diff --git a/.env.example b/.env.example index 5773a12..f2a04fd 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,7 @@ # Ansible Configuration ANSIBLE_VAULT_PASSWORD=your_vault_password_here -# BTCPay Server Configuration -BTCPAY_BASE_URL=https://your-btcpay-server.com -BTCPAY_STORE_ID=your_store_id -BTCPAY_WEBHOOK_SECRET=your_webhook_secret - # Flask Configuration FLASK_ENV=development FLASK_APP=app/handlers/webhook_handler.py -FLASK_DEBUG=1 - -# Server Configuration -VPN_SERVER_IP=your_server_ip -WIREGUARD_PORT=51820 +FLASK_DEBUG=1 \ No newline at end of file diff --git a/ansible/playbooks/vpn_cleanup.yml b/ansible/playbooks/vpn_cleanup.yml new file mode 100644 index 0000000..65ac0c9 --- /dev/null +++ b/ansible/playbooks/vpn_cleanup.yml @@ -0,0 +1,30 @@ +--- +- name: Cleanup expired VPN configuration + hosts: vpn_servers + become: yes + vars: + client_dir: /etc/wireguard/clients + wg_interface: wg0 + + tasks: + - name: Debug subscription ID + debug: + msg: "Cleaning up subscription ID: {{ subscription_id }}" + + - name: Remove client configuration directory + file: + path: "{{ client_dir }}/{{ subscription_id }}" + state: absent + + - name: Remove client from server config + blockinfile: + path: "/etc/wireguard/{{ wg_interface }}.conf" + marker: "# {mark} ANSIBLE MANAGED BLOCK FOR {{ subscription_id }}" + state: absent + notify: restart wireguard + + handlers: + - name: restart wireguard + service: + name: "wg-quick@{{ wg_interface }}" + state: restarted \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index c7a01d2..f99f452 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,9 @@ -from flask import Flask, request, jsonify +# app/__init__.py + +from flask import Flask, request, jsonify, render_template import logging from .handlers.webhook_handler import handle_payment_webhook +from .handlers.payment_handler import BTCPayHandler # Set up logging logging.basicConfig( @@ -10,10 +13,92 @@ logging.basicConfig( logger = logging.getLogger(__name__) app = Flask(__name__) +btcpay_handler = BTCPayHandler() +# Existing webhook route @app.route('/webhook/vpn', methods=['POST']) def handle_payment(): return handle_payment_webhook(request) +# Frontend routes +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/api/calculate-price', methods=['POST']) +def calculate_price(): + hours = request.json.get('hours', 0) + # Basic price calculation - adjust formula as needed + base_price = hours * 100 # 100 sats per hour + + # Apply volume discounts + if hours >= 720: # 1 month + base_price = base_price * 0.85 # 15% discount + elif hours >= 168: # 1 week + base_price = base_price * 0.90 # 10% discount + elif hours >= 24: # 1 day + base_price = base_price * 0.95 # 5% discount + + return jsonify({ + 'price': int(base_price), + 'duration': hours + }) + +@app.route('/create-invoice', methods=['POST']) +def create_invoice(): + try: + logger.info("Received invoice creation request") + data = request.json + logger.debug(f"Request data: {data}") + + # Validate input data + duration_hours = data.get('duration') + email = data.get('email') + + if not email: + logger.error("Email address missing from request") + return jsonify({'error': 'Email is required'}), 400 + + if not duration_hours: + logger.error("Duration missing from request") + return jsonify({'error': 'Duration is required'}), 400 + + try: + duration_hours = int(duration_hours) + except ValueError: + logger.error(f"Invalid duration value: {duration_hours}") + return jsonify({'error': 'Invalid duration value'}), 400 + + # Calculate price using same logic as calculate-price endpoint + base_price = duration_hours * 100 # 100 sats per hour + + if duration_hours >= 720: # 1 month + base_price = base_price * 0.85 # 15% discount + elif duration_hours >= 168: # 1 week + base_price = base_price * 0.90 # 10% discount + elif duration_hours >= 24: # 1 day + base_price = base_price * 0.95 # 5% discount + + amount_sats = int(base_price) + logger.info(f"Calculated price: {amount_sats} sats for {duration_hours} hours") + + # Create BTCPay invoice + invoice_data = btcpay_handler.create_invoice(amount_sats, duration_hours, email) + + if not invoice_data: + logger.error("Failed to create invoice - no data returned from BTCPayHandler") + return jsonify({'error': 'Failed to create invoice'}), 500 + + logger.info(f"Successfully created invoice with ID: {invoice_data.get('invoice_id')}") + return jsonify(invoice_data) + + except Exception as e: + logger.error(f"Error in create_invoice endpoint: {str(e)}") + return jsonify({'error': str(e)}), 500 + +@app.route('/payment/success') +def payment_success(): + return render_template('payment_success.html') + if __name__ == '__main__': app.run(host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/app/handlers/payment_handler.py b/app/handlers/payment_handler.py new file mode 100644 index 0000000..7fc8ec7 --- /dev/null +++ b/app/handlers/payment_handler.py @@ -0,0 +1,144 @@ +# app/handlers/payment_handler.py + +import logging +import requests +import os +from flask import jsonify +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from pathlib import Path +import traceback +from .webhook_handler import get_vault_values + +logger = logging.getLogger(__name__) + +class BTCPayHandler: + def __init__(self): + logger.info("Initializing BTCPayHandler") + try: + # Get configuration from Ansible vault + logger.debug("Retrieving vault values") + vault_values = get_vault_values() + + self.base_url = vault_values['btcpay_base_url'] + self.api_key = vault_values['btcpay_api_key'] + self.store_id = vault_values['btcpay_store_id'] + + # Email configuration + self.smtp_config = vault_values.get('smtp_config', {}) + + logger.info(f"BTCPayHandler initialized with base URL: {self.base_url}") + + except Exception as e: + logger.error(f"Failed to initialize BTCPayHandler: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def create_invoice(self, amount_sats, duration_hours, email): + """Create BTCPay invoice for VPN subscription""" + try: + logger.info(f"Creating invoice: {amount_sats} sats, {duration_hours}h for {email}") + + headers = { + 'Authorization': f'token {self.api_key}', + 'Content-Type': 'application/json' + } + + # Get app URL from environment or use a default + app_url = os.getenv('APP_BASE_URL', 'http://localhost:5000').rstrip('/') + logger.debug(f"Using app URL for redirect: {app_url}") + + payload = { + 'amount': amount_sats, + 'currency': 'SATS', + 'metadata': { + 'duration_hours': duration_hours, + 'email': email, + 'orderId': f'vpn_sub_{duration_hours}h', + }, + 'checkout': { + 'redirectURL': f'{app_url}/payment/success', + 'redirectAutomatically': True + } + } + + logger.debug(f"Sending request to BTCPay Server: {self.base_url}/api/v1/stores/{self.store_id}/invoices") + logger.debug(f"Request payload: {payload}") + + response = requests.post( + f'{self.base_url}/api/v1/stores/{self.store_id}/invoices', + headers=headers, + json=payload + ) + + logger.debug(f"BTCPay response status: {response.status_code}") + logger.debug(f"BTCPay response content: {response.text}") + + if response.status_code != 200: + logger.error(f"BTCPay invoice creation failed: {response.text}") + return None + + invoice_data = response.json() + logger.info(f"Successfully created invoice {invoice_data.get('id')}") + + return { + 'invoice_id': invoice_data['id'], + 'checkout_url': invoice_data['checkoutLink'] + } + + except Exception as e: + logger.error("Error creating BTCPay invoice:") + logger.error(traceback.format_exc()) + return None + + def send_confirmation_email(self, email, config_data): + """Send VPN configuration details via email""" + try: + logger.info(f"Sending confirmation email to {email}") + + if not self.smtp_config: + logger.warning("SMTP configuration not found in vault") + return False + + msg = MIMEMultipart() + msg['From'] = self.smtp_config['sender_email'] + msg['To'] = email + msg['Subject'] = "Your VPN Configuration" + + body = f""" + Thank you for subscribing to our VPN service! + + Please find your WireGuard configuration below: + + {config_data} + + Installation instructions: + 1. Install WireGuard client for your platform from https://www.wireguard.com/install/ + 2. Save the above configuration to a file named 'wg0.conf' + 3. Import the configuration file into your WireGuard client + + Need help? Reply to this email for support. + """ + + msg.attach(MIMEText(body, 'plain')) + + logger.debug("Connecting to SMTP server") + with smtplib.SMTP( + self.smtp_config['server'], + self.smtp_config.get('port', 587) + ) as server: + server.starttls() + server.login( + self.smtp_config['username'], + self.smtp_config['password'] + ) + server.send_message(msg) + + logger.info("Confirmation email sent successfully") + return True + + except Exception as e: + logger.error("Error sending confirmation email:") + logger.error(traceback.format_exc()) + return False \ No newline at end of file diff --git a/app/handlers/webhook_handler.py b/app/handlers/webhook_handler.py index 19d7a71..b3295f9 100644 --- a/app/handlers/webhook_handler.py +++ b/app/handlers/webhook_handler.py @@ -6,6 +6,8 @@ import logging import hmac import hashlib import yaml +import json +import datetime import traceback from pathlib import Path from dotenv import load_dotenv @@ -20,6 +22,8 @@ logger = logging.getLogger(__name__) BASE_DIR = Path(__file__).resolve().parent.parent.parent PLAYBOOK_PATH = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_provision.yml' +CLEANUP_PLAYBOOK = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_cleanup.yml' +SUBSCRIPTION_DB = BASE_DIR / 'data' / 'subscriptions.json' def get_vault_values(): """Get decrypted values from Ansible vault""" @@ -76,6 +80,107 @@ def verify_signature(payload_body, signature_header): logger.error(f"Signature verification failed: {str(e)}") return False +def load_subscriptions(): + """Load subscription data from JSON file""" + if not SUBSCRIPTION_DB.parent.exists(): + SUBSCRIPTION_DB.parent.mkdir(parents=True) + + if not SUBSCRIPTION_DB.exists(): + return {} + + with open(SUBSCRIPTION_DB, 'r') as f: + return json.load(f) + +def save_subscription(subscription_data): + """Save subscription data to JSON file""" + subscriptions = load_subscriptions() + sub_id = subscription_data['subscriptionId'] + subscriptions[sub_id] = subscription_data + + with open(SUBSCRIPTION_DB, 'w') as f: + json.dump(subscriptions, f, indent=2) + +def calculate_expiry(duration_hours): + """Calculate expiry date based on subscription duration""" + return (datetime.datetime.now() + + datetime.timedelta(hours=duration_hours)).isoformat() + +def run_ansible_playbook(invoice_id): + """Run the VPN provisioning playbook""" + vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '') + if not vault_pass: + raise Exception("Vault password not found in environment variables") + + with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file: + vault_pass_file.write(vault_pass) + vault_pass_file.flush() + + cmd = [ + 'ansible-playbook', + str(PLAYBOOK_PATH), + '-i', str(BASE_DIR / 'inventory.ini'), + '-e', f'invoice_id={invoice_id}', + '--vault-password-file', vault_pass_file.name, + '-vvv' + ] + + logger.info(f"Running ansible-playbook command: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + capture_output=True, + text=True + ) + + os.unlink(vault_pass_file.name) + return result + +def handle_subscription_status(data): + """Handle SubscriptionStatusUpdated webhook""" + sub_id = data['subscriptionId'] + status = data['status'] + + logger.info(f"Processing subscription status update: {sub_id} -> {status}") + + # Store subscription data + data['last_updated'] = datetime.datetime.now().isoformat() + save_subscription(data) + + if status != 'Active': + # Run cleanup playbook for inactive subscriptions + result = subprocess.run([ + 'ansible-playbook', + str(CLEANUP_PLAYBOOK), + '-i', str(BASE_DIR / 'inventory.ini'), + '-e', f'subscription_id={sub_id}', + '-vvv' + ], capture_output=True, text=True) + + if result.returncode != 0: + logger.error(f"Failed to clean up subscription {sub_id}: {result.stderr}") + + logger.info(f"Subscription {sub_id} is no longer active") + + return jsonify({ + "status": "success", + "message": f"Subscription {sub_id} status updated to {status}" + }) + +def handle_subscription_renewal(data): + """Handle SubscriptionRenewalRequested webhook""" + sub_id = data['subscriptionId'] + logger.info(f"Processing subscription renewal request: {sub_id}") + + # Update subscription data + data['renewal_requested'] = datetime.datetime.now().isoformat() + save_subscription(data) + + # TODO: Send renewal notification to user + return jsonify({ + "status": "success", + "message": f"Subscription {sub_id} renewal requested" + }) + def handle_payment_webhook(request): """Handle BTCPay Server webhook for VPN provisioning""" try: @@ -94,66 +199,100 @@ def handle_payment_webhook(request): data = request.json logger.info(f"Received webhook data: {data}") + webhook_type = data.get('type') - invoice_id = data.get('invoiceId') - if not invoice_id: - logger.error("Missing invoiceId in webhook data") - return jsonify({"error": "Missing invoiceId"}), 400 + if webhook_type == 'SubscriptionStatusUpdated': + return handle_subscription_status(data) - if invoice_id.startswith('__test__') and invoice_id.endswith('__test__'): - invoice_id = invoice_id[8:-8] - logger.info(f"Stripped test markers from invoice ID: {invoice_id}") - - vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '') - if not vault_pass: - raise Exception("Vault password not found in environment variables") - - with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file: - vault_pass_file.write(vault_pass) - vault_pass_file.flush() + elif webhook_type == 'SubscriptionRenewalRequested': + return handle_subscription_renewal(data) - cmd = [ - 'ansible-playbook', - str(PLAYBOOK_PATH), - '-i', str(BASE_DIR / 'inventory.ini'), - '-e', f'invoice_id={invoice_id}', - '--vault-password-file', vault_pass_file.name, - '-vvv' - ] + elif webhook_type == 'InvoiceSettled': + # Handle regular invoice payment + invoice_id = data.get('invoiceId') + metadata = data.get('metadata', {}) - logger.info(f"Running ansible-playbook command: {' '.join(cmd)}") + if not invoice_id: + logger.error("Missing invoiceId in webhook data") + return jsonify({"error": "Missing invoiceId"}), 400 + + if invoice_id.startswith('__test__') and invoice_id.endswith('__test__'): + invoice_id = invoice_id[8:-8] + logger.info(f"Stripped test markers from invoice ID: {invoice_id}") - result = subprocess.run( - cmd, - capture_output=True, - text=True - ) - - os.unlink(vault_pass_file.name) - - logger.info(f"Ansible playbook stdout: {result.stdout}") - if result.stderr: - logger.error(f"Ansible playbook stderr: {result.stderr}") - - if result.returncode != 0: - error_msg = f"Ansible playbook failed with return code {result.returncode}" - logger.error(error_msg) + # Run Ansible playbook with enhanced logging + logger.info(f"Starting VPN provisioning for invoice {invoice_id}") + result = run_ansible_playbook(invoice_id) + + if result.returncode != 0: + error_msg = f"Ansible playbook failed with return code {result.returncode}" + logger.error(error_msg) + logger.error(f"Ansible stdout: {result.stdout}") + logger.error(f"Ansible stderr: {result.stderr}") + return jsonify({ + "error": "Provisioning failed", + "details": error_msg, + "stdout": result.stdout, + "stderr": result.stderr + }), 500 + + logger.info(f"VPN provisioning completed for invoice {invoice_id}") + + # Update subscription database + try: + duration_hours = metadata.get('duration_hours', 24) + subscriptions = load_subscriptions() + subscriptions[invoice_id] = { + 'email': metadata.get('email'), + 'duration_hours': duration_hours, + 'start_time': datetime.datetime.now().isoformat(), + 'expiry': calculate_expiry(duration_hours), + 'status': 'Active' + } + with open(SUBSCRIPTION_DB, 'w') as f: + json.dump(subscriptions, f, indent=2) + logger.info(f"Updated subscription database for invoice {invoice_id}") + except Exception as e: + logger.error(f"Error updating subscription database: {str(e)}") + + # Send email confirmation if email is provided + try: + email = metadata.get('email') + if email: + config_path = f"/etc/wireguard/clients/{invoice_id}/wg0.conf" + if os.path.exists(config_path): + with open(config_path, 'r') as f: + config_data = f.read() + + btcpay_handler = BTCPayHandler() + email_sent = btcpay_handler.send_confirmation_email(email, config_data) + if email_sent: + logger.info(f"Sent configuration email to {email}") + else: + logger.warning(f"Failed to send configuration email to {email}") + else: + logger.warning(f"Config file not found at {config_path}") + except Exception as e: + logger.error(f"Error sending confirmation email: {str(e)}") + + logger.info(f"Successfully processed invoice {invoice_id}") return jsonify({ - "error": "Provisioning failed", - "details": error_msg, - "stdout": result.stdout, - "stderr": result.stderr - }), 500 + "status": "success", + "invoice_id": invoice_id, + "message": "VPN provisioning completed" + }) + + else: + # Log other webhook types as info instead of warning + logger.info(f"Received {webhook_type} webhook - no action required") + return jsonify({ + "status": "success", + "message": f"Webhook {webhook_type} acknowledged" + }) - logger.info(f"Successfully processed invoice {invoice_id}") - return jsonify({ - "status": "success", - "invoice_id": invoice_id, - "message": "VPN provisioning initiated" - }) - except Exception as e: logger.error(f"Error processing webhook: {str(e)}") + logger.error(traceback.format_exc()) return jsonify({ "error": str(e), "traceback": traceback.format_exc() diff --git a/app/static/js/pricing.js b/app/static/js/pricing.js new file mode 100644 index 0000000..5169466 --- /dev/null +++ b/app/static/js/pricing.js @@ -0,0 +1,84 @@ +// app/static/js/pricing.js +document.addEventListener('DOMContentLoaded', function() { + const form = document.getElementById('subscription-form'); + const slider = document.getElementById('duration-slider'); + const durationDisplay = document.getElementById('duration-display'); + const priceDisplay = document.getElementById('price-display'); + const presetButtons = document.querySelectorAll('.duration-preset'); + const emailInput = document.getElementById('email'); + + function formatDuration(hours) { + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'}`; + if (hours < 168) return `${hours / 24} day${hours === 24 ? '' : 's'}`; + if (hours < 720) return `${Math.floor(hours / 168)} week${hours === 168 ? '' : 's'}`; + return `${Math.floor(hours / 720)} month${hours === 720 ? '' : 's'}`; + } + + async function updatePrice(hours) { + try { + const response = await fetch('/api/calculate-price', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hours: parseInt(hours) }) + }); + const data = await response.json(); + priceDisplay.textContent = data.price; + durationDisplay.textContent = formatDuration(hours); + } catch (error) { + console.error('Error calculating price:', error); + } + } + + async function createInvoice(duration, email) { + try { + const response = await fetch('/create-invoice', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + duration: parseInt(duration), + email: email + }) + }); + + if (!response.ok) { + throw new Error('Failed to create invoice'); + } + + const data = await response.json(); + window.location.href = data.checkout_url; + + } catch (error) { + console.error('Error creating invoice:', error); + alert('Failed to create payment invoice. Please try again.'); + } + } + + // Event listeners + slider.addEventListener('input', () => updatePrice(slider.value)); + + presetButtons.forEach(button => { + button.addEventListener('click', (e) => { + const hours = e.target.dataset.hours; + slider.value = hours; + updatePrice(hours); + }); + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const email = emailInput.value.trim(); + if (!email) { + alert('Please enter your email address'); + return; + } + + const duration = slider.value; + await createInvoice(duration, email); + }); + + // Initial price calculation + updatePrice(slider.value); +}); \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..8f3b5b6 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,25 @@ + + + + + + VPN Service + + + + + + {% block content %}{% endblock %} + + \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..2125312 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Subscribe to VPN Service

+ +
+
+ + +
+ +
+ +
+ +
+ + + + +
+
+

+
+ +
+

+ - sats +

+
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/payment_success.html b/app/templates/payment_success.html new file mode 100644 index 0000000..05bd9ba --- /dev/null +++ b/app/templates/payment_success.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Payment Successful!

+

+ Thank you for your payment. Your VPN configuration will be sent to your email shortly. +

+

+ Please check your email for further instructions on setting up your VPN connection. +

+ + Return to Home + +
+
+{% endblock %} \ No newline at end of file diff --git a/data/subscriptions.json b/data/subscriptions.json new file mode 100644 index 0000000..961769a --- /dev/null +++ b/data/subscriptions.json @@ -0,0 +1,92 @@ +{ + "__test__ee2b820c-d1a0-4c4d-8b7c-0e5550fbb42e__test__": { + "deliveryId": "P4su7aNvuaa7mGEJLFJ5mk", + "webhookId": "CoGqJKKuE3838AWZQkncSJ", + "originalDeliveryId": "__test__73469547-03db-4961-9d2f-da9b1ed7519f__test__", + "isRedelivery": false, + "type": "SubscriptionRenewalRequested", + "timestamp": 1733961630, + "storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY", + "appId": "__test__dda0ab87-3962-435e-9cf6-2c9749dd21dc__test__", + "subscriptionId": "__test__ee2b820c-d1a0-4c4d-8b7c-0e5550fbb42e__test__", + "status": "Active", + "paymentRequestId": null, + "email": null, + "renewal_requested": "2024-12-12T00:00:31.600171" + }, + "__test__f3e4e7cf-304b-4a97-a341-9e78e0f0bf7f__test__": { + "deliveryId": "7BPjwRNtFd2k3kzQLH8RoJ", + "webhookId": "CoGqJKKuE3838AWZQkncSJ", + "originalDeliveryId": "__test__50e7d2ea-18d3-46b0-bad3-fc5475a9994f__test__", + "isRedelivery": false, + "type": "SubscriptionRenewalRequested", + "timestamp": 1733972813, + "storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY", + "appId": "__test__aa25dc16-84ac-4687-a2e9-41a97c5cc112__test__", + "subscriptionId": "__test__f3e4e7cf-304b-4a97-a341-9e78e0f0bf7f__test__", + "status": "Active", + "paymentRequestId": null, + "email": null, + "renewal_requested": "2024-12-12T03:06:54.340514" + }, + "__test__0caaab4d-026d-4df6-b09b-164fa0edde79__test__": { + "deliveryId": "PhRMLk717pAWj2L1Z3LZtr", + "webhookId": "CoGqJKKuE3838AWZQkncSJ", + "originalDeliveryId": "__test__36b2350d-45df-4fe0-a4fa-c701684c9209__test__", + "isRedelivery": false, + "type": "SubscriptionStatusUpdated", + "timestamp": 1733972824, + "storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY", + "appId": "__test__7dc7f4d8-020a-41b6-b728-84ca1ca0ab4c__test__", + "subscriptionId": "__test__0caaab4d-026d-4df6-b09b-164fa0edde79__test__", + "status": "Active", + "paymentRequestId": null, + "email": null, + "last_updated": "2024-12-12T03:07:05.439848" + }, + "__test__9aa786d3-053c-4b70-8b01-6df0f0207b79__test__": { + "deliveryId": "U7stmUDcB4qseDh29mhke7", + "webhookId": "CoGqJKKuE3838AWZQkncSJ", + "originalDeliveryId": "__test__c5eba94d-ea9e-46e9-b127-74234fcd7002__test__", + "isRedelivery": false, + "type": "SubscriptionStatusUpdated", + "timestamp": 1733973182, + "storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY", + "appId": "__test__c3149b37-ad19-428a-aa95-251adc96e5ae__test__", + "subscriptionId": "__test__9aa786d3-053c-4b70-8b01-6df0f0207b79__test__", + "status": "Active", + "paymentRequestId": null, + "email": null, + "last_updated": "2024-12-12T03:13:03.056437" + }, + "__test__d57b8102-87f7-4143-b175-32353b6eaec7__test__": { + "deliveryId": "834dSuc5bdeXF2zRxovj2x", + "webhookId": "CoGqJKKuE3838AWZQkncSJ", + "originalDeliveryId": "__test__16c072e3-5702-4f57-bc32-7181f585bef2__test__", + "isRedelivery": false, + "type": "SubscriptionStatusUpdated", + "timestamp": 1733973198, + "storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY", + "appId": "__test__59579b1c-c393-4fa4-b4be-6691b7a6db27__test__", + "subscriptionId": "__test__d57b8102-87f7-4143-b175-32353b6eaec7__test__", + "status": "Active", + "paymentRequestId": null, + "email": null, + "last_updated": "2024-12-12T03:13:19.677073" + }, + "__test__5f28edc1-1c9e-4f1a-9fe9-591bc4ae7988__test__": { + "deliveryId": "9LUJAQmU89k8ofSKc7M7Kt", + "webhookId": "CoGqJKKuE3838AWZQkncSJ", + "originalDeliveryId": "__test__c274b638-568b-459f-b4f4-fd10029f5dce__test__", + "isRedelivery": false, + "type": "SubscriptionRenewalRequested", + "timestamp": 1733973203, + "storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY", + "appId": "__test__6896db98-91f3-458e-b8c8-6c6280a5698f__test__", + "subscriptionId": "__test__5f28edc1-1c9e-4f1a-9fe9-591bc4ae7988__test__", + "status": "Active", + "paymentRequestId": null, + "email": null, + "renewal_requested": "2024-12-12T03:13:24.040298" + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 10403b8..99ca526 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ flask==3.0.0 pyyaml==6.0.1 python-dotenv==1.0.0 -cryptography==41.0.7 # For ansible-vault operations +cryptography==41.0.7 ansible==9.1.0 +requests==2.31.0 \ No newline at end of file diff --git a/scripts/subscription_checker.py b/scripts/subscription_checker.py new file mode 100644 index 0000000..e3c3acc --- /dev/null +++ b/scripts/subscription_checker.py @@ -0,0 +1,253 @@ +# scripts/subscription_checker.py + +import json +import datetime +import logging +import subprocess +import os +import tempfile +from pathlib import Path +from dateutil.parser import parse +from dateutil.relativedelta import relativedelta + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Path setup +SCRIPT_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = SCRIPT_DIR.parent +SUBSCRIPTION_DB = PROJECT_ROOT / 'data' / 'subscriptions.json' +CLEANUP_PLAYBOOK = PROJECT_ROOT / 'ansible' / 'playbooks' / 'vpn_cleanup.yml' +INVENTORY_FILE = PROJECT_ROOT / 'inventory.ini' + +# Notification thresholds configuration +NOTIFICATION_THRESHOLDS = { + 'minimum_duration': datetime.timedelta(hours=1), # Minimum subscription duration + 'short_term': { + 'max_duration': datetime.timedelta(days=1), + 'warning_fraction': 0.5, # Warn when 50% of time remains + 'grace_fraction': 0.1 # Grace period of 10% of subscription length + }, + 'medium_term': { + 'max_duration': datetime.timedelta(days=7), + 'warning_fraction': 0.25, # Warn when 25% of time remains + 'grace_hours': 12 # Fixed 12-hour grace period + }, + 'long_term': { + 'warning_days': 7, # 7 days warning for longer subscriptions + 'grace_days': 2 # 2 days grace period + } +} + +def load_subscriptions(): + """Load subscription data from JSON file""" + if not SUBSCRIPTION_DB.parent.exists(): + SUBSCRIPTION_DB.parent.mkdir(parents=True) + + if not SUBSCRIPTION_DB.exists(): + return {} + + with open(SUBSCRIPTION_DB, 'r') as f: + return json.load(f) + +def save_subscriptions(subscriptions): + """Save subscriptions to JSON file""" + with open(SUBSCRIPTION_DB, 'w') as f: + json.dump(subscriptions, f, indent=2) + +def run_cleanup_playbook(sub_id): + """Run the VPN cleanup playbook""" + logger.info(f"Running cleanup playbook for subscription {sub_id}") + + vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '') + if not vault_pass: + raise Exception("Vault password not found in environment variables") + + with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file: + vault_pass_file.write(vault_pass) + vault_pass_file.flush() + + cmd = [ + 'ansible-playbook', + str(CLEANUP_PLAYBOOK), + '-i', str(INVENTORY_FILE), + '-e', f'subscription_id={sub_id}', + '--vault-password-file', vault_pass_file.name, + '-vvv' + ] + + logger.debug(f"Running command: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + capture_output=True, + text=True + ) + + if result.returncode != 0: + logger.error(f"Cleanup playbook failed: {result.stderr}") + else: + logger.debug(f"Cleanup playbook output: {result.stdout}") + + os.unlink(vault_pass_file.name) + return result + +def calculate_notification_times(start_time, end_time): + """ + Calculate warning and grace period times based on subscription duration + + Returns: + tuple: (warning_time, grace_end_time) + """ + duration = end_time - start_time + + # Handle extremely short subscriptions + if duration < NOTIFICATION_THRESHOLDS['minimum_duration']: + warning_time = start_time # Immediate warning + grace_end_time = end_time # No grace period + return warning_time, grace_end_time + + # Short-term subscriptions (less than 1 day) + if duration <= NOTIFICATION_THRESHOLDS['short_term']['max_duration']: + warning_delta = duration * NOTIFICATION_THRESHOLDS['short_term']['warning_fraction'] + grace_delta = duration * NOTIFICATION_THRESHOLDS['short_term']['grace_fraction'] + warning_time = end_time - warning_delta + grace_end_time = end_time + grace_delta + + # Medium-term subscriptions (1-7 days) + elif duration <= NOTIFICATION_THRESHOLDS['medium_term']['max_duration']: + warning_delta = duration * NOTIFICATION_THRESHOLDS['medium_term']['warning_fraction'] + grace_hours = NOTIFICATION_THRESHOLDS['medium_term']['grace_hours'] + warning_time = end_time - warning_delta + grace_end_time = end_time + datetime.timedelta(hours=grace_hours) + + # Long-term subscriptions (> 7 days) + else: + warning_days = NOTIFICATION_THRESHOLDS['long_term']['warning_days'] + grace_days = NOTIFICATION_THRESHOLDS['long_term']['grace_days'] + warning_time = end_time - datetime.timedelta(days=warning_days) + grace_end_time = end_time + datetime.timedelta(days=grace_days) + + return warning_time, grace_end_time + +def get_notification_message(sub_data, remaining_time): + """Generate appropriate notification message based on subscription duration""" + if remaining_time < datetime.timedelta(hours=1): + return f"Your VPN subscription expires in {int(remaining_time.total_seconds() / 60)} minutes!" + elif remaining_time < datetime.timedelta(days=1): + return f"Your VPN subscription expires in {int(remaining_time.total_seconds() / 3600)} hours!" + else: + return f"Your VPN subscription expires in {remaining_time.days} days!" + +def notify_user(user_id, message): + """Send notification to user about subscription status""" + try: + subscriptions = load_subscriptions() + sub_data = subscriptions.get(user_id) + + if not sub_data or 'email' not in sub_data: + logger.error(f"No email found for user {user_id}") + return False + + # Import BTCPayHandler here to avoid circular imports + from app.handlers.payment_handler import BTCPayHandler + + btcpay_handler = BTCPayHandler() + email_sent = btcpay_handler.send_confirmation_email( + sub_data['email'], + f""" + VPN Subscription Update + + {message} + + If you wish to continue using the VPN service, please renew your subscription. + """ + ) + + if email_sent: + logger.info(f"Sent notification to {sub_data['email']}: {message}") + else: + logger.warning(f"Failed to send notification to {sub_data['email']}") + + return email_sent + + except Exception as e: + logger.error(f"Error sending notification: {str(e)}") + return False + +def check_subscriptions(): + """Check subscription status and clean up expired ones""" + logger.info("Starting subscription check") + + subscriptions = load_subscriptions() + now = datetime.datetime.now() + modified = False + + logger.info(f"Checking {len(subscriptions)} subscriptions") + + for sub_id, sub_data in list(subscriptions.items()): + try: + logger.debug(f"Processing subscription {sub_id}") + + if sub_data.get('status') != 'Active': + logger.debug(f"Skipping inactive subscription {sub_id}") + continue + + start_time = parse(sub_data['start_time']) + expiry = parse(sub_data['expiry']) + warning_time, grace_end_time = calculate_notification_times(start_time, expiry) + + # Calculate remaining time + remaining_time = expiry - now + logger.debug(f"Subscription {sub_id} has {remaining_time} remaining") + + # Handle warnings + if now >= warning_time and not sub_data.get('warning_sent'): + message = get_notification_message(sub_data, remaining_time) + logger.info(f"Sending notification for subscription {sub_id}: {message}") + + notify_user(sub_id, message) + + sub_data['warning_sent'] = True + modified = True + + # Handle expiration + if now >= grace_end_time and sub_data.get('status') == 'Active': + logger.info(f"Processing expiration for subscription {sub_id}") + + try: + logger.debug(f"Running cleanup playbook for {sub_id}") + result = run_cleanup_playbook(sub_id) + + if result.returncode == 0: + sub_data['status'] = 'Expired' + sub_data['cleanup_date'] = now.isoformat() + modified = True + logger.info(f"Successfully cleaned up subscription {sub_id}") + else: + logger.error(f"Cleanup failed: {result.stderr}") + + except Exception as e: + logger.error(f"Error during cleanup: {str(e)}") + logger.error(traceback.format_exc()) + + except Exception as e: + logger.error(f"Error processing subscription {sub_id}: {str(e)}") + logger.error(traceback.format_exc()) + continue + + if modified: + save_subscriptions(subscriptions) + logger.info("Updated subscription database") + + logger.info("Subscription check completed") + +if __name__ == '__main__': + try: + check_subscriptions() + except Exception as e: + logger.error(f"Subscription checker failed: {str(e)}") + raise \ No newline at end of file diff --git a/vault_pass.txt b/vault_pass.txt new file mode 100644 index 0000000..c9b2789 --- /dev/null +++ b/vault_pass.txt @@ -0,0 +1 @@ +Q1w2e3r4t5 diff --git a/venv/bin/normalizer b/venv/bin/normalizer new file mode 100755 index 0000000..7ff22d8 --- /dev/null +++ b/venv/bin/normalizer @@ -0,0 +1,8 @@ +#!/home/hashgate/vpn-btcpay-provisioner/venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from charset_normalizer.cli import cli_detect +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(cli_detect())