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 @@ + + +
+ + ++ 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 + +