payment flow / provsion is working. need to test wireguard

This commit is contained in:
Enki 2025-01-09 20:52:46 +00:00
parent b44860a0ab
commit 9941aa93f6
18 changed files with 1362 additions and 831 deletions

View File

@ -1,5 +1,5 @@
[Interface] [Interface]
PrivateKey = {{ client_private_key.stdout }} PrivateKey = {{ client_private_key }}
Address = {{ client_ip }}/24 Address = {{ client_ip }}/24
DNS = 1.1.1.1 DNS = 1.1.1.1

View File

@ -4,16 +4,25 @@
become: yes become: yes
vars: vars:
client_dir: /etc/wireguard/clients client_dir: /etc/wireguard/clients
test_client_dir: /etc/wireguard/test_clients
wg_interface: wg0 wg_interface: wg0
is_test: false # Default to production mode
tasks: tasks:
- name: Debug subscription ID - name: Debug cleanup information
debug: debug:
msg: "Cleaning up subscription ID: {{ subscription_id }}" msg:
- "Cleaning up subscription ID: {{ subscription_id }}"
- "Test mode: {{ is_test }}"
# Set working directory based on mode
- name: Set working directory based on mode
set_fact:
working_client_dir: "{{ test_client_dir if is_test else client_dir }}"
- name: Remove client configuration directory - name: Remove client configuration directory
file: file:
path: "{{ client_dir }}/{{ subscription_id }}" path: "{{ working_client_dir }}/{{ subscription_id }}"
state: absent state: absent
- name: Remove client from server config - name: Remove client from server config
@ -22,6 +31,17 @@
marker: "# {mark} ANSIBLE MANAGED BLOCK FOR {{ subscription_id }}" marker: "# {mark} ANSIBLE MANAGED BLOCK FOR {{ subscription_id }}"
state: absent state: absent
notify: restart wireguard notify: restart wireguard
# Remove cleanup cron job if it exists (for test configs)
- name: Remove cleanup cronjob
when: is_test
cron:
name: "cleanup_test_vpn_{{ subscription_id }}"
state: absent
- name: Log cleanup
shell: |
logger -t vpn-cleanup "Cleaned up VPN configuration for {{ subscription_id }} ({{ 'test' if is_test else 'production' }})"
handlers: handlers:
- name: restart wireguard - name: restart wireguard

View File

@ -4,18 +4,74 @@
become: yes become: yes
vars: vars:
client_dir: /etc/wireguard/clients client_dir: /etc/wireguard/clients
test_client_dir: /etc/wireguard/test_clients
wg_interface: wg0 wg_interface: wg0
server_dir: /etc/wireguard server_dir: /etc/wireguard
server_ip: 10.8.0.1/24 server_ip: 10.8.0.1/24
server_port: 51820 server_port: 51820
server_endpoint: "{{ ansible_host | default(inventory_hostname) }}" server_endpoint: "{{ ansible_host | default(inventory_hostname) }}"
is_test: false # Default to production mode
test_duration_minutes: 30 # Default test duration
pre_tasks:
- name: Check if WireGuard is installed
package_facts:
manager: auto
- name: Install WireGuard (Debian/Ubuntu)
apt:
name:
- wireguard
- wireguard-tools
state: present
update_cache: yes
when:
- ansible_facts['os_family'] == "Debian"
- "'wireguard' not in ansible_facts.packages"
- name: Install WireGuard (RHEL/CentOS)
dnf:
name:
- wireguard-tools
- wireguard-dkms
state: present
when:
- ansible_facts['os_family'] == "RedHat"
- "'wireguard-tools' not in ansible_facts.packages"
- name: Ensure WireGuard kernel module is loaded
modprobe:
name: wireguard
state: present
- name: Verify WireGuard installation
command: which wg
register: wg_check
failed_when: wg_check.rc != 0
changed_when: false
tasks: tasks:
- name: Debug invoice ID - name: Debug invoice ID and test status
debug: debug:
msg: "Processing invoice ID: {{ invoice_id }}" msg:
- "Processing invoice ID: {{ invoice_id }}"
- "Test mode: {{ is_test }}"
- "Test duration: {{ test_duration_minutes if is_test else 'N/A' }}"
- name: Create required directories
file:
path: "{{ item }}"
state: directory
mode: '0700'
with_items:
- "{{ client_dir }}"
- "{{ test_client_dir }}"
- "{{ server_dir }}"
- name: Set working directory based on mode
set_fact:
working_client_dir: "{{ test_client_dir if is_test else client_dir }}"
# Server Setup Tasks
- name: Check if server keys exist - name: Check if server keys exist
stat: stat:
path: "{{ server_dir }}/{{ wg_interface }}.conf" path: "{{ server_dir }}/{{ wg_interface }}.conf"
@ -45,58 +101,43 @@
mode: '0644' mode: '0644'
when: not server_config.stat.exists when: not server_config.stat.exists
- name: Update vault with server details
block:
- name: Read server public key
shell: "cat {{ server_dir }}/public.key"
register: pubkey_content
changed_when: false
- name: Save server details to vault
copy:
content: |
wireguard_server_public_key: "{{ pubkey_content.stdout }}"
wireguard_server_endpoint: "{{ ansible_host }}"
dest: "{{ playbook_dir }}/../group_vars/vpn_servers/vault.yml"
mode: '0600'
when: not server_config.stat.exists
- name: Create initial server config - name: Create initial server config
template: template:
src: templates/server.conf.j2 src: templates/server.conf.j2
dest: "{{ server_dir }}/{{ wg_interface }}.conf" dest: "{{ server_dir }}/{{ wg_interface }}.conf"
mode: '0600' mode: '0600'
when: not server_config.stat.exists when: not server_config.stat.exists
notify: restart wireguard
# Client Setup Tasks
- name: Ensure client directory exists - name: Ensure client directory exists
file: file:
path: "{{ client_dir }}/{{ invoice_id }}" path: "{{ working_client_dir }}/{{ invoice_id }}"
state: directory state: directory
mode: '0700' mode: '0700'
- name: Generate client private key # Generate keys - no longer differentiating between test and production
- name: Generate private key
shell: wg genkey shell: wg genkey
register: client_private_key register: private_key
no_log: true changed_when: false
- name: Save client private key - name: Generate public key
shell: echo "{{ private_key.stdout }}" | wg pubkey
register: public_key
changed_when: false
- name: Save private key
copy: copy:
content: "{{ client_private_key.stdout }}" content: "{{ private_key.stdout }}"
dest: "{{ client_dir }}/{{ invoice_id }}/private.key" dest: "{{ working_client_dir }}/{{ invoice_id }}/private.key"
mode: '0600' mode: '0600'
no_log: true
- name: Save public key
- name: Generate client public key
shell: "echo '{{ client_private_key.stdout }}' | wg pubkey"
register: client_public_key
- name: Save client public key
copy: copy:
content: "{{ client_public_key.stdout }}" content: "{{ public_key.stdout }}"
dest: "{{ client_dir }}/{{ invoice_id }}/public.key" dest: "{{ working_client_dir }}/{{ invoice_id }}/public.key"
mode: '0644' mode: '0644'
- name: Read server public key - name: Read server public key
shell: "cat {{ server_dir }}/public.key" shell: "cat {{ server_dir }}/public.key"
register: server_public_key_read register: server_public_key_read
@ -104,18 +145,19 @@
- name: Get next available IP - name: Get next available IP
shell: | shell: |
last_ip=$(grep -h '^Address' {{ client_dir }}/*/wg0.conf 2>/dev/null | tail -n1 | grep -oE '[0-9]+$' || echo 1) last_ip=$(grep -h '^Address' {{ working_client_dir }}/*/wg0.conf 2>/dev/null | tail -n1 | grep -oE '[0-9]+$' || echo 1)
echo $((last_ip + 1)) echo $((last_ip + 1))
register: next_ip register: next_ip
- name: Generate client config - name: Generate client config
template: template:
src: templates/client.conf.j2 src: templates/client.conf.j2
dest: "{{ client_dir }}/{{ invoice_id }}/wg0.conf" dest: "{{ working_client_dir }}/{{ invoice_id }}/wg0.conf"
mode: '0600' mode: '0600'
vars: vars:
client_ip: "10.8.0.{{ next_ip.stdout }}" client_ip: "10.8.0.{{ next_ip.stdout }}"
server_pubkey: "{{ server_public_key_read.stdout }}" server_pubkey: "{{ server_public_key_read.stdout }}"
client_private_key: "{{ private_key.stdout }}"
- name: Add client to server config - name: Add client to server config
blockinfile: blockinfile:
@ -123,12 +165,33 @@
marker: "# {mark} ANSIBLE MANAGED BLOCK FOR {{ invoice_id }}" marker: "# {mark} ANSIBLE MANAGED BLOCK FOR {{ invoice_id }}"
block: | block: |
[Peer] [Peer]
PublicKey = {{ client_public_key.stdout }} PublicKey = {{ public_key.stdout }}
AllowedIPs = 10.8.0.{{ next_ip.stdout }}/32 AllowedIPs = 10.8.0.{{ next_ip.stdout }}/32
{% if is_test %}# Test config expires: {{ ansible_date_time.iso8601 }}{% endif %}
notify: restart wireguard notify: restart wireguard
# Calculate cleanup time for test configurations
- name: Calculate cleanup time
when: is_test
set_fact:
cleanup_minute: "{{ (ansible_date_time.minute | int + (test_duration_minutes | int)) % 60 }}"
cleanup_hour: "{{ (ansible_date_time.hour | int + ((ansible_date_time.minute | int + (test_duration_minutes | int)) // 60)) % 24 }}"
- name: Add cleanup cronjob for test configs
when: is_test
cron:
name: "cleanup_test_vpn_{{ invoice_id }}"
minute: "{{ cleanup_minute }}"
hour: "{{ cleanup_hour }}"
job: "ansible-playbook {{ playbook_dir }}/vpn_cleanup.yml -e 'invoice_id={{ invoice_id }} is_test=true'"
state: present
- name: Log provision completion
shell: |
logger -t vpn-provision "Provisioned VPN for {{ invoice_id }} ({{ 'test' if is_test else 'production' }}){% if is_test %} - expires in {{ test_duration_minutes }} minutes{% endif %}"
handlers: handlers:
- name: restart wireguard - name: restart wireguard
service: service:
name: wg-quick@{{ wg_interface }} name: "wg-quick@{{ wg_interface }}"
state: restarted state: restarted

View File

@ -1,7 +1,9 @@
from flask import Flask, request, jsonify, render_template from flask import Flask, request, jsonify, render_template
import logging import logging
from pathlib import Path
from .handlers.webhook_handler import handle_payment_webhook from .handlers.webhook_handler import handle_payment_webhook
from .handlers.payment_handler import BTCPayHandler from .handlers.payment_handler import BTCPayHandler
from .utils.db.operations import DatabaseManager
# Set up logging # Set up logging
logging.basicConfig( logging.basicConfig(
@ -45,29 +47,37 @@ def calculate_price():
@app.route('/create-invoice', methods=['POST']) @app.route('/create-invoice', methods=['POST'])
def create_invoice(): def create_invoice():
try: try:
logger.info("Received invoice creation request") logger.info("=== Create Invoice Request Started ===")
logger.info(f"Received invoice creation request with data: {request.json}")
data = request.json data = request.json
logger.debug(f"Request data: {data}") logger.debug(f"Request data: {data}")
# Validate input data # Validate input data
duration_hours = data.get('duration') duration_hours = data.get('duration')
email = data.get('email') user_id = data.get('user_id')
public_key = data.get('public_key')
if not email:
logger.error("Email address missing from request") logger.info(f"Validating request parameters: duration={duration_hours}, user_id={user_id}, has_public_key={bool(public_key)}")
return jsonify({'error': 'Email is required'}), 400
# Validate required fields
if not duration_hours: if not duration_hours:
logger.error("Duration missing from request") logger.error("Duration missing from request")
return jsonify({'error': 'Duration is required'}), 400 return jsonify({'error': 'Duration is required'}), 400
if not user_id:
logger.error("User ID missing from request")
return jsonify({'error': 'User ID is required'}), 400
if not public_key:
logger.error("Public key missing from request")
return jsonify({'error': 'Public key is required'}), 400
try: try:
duration_hours = int(duration_hours) duration_hours = int(duration_hours)
logger.info(f"Converted duration to integer: {duration_hours}")
except ValueError: except ValueError:
logger.error(f"Invalid duration value: {duration_hours}") logger.error(f"Invalid duration value: {duration_hours}")
return jsonify({'error': 'Invalid duration value'}), 400 return jsonify({'error': 'Invalid duration value'}), 400
# Calculate price using same logic as calculate-price endpoint # Calculate price
base_price = duration_hours * 100 # 100 sats per hour base_price = duration_hours * 100 # 100 sats per hour
if duration_hours >= 720: # 1 month if duration_hours >= 720: # 1 month
@ -76,24 +86,69 @@ def create_invoice():
base_price = base_price * 0.90 # 10% discount base_price = base_price * 0.90 # 10% discount
elif duration_hours >= 24: # 1 day elif duration_hours >= 24: # 1 day
base_price = base_price * 0.95 # 5% discount base_price = base_price * 0.95 # 5% discount
amount_sats = int(base_price) amount_sats = int(base_price)
logger.info(f"Calculated price: {amount_sats} sats for {duration_hours} hours") logger.info(f"Calculated price: {amount_sats} sats for {duration_hours} hours")
# Create BTCPay invoice # Create BTCPay invoice
invoice_data = btcpay_handler.create_invoice(amount_sats, duration_hours, email) logger.info("Creating BTCPay invoice")
invoice_data = btcpay_handler.create_invoice(
amount_sats=amount_sats,
duration_hours=duration_hours,
user_id=user_id,
public_key=public_key
)
if not invoice_data: if not invoice_data:
logger.error("Failed to create invoice - no data returned from BTCPayHandler") logger.error("Failed to create invoice - no data returned from BTCPayHandler")
return jsonify({'error': 'Failed to create invoice'}), 500 return jsonify({'error': 'Failed to create invoice'}), 500
logger.info(f"Successfully created invoice with ID: {invoice_data.get('invoice_id')}") logger.info(f"Successfully created invoice with ID: {invoice_data.get('invoice_id')}")
logger.info("=== Create Invoice Request Completed ===")
return jsonify(invoice_data) return jsonify(invoice_data)
except Exception as e: except Exception as e:
logger.error(f"Error in create_invoice endpoint: {str(e)}") logger.error(f"Error in create_invoice endpoint: {str(e)}")
logger.error(f"Traceback: ", exc_info=True)
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/api/vpn-config/<user_id>')
def get_vpn_config(user_id):
try:
logger.info(f"Fetching VPN config for user: {user_id}")
subscription = DatabaseManager.get_active_subscription_for_user(user_id)
if not subscription:
logger.error(f"No active subscription found for user {user_id}")
return jsonify({"error": "No active subscription found"}), 404
# Get the config based on test or production path
base_path = Path('/etc/wireguard')
if subscription.invoice_id.startswith('__test__'):
config_path = base_path / 'test_clients' / subscription.invoice_id / 'wg0.conf'
else:
config_path = base_path / 'clients' / subscription.invoice_id / 'wg0.conf'
logger.info(f"Looking for config at: {config_path}")
if not config_path.exists():
logger.error(f"Configuration file not found at {config_path}")
return jsonify({"error": "Configuration file not found"}), 404
with open(config_path) as f:
config_text = f.read()
logger.info(f"Successfully retrieved config for user {user_id}")
return jsonify({
"configText": config_text,
"status": "active",
"expiryTime": subscription.expiry_time.isoformat() if subscription.expiry_time else None
})
except Exception as e:
logger.error(f"Error retrieving VPN config: {str(e)}")
logger.error("Traceback:", exc_info=True)
return jsonify({"error": "Failed to retrieve configuration"}), 500
@app.route('/payment/success') @app.route('/payment/success')
def payment_success(): def payment_success():
return render_template('payment_success.html') return render_template('payment_success.html')

Binary file not shown.

View File

@ -7,11 +7,15 @@ import hmac
import hashlib import hashlib
import yaml import yaml
import datetime import datetime
import uuid
import traceback import traceback
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy.orm import joinedload
from ..utils.db.models import Subscription, Payment
from ..utils.db.operations import DatabaseManager from ..utils.db.operations import DatabaseManager
from ..utils.db.models import SubscriptionStatus from ..utils.db.models import SubscriptionStatus
from ..utils.ansible_logger import AnsibleLogger
load_dotenv() load_dotenv()
@ -26,6 +30,8 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ansible_logger = AnsibleLogger()
# Constants # Constants
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
PLAYBOOK_PATH = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_provision.yml' PLAYBOOK_PATH = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_provision.yml'
@ -124,25 +130,14 @@ def verify_signature(payload_body: bytes, signature_header: str) -> bool:
logger.error(f"Signature verification failed: {traceback.format_exc()}") logger.error(f"Signature verification failed: {traceback.format_exc()}")
return False return False
def run_ansible_playbook(invoice_id: str, cleanup: bool = False) -> subprocess.CompletedProcess: def run_ansible_playbook(invoice_id: str, cleanup: bool = False, extra_vars: dict = None) -> subprocess.CompletedProcess:
""" """Run the appropriate Ansible playbook with logging"""
Run the appropriate Ansible playbook with proper error handling
Args:
invoice_id: BTCPay invoice ID
cleanup: Whether to run cleanup playbook instead of provision
Returns:
subprocess.CompletedProcess: Playbook execution result
Raises:
WebhookError: If playbook execution fails
"""
try: try:
operation_type = 'cleanup' if cleanup else 'provision'
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD') vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD')
if not vault_pass: if not vault_pass:
raise WebhookError("Vault password not found in environment variables") raise WebhookError("Vault password not found in environment variables")
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file: with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
vault_pass_file.write(vault_pass) vault_pass_file.write(vault_pass)
vault_pass_file.flush() vault_pass_file.flush()
@ -153,31 +148,73 @@ def run_ansible_playbook(invoice_id: str, cleanup: bool = False) -> subprocess.C
'ansible-playbook', 'ansible-playbook',
str(playbook), str(playbook),
'-i', str(BASE_DIR / 'inventory.ini'), '-i', str(BASE_DIR / 'inventory.ini'),
'-e', f'invoice_id={invoice_id}', '-e', f'invoice_id={invoice_id}'
]
if extra_vars:
for key, value in extra_vars.items():
cmd.extend(['-e', f'{key}={value}'])
cmd.extend([
'--vault-password-file', vault_pass_file.name, '--vault-password-file', vault_pass_file.name,
'-vvv' '-vvv'
] ])
logger.info(f"Running ansible-playbook command: {' '.join(cmd)}") logger.info(f"Running ansible-playbook command: {' '.join(cmd)}")
# Run ansible-playbook without check=True to handle errors better
result = subprocess.run( result = subprocess.run(
cmd, cmd,
capture_output=True, capture_output=True,
text=True, text=True
check=True # This will raise CalledProcessError if playbook fails
) )
# Log detailed output for debugging
logger.info("Ansible STDOUT:")
logger.info(result.stdout)
if result.stderr:
logger.error("Ansible STDERR:")
logger.error(result.stderr)
# Check return code manually
if result.returncode != 0:
logger.error(f"Ansible playbook failed with return code {result.returncode}")
logger.error(f"Error output: {result.stderr}")
raise WebhookError(f"Failed {operation_type} for subscription {invoice_id}")
# Log successful operation
is_test = bool(extra_vars and extra_vars.get('is_test'))
ansible_logger.log_operation(
invoice_id,
operation_type,
result,
is_test=is_test
)
# Check for fatal errors in output
if "fatal:" in result.stdout or "fatal:" in result.stderr: if "fatal:" in result.stdout or "fatal:" in result.stderr:
logger.error("Fatal error detected in Ansible output")
raise WebhookError("Ansible playbook reported fatal error") raise WebhookError("Ansible playbook reported fatal error")
logger.info(f"Successfully completed {operation_type} for {invoice_id}")
return result return result
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Playbook execution failed: {e.stderr}") logger.error(f"Playbook execution failed: {e.stderr}")
raise WebhookError(f"Ansible playbook failed with return code {e.returncode}") # Log failed operation
ansible_logger.log_operation(
invoice_id,
operation_type,
e,
is_test=bool(extra_vars and extra_vars.get('is_test'))
)
raise WebhookError(f"Failed {operation_type} for subscription {invoice_id}: {e.stderr}")
except Exception as e: except Exception as e:
logger.error(f"Error running playbook: {traceback.format_exc()}") logger.error(f"Error running playbook: {traceback.format_exc()}")
raise WebhookError(f"Playbook execution failed: {str(e)}") raise WebhookError(f"Playbook execution failed: {str(e)}")
finally: finally:
if 'vault_pass_file' in locals(): if 'vault_pass_file' in locals():
os.unlink(vault_pass_file.name) os.unlink(vault_pass_file.name)
@ -220,13 +257,106 @@ def handle_subscription_status(data: dict) -> tuple:
logger.error(f"Error handling subscription status: {traceback.format_exc()}") logger.error(f"Error handling subscription status: {traceback.format_exc()}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
def handle_payment_webhook(request) -> tuple: def handle_test_webhook(data, webhook_type):
""" """Handle test webhook with proper Ansible execution and logging"""
Handle BTCPay Server webhook for VPN provisioning logger.info(f"Processing test webhook: {webhook_type}")
invoice_id = data.get('invoiceId', '')
Returns: if not invoice_id.startswith('__test__'):
tuple: (response, status_code) logger.error("Invalid test invoice ID format")
""" return jsonify({"error": "Invalid test invoice ID"}), 400
# Process both types of invoice settlement webhooks
if webhook_type in ['InvoiceSettled', 'InvoicePaymentSettled']:
try:
# For test invoices, create a 30-minute subscription
test_duration = 30 # minutes
test_user_id = f"test_{uuid.uuid4()}"
test_pubkey = f"TEST_KEY_{uuid.uuid4()}"
logger.info(f"Creating test subscription for {test_duration} minutes")
# Create test subscription entry - now returns a dictionary
subscription_data = DatabaseManager.create_subscription(
user_id=test_user_id,
invoice_id=invoice_id,
public_key=test_pubkey,
duration_hours=0.5 # 30 minutes
)
if not subscription_data:
logger.error("Failed to create test subscription")
return jsonify({"error": "Failed to create test subscription"}), 500
logger.info(f"Created test subscription: {subscription_data['id']}")
# Run the provisioning playbook with test flag
try:
logger.info("Running test VPN provision playbook")
result = run_ansible_playbook(
invoice_id=invoice_id,
cleanup=False,
extra_vars={
"is_test": True,
"test_duration_minutes": test_duration,
"test_public_key": test_pubkey
}
)
if result.returncode == 0:
logger.info(f"Test VPN provisioned successfully for {test_duration} minutes")
# Activate subscription and record payment
activated_data = DatabaseManager.activate_subscription(invoice_id)
if activated_data:
DatabaseManager.record_payment(
test_user_id,
subscription_data['id'], # Use dictionary key instead of object attribute
invoice_id,
data.get('amount', 0)
)
cleanup_time = datetime.datetime.utcnow() + datetime.timedelta(minutes=test_duration)
logger.info(f"Scheduling cleanup for {cleanup_time}")
return jsonify({
"status": "success",
"message": f"Test VPN provisioned for {test_duration} minutes",
"test_user_id": test_user_id,
"subscription_id": subscription_data['id'], # Include subscription ID in response
"assigned_ip": subscription_data['assigned_ip'], # Include assigned IP
"cleanup_scheduled": cleanup_time.isoformat()
}), 200
else:
logger.error("Failed to activate subscription")
return jsonify({"error": "Failed to activate subscription"}), 500
logger.error(f"Test provisioning failed: {result.stderr}")
return jsonify({"error": "Test provisioning failed"}), 500
except WebhookError as e:
logger.error(f"Error in test provision playbook: {str(e)}")
return jsonify({"error": str(e)}), 500
except Exception as e:
logger.error(f"Error in test provisioning: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({"error": str(e)}), 500
# Handle test subscription status updates
elif webhook_type == 'SubscriptionStatusUpdated':
return handle_subscription_status(data)
# For other test webhook types, just acknowledge
else:
logger.info(f"Acknowledged test webhook: {webhook_type}")
return jsonify({
"status": "success",
"message": f"Test webhook {webhook_type} acknowledged"
}), 200
def handle_payment_webhook(request) -> tuple:
"""Handle BTCPay Server webhook for VPN provisioning"""
try: try:
vault_values = get_vault_values() vault_values = get_vault_values()
logger.info(f"Processing webhook on endpoint: {vault_values['webhook_full_url']}") logger.info(f"Processing webhook on endpoint: {vault_values['webhook_full_url']}")
@ -254,57 +384,85 @@ def handle_payment_webhook(request) -> tuple:
logger.info(f"Received webhook data: {data}") logger.info(f"Received webhook data: {data}")
# Handle test webhooks # Extract webhook type and invoice ID
invoice_id = data.get('invoiceId', '')
if invoice_id.startswith('__test__'):
logger.info(f"Received test webhook, acknowledging: {data.get('type')}")
return jsonify({
"status": "success",
"message": "Test webhook acknowledged"
}), 200
webhook_type = data.get('type') webhook_type = data.get('type')
invoice_id = data.get('invoiceId', '')
if not webhook_type: if not webhook_type:
return jsonify({"error": "Missing webhook type"}), 400 return jsonify({"error": "Missing webhook type"}), 400
# Handle different webhook types # Handle test webhooks with special processing
if invoice_id.startswith('__test__'):
return handle_test_webhook(data, webhook_type)
# Handle different webhook types for production
if webhook_type == 'SubscriptionStatusUpdated': if webhook_type == 'SubscriptionStatusUpdated':
return handle_subscription_status(data) return handle_subscription_status(data)
elif webhook_type == 'InvoiceSettled' or webhook_type == 'InvoicePaymentSettled': elif webhook_type in ['InvoiceSettled', 'InvoicePaymentSettled']:
if not invoice_id: if not invoice_id:
logger.error("Missing invoiceId in webhook data") logger.error("Missing invoiceId in webhook data")
return jsonify({"error": "Missing invoiceId"}), 400 return jsonify({"error": "Missing invoiceId"}), 400
try: from ..utils.db import get_session
# Run VPN provisioning with get_session() as session:
logger.info(f"Starting VPN provisioning for invoice {invoice_id}") try:
result = run_ansible_playbook(invoice_id) # Check if payment already exists
existing_payment = session.query(Payment).filter(
# Update subscription status Payment.invoice_id == invoice_id
subscription = DatabaseManager.get_subscription_by_invoice(invoice_id) ).first()
if subscription:
subscription = DatabaseManager.activate_subscription(invoice_id)
DatabaseManager.record_payment(
subscription.user_id,
subscription.id,
invoice_id,
data.get('amount', 0)
)
logger.info(f"VPN provisioning completed for invoice {invoice_id}") if existing_payment:
return jsonify({ logger.info(f"Payment already recorded for invoice {invoice_id}")
"status": "success", return jsonify({
"invoice_id": invoice_id, "status": "success",
"message": "VPN provisioning completed" "message": "Payment already processed",
}), 200 "invoice_id": invoice_id
}), 200
except WebhookError as e:
logger.error(f"VPN provisioning failed: {str(e)}") # Run VPN provisioning
return jsonify({ logger.info(f"Starting VPN provisioning for invoice {invoice_id}")
"error": "Provisioning failed", result = run_ansible_playbook(invoice_id)
"details": str(e)
}), 500 # Update subscription status within session
subscription = session.query(Subscription).filter(
Subscription.invoice_id == invoice_id
).options(joinedload(Subscription.user)).first()
if subscription:
# Activate subscription
subscription.status = SubscriptionStatus.ACTIVE
# Record payment only if it doesn't exist
payment = Payment(
user_id=subscription.user_id,
subscription_id=subscription.id,
invoice_id=invoice_id,
amount=data.get('amount', 0)
)
session.add(payment)
# Commit all changes
session.commit()
logger.info(f"VPN provisioning completed for invoice {invoice_id}")
return jsonify({
"status": "success",
"invoice_id": invoice_id,
"message": "VPN provisioning completed"
}), 200
else:
logger.error(f"Subscription not found for invoice {invoice_id}")
return jsonify({"error": "Subscription not found"}), 404
except Exception as e:
session.rollback()
logger.error(f"VPN provisioning failed: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
"error": "Provisioning failed",
"details": str(e)
}), 500
else: else:
logger.info(f"Received {webhook_type} webhook - no action required") logger.info(f"Received {webhook_type} webhook - no action required")

View File

@ -1,213 +0,0 @@
import React, { useState, useEffect } from 'react';
import { generateKeys } from '../utils/wireguard';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
const WireGuardPayment = () => {
const [keyData, setKeyData] = useState(null);
const [duration, setDuration] = useState(24);
const [price, setPrice] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [userId, setUserId] = useState('');
useEffect(() => {
// Generate random userId and keys when component mounts
const init = async () => {
try {
const randomId = crypto.randomUUID();
setUserId(randomId);
const keys = await generateKeys();
setKeyData(keys);
calculatePrice(duration);
} catch (err) {
setError('Failed to initialize keys');
console.error(err);
}
};
init();
}, []);
const calculatePrice = async (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();
setPrice(data.price);
} catch (err) {
setError('Failed to calculate price');
}
};
const handleDurationChange = (event) => {
const newDuration = parseInt(event.target.value);
setDuration(newDuration);
calculatePrice(newDuration);
};
const handlePayment = async () => {
if (!keyData) {
setError('No keys generated. Please refresh the page.');
return;
}
try {
setLoading(true);
const response = await fetch('/create-invoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
duration,
userId,
publicKey: keyData.publicKey,
// Don't send private key to server!
configuration: {
type: 'wireguard',
publicKey: keyData.publicKey
}
})
});
if (!response.ok) {
throw new Error('Failed to create payment');
}
const data = await response.json();
// Save private key to localStorage before redirecting
localStorage.setItem(`vpn_keys_${userId}`, JSON.stringify({
privateKey: keyData.privateKey,
publicKey: keyData.publicKey,
createdAt: new Date().toISOString()
}));
window.location.href = data.checkout_url;
} catch (err) {
setError('Failed to initiate payment');
console.error(err);
} finally {
setLoading(false);
}
};
const handleRegenerateKeys = async () => {
try {
const keys = await generateKeys();
setKeyData(keys);
} catch (err) {
setError('Failed to regenerate keys');
}
};
return (
<Card className="w-full max-w-xl mx-auto">
<CardHeader>
<CardTitle>WireGuard VPN Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<label className="text-sm font-medium">Your User ID:</label>
<Input value={userId} readOnly className="font-mono text-sm" />
<p className="text-sm text-gray-500">
Save this ID - you'll need it to manage your subscription
</p>
</div>
{keyData && (
<div className="space-y-2">
<label className="text-sm font-medium">Your Public Key:</label>
<Input value={keyData.publicKey} readOnly className="font-mono text-sm" />
<Button
onClick={handleRegenerateKeys}
variant="outline"
size="sm"
className="w-full"
>
Regenerate Keys
</Button>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium">Duration:</label>
<div className="space-y-1">
<Input
type="range"
min="1"
max="720"
value={duration}
onChange={handleDurationChange}
className="w-full"
/>
<div className="flex justify-between text-sm text-gray-500">
<Button
variant="ghost"
size="sm"
onClick={() => {
setDuration(24);
calculatePrice(24);
}}
>
1 Day
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDuration(168);
calculatePrice(168);
}}
>
1 Week
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDuration(720);
calculatePrice(720);
}}
>
30 Days
</Button>
</div>
<p className="text-center font-medium">{duration} hours</p>
</div>
</div>
<div className="text-center py-4">
<p className="text-3xl font-bold text-blue-500">{price} sats</p>
</div>
<Button
onClick={handlePayment}
disabled={loading || !keyData}
className="w-full"
>
{loading ? 'Processing...' : 'Pay with Bitcoin'}
</Button>
<div className="mt-4 text-sm text-gray-500 space-y-1">
<p> Keys are generated securely in your browser</p>
<p> Your private key never leaves your device</p>
<p> Configuration will be available after payment</p>
</div>
</CardContent>
</Card>
);
};
export default WireGuardPayment;

View File

@ -1,116 +1,190 @@
// Constants for pricing
const HOURLY_RATE = 100; // 100 sats per hour
const MIN_HOURS = 1;
const MAX_HOURS = 2160; // 3 months
const MIN_SATS = HOURLY_RATE * MIN_HOURS;
const MAX_SATS = 216000; // Maximum for 3 months
// Utility functions for duration formatting // Utility functions for duration formatting
function formatDuration(hours) { function formatDuration(hours) {
if (hours < 24) { const exactHours = `${hours} hour${hours === 1 ? '' : 's'}`;
return `${hours} hour${hours === 1 ? '' : 's'}`;
// Break down the time into components
const months = Math.floor(hours / 720);
const remainingAfterMonths = hours % 720;
const weeks = Math.floor(remainingAfterMonths / 168);
const remainingAfterWeeks = remainingAfterMonths % 168;
const days = Math.floor(remainingAfterWeeks / 24);
const remainingHours = remainingAfterWeeks % 24;
// Build the detailed breakdown
const parts = [];
if (months > 0) {
parts.push(`${months} month${months === 1 ? '' : 's'}`);
} }
if (hours < 168) { if (weeks > 0) {
return `${hours / 24} day${hours === 24 ? '' : 's'}`; parts.push(`${weeks} week${weeks === 1 ? '' : 's'}`);
} }
if (hours < 720) { if (days > 0) {
return `${Math.floor(hours / 168)} week${hours === 168 ? '' : 's'}`; parts.push(`${days} day${days === 1 ? '' : 's'}`);
} }
return `${Math.floor(hours / 720)} month${hours === 720 ? '' : 's'}`; if (remainingHours > 0 || parts.length === 0) {
parts.push(`${remainingHours} hour${remainingHours === 1 ? '' : 's'}`);
}
// Combine all parts with proper grammar
let breakdown = '';
if (parts.length > 1) {
const lastPart = parts.pop();
breakdown = parts.join(', ') + ' and ' + lastPart;
} else {
breakdown = parts[0];
}
return `${exactHours} (${breakdown})`;
} }
// Price calculation with volume discounts // Price calculation with volume discounts
async function calculatePrice(hours) { function calculatePrice(hours) {
try { try {
const response = await fetch('/api/calculate-price', { hours = parseInt(hours);
method: 'POST', if (hours < MIN_HOURS) return MIN_SATS;
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hours: parseInt(hours) }) let basePrice = hours * HOURLY_RATE;
}); if (hours >= 2160) { // 3 months
basePrice = basePrice * 0.75;
if (!response.ok) { } else if (hours >= 720) { // 30 days
throw new Error('Failed to calculate price'); basePrice = basePrice * 0.85;
} else if (hours >= 168) { // 7 days
basePrice = basePrice * 0.90;
} else if (hours >= 24) { // 1 day
basePrice = basePrice * 0.95;
} }
return Math.round(basePrice);
const data = await response.json();
return {
price: data.price,
formattedDuration: formatDuration(hours)
};
} catch (error) { } catch (error) {
console.error('Price calculation failed:', error); console.error('Error calculating price:', error);
throw error; return MIN_SATS;
} }
} }
// Form initialization and event handling // Calculate hours from price
function initializeForm(config) { function calculateHoursFromPrice(sats) {
const { try {
formId = 'subscription-form', sats = parseInt(sats);
sliderId = 'duration-slider', if (sats < MIN_SATS) return MIN_HOURS;
priceDisplayId = 'price-display', if (sats > MAX_SATS) return MAX_HOURS;
durationDisplayId = 'duration-display',
presetButtonClass = 'duration-preset' // Binary search for the closest hour value
} = config; const binarySearchHours = (min, max, targetSats) => {
while (min <= max) {
const form = document.getElementById(formId); const mid = Math.floor((min + max) / 2);
const slider = document.getElementById(sliderId); const price = calculatePrice(mid);
const priceDisplay = document.getElementById(priceDisplayId);
const durationDisplay = document.getElementById(durationDisplayId); if (price === targetSats) return mid;
const presetButtons = document.querySelectorAll(`.${presetButtonClass}`); if (price < targetSats) min = mid + 1;
else max = mid - 1;
if (!form || !slider || !priceDisplay || !durationDisplay) { }
throw new Error('Required elements not found'); return max;
};
let hours = 0;
if (sats >= calculatePrice(2160)) {
hours = Math.floor(sats / (HOURLY_RATE * 0.75));
} else if (sats >= calculatePrice(720)) {
hours = binarySearchHours(720, 2159, sats);
} else if (sats >= calculatePrice(168)) {
hours = binarySearchHours(168, 719, sats);
} else if (sats >= calculatePrice(24)) {
hours = binarySearchHours(24, 167, sats);
} else {
hours = binarySearchHours(1, 23, sats);
}
return Math.max(MIN_HOURS, Math.min(MAX_HOURS, hours));
} catch (error) {
console.error('Error calculating hours from price:', error);
return MIN_HOURS;
} }
}
return { // Update all displays and inputs
form, function updateDisplays(hours, skipSource = null) {
slider, const elements = {
priceDisplay, priceDisplay: document.getElementById('price-display'),
durationDisplay, durationDisplay: document.getElementById('duration-display'),
presetButtons customHours: document.getElementById('custom-hours'),
customSats: document.getElementById('custom-sats')
}; };
hours = Math.max(MIN_HOURS, Math.min(MAX_HOURS, hours));
const price = calculatePrice(hours);
// Update displays
if (elements.priceDisplay && elements.durationDisplay) {
elements.priceDisplay.textContent = price;
elements.durationDisplay.textContent = formatDuration(hours);
}
// Update inputs (skip the source of the update)
if (skipSource !== 'hours' && elements.customHours) {
elements.customHours.value = hours;
}
if (skipSource !== 'sats' && elements.customSats) {
elements.customSats.value = price;
}
} }
// Main pricing interface // Main pricing interface
export const Pricing = { export const Pricing = {
async init(config = {}) { init() {
try { console.log('Initializing pricing system...');
const elements = initializeForm(config); const elements = {
const { form, slider, priceDisplay, durationDisplay, presetButtons } = elements; customHours: document.getElementById('custom-hours'),
customSats: document.getElementById('custom-sats'),
presetButtons: document.querySelectorAll('.duration-preset')
};
// Update price when duration changes // Initial display
const updateDisplay = async (hours) => { updateDisplays(24); // Start with 24 hours as default
try {
const { price, formattedDuration } = await calculatePrice(hours);
priceDisplay.textContent = price;
durationDisplay.textContent = formattedDuration;
} catch (error) {
console.error('Failed to update price display:', error);
priceDisplay.textContent = 'Error';
durationDisplay.textContent = 'Error calculating duration';
}
};
// Set up event listeners // Event listeners for custom inputs
slider.addEventListener('input', () => updateDisplay(slider.value)); elements.customHours?.addEventListener('input', (e) => {
let hours = parseInt(e.target.value) || MIN_HOURS;
presetButtons.forEach(button => { hours = Math.max(MIN_HOURS, Math.min(MAX_HOURS, hours));
button.addEventListener('click', (e) => { updateDisplays(hours, 'hours');
const hours = e.target.dataset.hours; });
slider.value = hours;
updateDisplay(hours); elements.customSats?.addEventListener('input', (e) => {
}); let sats = parseInt(e.target.value) || MIN_SATS;
sats = Math.max(MIN_SATS, Math.min(MAX_SATS, sats));
const hours = calculateHoursFromPrice(sats);
updateDisplays(hours, 'sats');
});
// Add blur events to enforce minimums
elements.customHours?.addEventListener('blur', (e) => {
if (!e.target.value || parseInt(e.target.value) < MIN_HOURS) {
updateDisplays(MIN_HOURS, 'hours');
}
});
elements.customSats?.addEventListener('blur', (e) => {
if (!e.target.value || parseInt(e.target.value) < MIN_SATS) {
updateDisplays(MIN_HOURS, 'sats');
}
});
// Handle preset buttons
elements.presetButtons.forEach(button => {
button.addEventListener('click', () => {
const hours = parseInt(button.getAttribute('data-hours'));
updateDisplays(hours);
}); });
});
// Initial price calculation }
await updateDisplay(slider.value);
return {
updatePrice: updateDisplay,
getCurrentDuration: () => parseInt(slider.value)
};
} catch (error) {
console.error('Failed to initialize pricing:', error);
throw error;
}
},
formatDuration,
calculatePrice
}; };
export default Pricing; // Auto-initialize on script load
document.addEventListener('DOMContentLoaded', () => {
Pricing.init();
});

View File

@ -1,134 +1,117 @@
// Base64 encoding/decoding utilities with error handling // Base64 encoding/decoding utilities
const b64 = { const b64 = {
encode: (array) => { encode: (array) => {
try { try {
return btoa(String.fromCharCode.apply(null, array)); return btoa(String.fromCharCode.apply(null, array))
} catch (error) { .replace(/[+/]/g, char => char === '+' ? '-' : '_')
console.error('Base64 encoding failed:', error); .replace(/=+$/, '');
throw new Error('Failed to encode key data'); } catch (error) {
} console.error('Base64 encoding failed:', error);
}, throw new Error('Failed to encode key data');
decode: (str) => { }
try { },
return Uint8Array.from(atob(str), c => c.charCodeAt(0)); decode: (str) => {
} catch (error) { try {
console.error('Base64 decoding failed:', error); str = str.replace(/[-_]/g, char => char === '-' ? '+' : '/');
throw new Error('Failed to decode key data'); while (str.length % 4) str += '=';
} return Uint8Array.from(atob(str), c => c.charCodeAt(0));
} } catch (error) {
console.error('Base64 decoding failed:', error);
throw new Error('Failed to decode key data');
}
}
}; };
// Key storage management // Check if we're in a secure context (HTTPS) or development mode
const keyStorage = { const isDevelopment = window.location.hostname === 'localhost' ||
store: (userId, keyData) => { window.location.hostname === '127.0.0.1' ||
try { /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(window.location.hostname);
const data = {
privateKey: keyData.privateKey,
publicKey: keyData.publicKey,
createdAt: new Date().toISOString()
};
localStorage.setItem(`vpn_keys_${userId}`, JSON.stringify(data));
} catch (error) {
console.error('Failed to store keys:', error);
throw new Error('Failed to save key data');
}
},
retrieve: (userId) => { // Generate secure random bytes
try { async function getRandomBytes(length) {
const data = localStorage.getItem(`vpn_keys_${userId}`); const array = new Uint8Array(length);
return data ? JSON.parse(data) : null; crypto.getRandomValues(array);
} catch (error) { return array;
console.error('Failed to retrieve keys:', error); }
throw new Error('Failed to retrieve key data');
}
},
remove: (userId) => { // Generate a WireGuard key pair
try {
localStorage.removeItem(`vpn_keys_${userId}`);
} catch (error) {
console.error('Failed to remove keys:', error);
}
}
};
// Main key generation function
async function generateKeyPair() { async function generateKeyPair() {
try { try {
const keyPair = await window.crypto.subtle.generateKey( console.log('Generating WireGuard keys...');
{ console.log('Environment:', isDevelopment ? 'Development' : 'Production');
name: 'X25519',
namedCurve: 'X25519',
},
true,
['deriveKey', 'deriveBits']
);
const privateKey = await window.crypto.subtle.exportKey('raw', keyPair.privateKey); // Generate private key (32 random bytes)
const publicKey = await window.crypto.subtle.exportKey('raw', keyPair.publicKey); const privateKeyBytes = await getRandomBytes(32);
const privateKey = b64.encode(privateKeyBytes);
console.log('Private key generated');
return { let publicKey;
privateKey: b64.encode(new Uint8Array(privateKey)), let publicKeyBytes;
publicKey: b64.encode(new Uint8Array(publicKey))
}; // Use Web Crypto API in production/HTTPS, fallback for development/HTTP
} catch (error) { if (!isDevelopment && window.crypto.subtle) {
console.error('Key generation failed:', error); const keyPair = await window.crypto.subtle.generateKey(
throw new Error('Failed to generate WireGuard keys'); {
} name: 'ECDH',
namedCurve: 'P-256',
},
true,
['deriveKey', 'deriveBits']
);
publicKeyBytes = await window.crypto.subtle.exportKey(
'raw',
keyPair.publicKey
);
publicKey = b64.encode(new Uint8Array(publicKeyBytes));
} else {
// Development fallback
console.log('Using development key generation mode');
publicKeyBytes = await getRandomBytes(32);
publicKey = b64.encode(publicKeyBytes);
}
console.log('Public key generated');
// Generate preshared key
const presharedKeyBytes = await getRandomBytes(32);
const presharedKey = b64.encode(presharedKeyBytes);
console.log('Preshared key generated');
return { privateKey, publicKey, presharedKey };
} catch (error) {
console.error('Key generation failed:', error);
throw new Error('Failed to generate WireGuard keys');
}
} }
// Key validation function // Export WireGuard interface
function validateKey(key) {
try {
const decoded = b64.decode(key);
return decoded.length === 32;
} catch {
return false;
}
}
// WireGuard config generation
function generateConfig(keys, serverPublicKey, serverEndpoint, clientIp) {
if (!keys || !serverPublicKey || !serverEndpoint || !clientIp) {
throw new Error('Missing required configuration parameters');
}
return `[Interface]
PrivateKey = ${keys.privateKey}
Address = ${clientIp}/24
DNS = 1.1.1.1
[Peer]
PublicKey = ${serverPublicKey}
Endpoint = ${serverEndpoint}:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25`;
}
// Main interface for key management
export const WireGuard = { export const WireGuard = {
generateKeys: async () => { generateKeys: async () => {
return await generateKeyPair(); try {
}, console.log('Starting key generation process...');
const keys = await generateKeyPair();
console.log('Keys generated successfully:', {
privateKeyLength: keys.privateKey.length,
publicKeyLength: keys.publicKey.length,
presharedKeyLength: keys.presharedKey.length
});
return keys;
} catch (error) {
console.error('Error in generateKeys:', error);
throw error;
}
},
saveKeys: (userId, keyPair) => { validateKey: (key) => {
if (!validateKey(keyPair.publicKey) || !validateKey(keyPair.privateKey)) { try {
throw new Error('Invalid key data'); const decoded = b64.decode(key);
} return decoded.length === 32;
keyStorage.store(userId, keyPair); } catch {
}, return false;
}
},
getKeys: (userId) => { // Expose environment information
return keyStorage.retrieve(userId); isDevelopment
},
removeKeys: (userId) => {
keyStorage.remove(userId);
},
generateConfig,
validateKey
}; };
export default WireGuard; export default WireGuard;

View File

@ -6,104 +6,261 @@
<form id="subscription-form" class="space-y-6"> <form id="subscription-form" class="space-y-6">
<div> <div>
<label class="block text-sm font-medium mb-2">User ID</label> <label class="block text-sm font-medium mb-2">User ID</label>
<input <input type="text" id="user-id" readonly
type="text" class="w-full px-3 py-2 bg-dark border border-gray-600 rounded-md text-white focus:outline-none focus:border-blue-500 font-mono text-sm">
id="user-id" <p class="mt-1 text-sm text-gray-400">Only used for subscription management</p>
readonly
class="w-full px-3 py-2 bg-dark border border-gray-600 rounded-md text-white focus:outline-none focus:border-blue-500 font-mono text-sm"
>
<p class="mt-1 text-sm text-gray-400">Save this ID - you'll need it to manage your subscription</p>
</div>
<div>
<label class="block text-sm font-medium mb-2">WireGuard Public Key</label>
<input
type="text"
id="public-key"
readonly
class="w-full px-3 py-2 bg-dark border border-gray-600 rounded-md text-white focus:outline-none focus:border-blue-500 font-mono text-sm"
>
<button
type="button"
id="regenerate-keys"
class="mt-2 w-full px-3 py-2 bg-dark border border-gray-600 rounded-md text-white hover:bg-gray-800 transition-colors"
>
Regenerate Keys
</button>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-2">Duration</label> <label class="block text-sm font-medium mb-2">Duration & Price</label>
<div class="space-y-4"> <div class="space-y-4">
<input <!-- Preset buttons -->
type="range"
id="duration-slider"
min="1"
max="720"
value="24"
class="w-full"
>
<div class="flex justify-between text-sm text-gray-400"> <div class="flex justify-between text-sm text-gray-400">
<button type="button" data-hours="1" class="duration-preset hover:text-blue-400">1 Hour</button> <button type="button" data-hours="1" class="duration-preset hover:text-blue-400">1 Hour</button>
<button type="button" data-hours="24" class="duration-preset hover:text-blue-400">1 Day</button> <button type="button" data-hours="24" class="duration-preset hover:text-blue-400">1 Day</button>
<button type="button" data-hours="168" class="duration-preset hover:text-blue-400">1 Week</button> <button type="button" data-hours="168" class="duration-preset hover:text-blue-400">1
<button type="button" data-hours="720" class="duration-preset hover:text-blue-400">1 Month</button> Week</button>
<button type="button" data-hours="720" class="duration-preset hover:text-blue-400">1
Month</button>
<button type="button" data-hours="2160" class="duration-preset hover:text-blue-400">3
Months</button>
</div>
<!-- Direct input fields -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-xs text-gray-400">Custom Duration</label>
<div class="flex items-center space-x-2">
<input type="number" id="custom-hours" min="1" max="2160" placeholder="Hours"
class="w-full px-3 py-2 bg-dark border border-gray-600 rounded-md text-white focus:outline-none focus:border-blue-500 text-sm">
</div>
</div>
<div class="space-y-1">
<label class="text-xs text-gray-400">Custom Amount</label>
<div class="flex items-center space-x-2">
<input type="number" id="custom-sats" min="100" max="216000" placeholder="Min 100 sats"
class="w-full px-3 py-2 bg-dark border border-gray-600 rounded-md text-white focus:outline-none focus:border-blue-500 text-sm">
</div>
</div>
</div> </div>
</div> </div>
<p id="duration-display" class="mt-2 text-center text-gray-400"></p> <div class="text-center mt-4">
<div class="text-lg text-gray-400 mb-2">
<span id="duration-display">24 hours</span>
</div>
<span id="price-display" class="text-2xl font-bold text-blue-400">-</span>
<span class="text-2xl font-bold text-blue-400"> sats</span>
</div>
</div> </div>
<div class="text-center"> <div id="keys-section" class="space-y-4">
<p class="text-2xl font-bold text-blue-400"> <button type="button" id="show-keys"
<span id="price-display">-</span> sats class="w-full px-3 py-2 bg-dark border border-gray-600 rounded-md text-white hover:bg-gray-800 transition-colors">
</p> Show my keys
</button>
<div id="keys-display" class="hidden space-y-3">
<div class="bg-yellow-900 bg-opacity-20 p-4 rounded-md text-yellow-500 text-sm">
Your private keys are only generated within the browser!
Save these keys securely - you'll need them to connect to the VPN.
</div>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-sm font-medium w-24">Private Key:</label>
<input type="text" id="private-key" readonly
class="flex-1 px-2 py-1 bg-dark border border-gray-600 rounded text-sm font-mono">
<button type="button" onclick="copyToClipboard('private-key')"
class="p-1 hover:text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
fill="currentColor">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path
d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
</svg>
</button>
</div>
<div class="flex items-center space-x-2">
<label class="text-sm font-medium w-24">Public Key:</label>
<input type="text" id="public-key" readonly
class="flex-1 px-2 py-1 bg-dark border border-gray-600 rounded text-sm font-mono">
<button type="button" onclick="copyToClipboard('public-key')"
class="p-1 hover:text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
fill="currentColor">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path
d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
</svg>
</button>
</div>
<div class="flex items-center space-x-2">
<label class="text-sm font-medium w-24">Preshared Key:</label>
<input type="text" id="preshared-key" readonly
class="flex-1 px-2 py-1 bg-dark border border-gray-600 rounded text-sm font-mono">
<button type="button" onclick="copyToClipboard('preshared-key')"
class="p-1 hover:text-blue-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
fill="currentColor">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path
d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
</svg>
</button>
</div>
</div>
</div>
</div> </div>
<button <button type="submit"
type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition-colors">
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition-colors"
>
Pay with Bitcoin Pay with Bitcoin
</button> </button>
<div class="mt-4 text-sm text-gray-400 space-y-1">
<p>• Keys are generated securely in your browser</p>
<p>• Your private key never leaves your device</p>
<p>• Configuration will be available after payment</p>
</div>
</form> </form>
</div> </div>
</div> </div>
<script>
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
element.select();
document.execCommand('copy');
// Visual feedback
const button = element.nextElementSibling;
button.classList.add('text-green-400');
setTimeout(() => button.classList.remove('text-green-400'), 1000);
}
</script>
<script type="module"> <script type="module">
import { WireGuard } from '/static/js/utils/wireguard.js'; import { WireGuard } from '/static/js/utils/wireguard.js';
import { Pricing } from '/static/js/pricing.js'; import { Pricing } from '/static/js/pricing.js';
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function () {
const userId = Date.now().toString(); // Generate a unique user ID without prefix // Initialize user ID
document.getElementById('user-id').value = userId; const userId = Date.now().toString();
document.getElementById('user-id').value = userId;
// Generate keys and set public key // Initialize pricing
const keyPair = await WireGuard.generateKeys(); Pricing.init();
document.getElementById('public-key').value = keyPair.publicKey;
// Store keys in local storage document.querySelectorAll('.duration-preset').forEach(button => {
WireGuard.saveKeys(userId, keyPair); button.addEventListener('click', function () {
const hours = this.dataset.hours;
console.log('Preset clicked:', hours);
const customHours = document.getElementById('custom-hours');
if (customHours) {
customHours.value = hours;
console.log('Custom hours updated to:', customHours.value);
}
});
});
// Initialize pricing const customHoursInput = document.getElementById('custom-hours');
Pricing.init({ if (customHoursInput) {
formId: 'subscription-form', customHoursInput.addEventListener('input', function () {
sliderId: 'duration-slider', console.log('Custom hours changed to:', this.value);
priceDisplayId: 'price-display', });
durationDisplayId: 'duration-display', }
presetButtonClass: 'duration-preset'
// Handle show keys button
let keys = null;
const showKeysButton = document.getElementById('show-keys');
const keysDisplay = document.getElementById('keys-display');
showKeysButton.addEventListener('click', async function () {
try {
showKeysButton.disabled = true;
showKeysButton.textContent = 'Generating...';
// Remove the if (!keys) check to allow regeneration
keys = await WireGuard.generateKeys();
document.getElementById('private-key').value = keys.privateKey;
document.getElementById('public-key').value = keys.publicKey;
document.getElementById('preshared-key').value = keys.presharedKey;
keysDisplay.classList.remove('hidden');
showKeysButton.textContent = 'Regenerate Keys';
} catch (error) {
console.error('Failed to generate/show keys:', error);
alert('Failed to generate keys. Please try again.');
} finally {
showKeysButton.disabled = false;
}
});
// Handle form submission
document.getElementById('subscription-form').addEventListener('submit', async function (e) {
e.preventDefault();
console.log("Form submission started");
const publicKey = document.getElementById('public-key').value;
const presharedKey = document.getElementById('preshared-key').value;
const userId = document.getElementById('user-id').value;
console.log("Keys collected:", {
publicKey: publicKey,
userId: userId,
hasPreSharedKey: !!presharedKey
});
if (!publicKey || !presharedKey) {
alert('Please generate your keys first');
return;
}
// Get duration from custom hours input
const customHours = document.getElementById('custom-hours');
const duration = customHours && customHours.value ? parseInt(customHours.value) : 24;
console.log("Duration:", duration);
// Create the request payload
const payload = {
duration: duration,
user_id: userId,
public_key: publicKey
};
console.log("Payload prepared:", payload);
try {
console.log("Attempting to send request to /create-invoice");
const response = await fetch('/create-invoice', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
console.log("Response received:", {
status: response.status,
statusText: response.statusText
});
const data = await response.json();
console.log("Response data:", data);
if (!response.ok) {
console.log('Server error response:', data);
throw new Error(data.error || 'Failed to create invoice');
}
if (data.checkout_url) {
console.log("Redirecting to:", data.checkout_url);
window.location.href = data.checkout_url;
} else {
throw new Error('No checkout URL received');
}
} catch (error) {
console.error('Failed to create invoice:', error);
alert('Failed to create payment: ' + error.message);
}
});
}); });
// Regenerate keys button
document.getElementById('regenerate-keys').addEventListener('click', async function() {
const newKeyPair = await WireGuard.generateKeys();
document.getElementById('public-key').value = newKeyPair.publicKey;
WireGuard.saveKeys(userId, newKeyPair);
});
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,4 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="min-h-screen bg-dark py-8 px-4"> <div class="min-h-screen bg-dark py-8 px-4">
<div class="max-w-2xl mx-auto bg-dark-lighter rounded-lg shadow-lg p-6"> <div class="max-w-2xl mx-auto bg-dark-lighter rounded-lg shadow-lg p-6">
@ -65,84 +64,82 @@
</div> </div>
<script type="module"> <script type="module">
import { WireGuard } from '/static/js/utils/wireguard.js'; import { WireGuard } from '/static/js/utils/wireguard.js';
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function () {
const userId = new URLSearchParams(window.location.search).get('userId'); const userId = new URLSearchParams(window.location.search).get('userId');
if (!userId) { if (!userId) {
console.error('No user ID found in URL'); console.error('No user ID found in URL');
return;
}
try {
// Retrieve keys from storage
const keyData = WireGuard.getKeys(userId);
if (!keyData) {
console.error('No key data found');
return; return;
} }
// Hardcoded server details for demonstration purposes try {
const serverData = { // Get stored keys from localStorage
serverPublicKey: 'your-server-public-key', const storedKeys = localStorage.getItem(`vpn_keys_${userId}`);
serverEndpoint: 'your-server-endpoint', if (!storedKeys) {
clientIp: 'your-client-ip' console.error('No key data found');
}; return;
// Generate WireGuard config
const config = WireGuard.generateConfig(
keyData,
serverData.serverPublicKey,
serverData.serverEndpoint,
serverData.clientIp
);
// Show configuration section
const configSection = document.getElementById('config-section');
configSection.classList.remove('hidden');
// Display config
document.getElementById('wireguard-config').textContent = config;
// Setup copy button
document.getElementById('copy-config').addEventListener('click', async function() {
try {
await navigator.clipboard.writeText(config);
this.textContent = 'Copied!';
setTimeout(() => {
this.textContent = 'Copy Configuration';
}, 2000);
} catch (error) {
console.error('Failed to copy config:', error);
alert('Failed to copy configuration. Please try manually copying.');
} }
}); const keyData = JSON.parse(storedKeys);
// Setup download button // Fetch VPN configuration from server
document.getElementById('download-config').addEventListener('click', function() { const response = await fetch(`/api/vpn-config/${userId}`);
try { if (!response.ok) {
const blob = new Blob([config], { type: 'text/plain' }); throw new Error('Failed to fetch VPN configuration');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wireguard.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download config:', error);
alert('Failed to download configuration. Please try copying manually.');
} }
});
// Clear keys from storage after successful display const data = await response.json();
WireGuard.removeKeys(userId); if (!data.configText) {
throw new Error('No configuration received');
}
} catch (error) { // Show configuration section
console.error('Error setting up configuration:', error); const configSection = document.getElementById('config-section');
alert('Failed to load VPN configuration. Please contact support.'); configSection.classList.remove('hidden');
}
}); // Display config
const configText = data.configText;
document.getElementById('wireguard-config').textContent = configText;
// Setup copy button
document.getElementById('copy-config').addEventListener('click', async function () {
try {
await navigator.clipboard.writeText(configText);
this.textContent = 'Copied!';
setTimeout(() => {
this.textContent = 'Copy Configuration';
}, 2000);
} catch (error) {
console.error('Failed to copy config:', error);
alert('Failed to copy configuration. Please try manually copying.');
}
});
// Setup download button
document.getElementById('download-config').addEventListener('click', function () {
try {
const blob = new Blob([configText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wireguard.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download config:', error);
alert('Failed to download configuration. Please try copying manually.');
}
});
// Clear stored keys
localStorage.removeItem(`vpn_keys_${userId}`);
} catch (error) {
console.error('Error setting up configuration:', error);
alert('Failed to load VPN configuration. Please contact support.');
}
});
</script> </script>
{% endblock %} {% endblock %}

112
app/utils/ansible_logger.py Normal file
View File

@ -0,0 +1,112 @@
# app/utils/ansible_logger.py
import logging
import json
from datetime import datetime
from pathlib import Path
from .db.operations import DatabaseManager
class AnsibleLogger:
def __init__(self, log_dir=None):
"""Initialize the Ansible logger"""
# Use data directory from project structure
self.base_dir = Path(__file__).resolve().parent.parent.parent
self.log_dir = log_dir or (self.base_dir / 'data' / 'logs')
self.log_dir.mkdir(parents=True, exist_ok=True)
# Set up file handler
self.logger = logging.getLogger('ansible_operations')
self.logger.setLevel(logging.DEBUG)
# Create a detailed log file
detailed_log = self.log_dir / 'ansible_operations.log'
file_handler = logging.FileHandler(detailed_log)
file_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
def log_operation(self, subscription_id, operation_type, result, is_test=False):
"""Log an Ansible operation"""
try:
# Get the subscription
subscription = DatabaseManager.get_subscription_by_invoice(subscription_id)
if not subscription:
self.logger.error(f"Subscription {subscription_id} not found")
return
# Create detailed log entry
log_entry = {
'timestamp': datetime.utcnow().isoformat(),
'subscription_id': subscription_id,
'operation_type': operation_type,
'is_test': is_test,
'return_code': result.returncode,
'stdout': result.stdout,
'stderr': result.stderr,
'assigned_ip': subscription.assigned_ip
}
# Create log filename with timestamp
log_file = self.log_dir / f"{operation_type}_{subscription_id}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
# Write detailed JSON log
with open(log_file, 'w') as f:
json.dump(log_entry, f, indent=2)
# Create provision log in database
DatabaseManager.create_provision_log({
'subscription_id': subscription.id,
'action': operation_type,
'status': 'success' if result.returncode == 0 else 'failure',
'ansible_output': result.stdout,
'error_message': result.stderr if result.returncode != 0 else None
})
# Log summary
if result.returncode == 0:
self.logger.info(f"Successfully completed {operation_type} for subscription {subscription_id}")
else:
self.logger.error(f"Failed {operation_type} for subscription {subscription_id}: {result.stderr}")
except Exception as e:
self.logger.error(f"Error logging operation: {str(e)}")
def get_logs(self, subscription_id=None, hours=24, operation_type=None):
"""Get recent Ansible operation logs"""
try:
log_files = []
pattern = f"*{subscription_id if subscription_id else ''}*.json"
for log_file in self.log_dir.glob(pattern):
if operation_type and operation_type not in log_file.name:
continue
log_files.append(log_file)
# Sort by modification time and return most recent first
log_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
logs = []
for log_file in log_files:
with open(log_file) as f:
logs.append(json.load(f))
return logs
except Exception as e:
self.logger.error(f"Error retrieving logs: {str(e)}")
return []
def cleanup_old_logs(self, days=30):
"""Clean up logs older than specified days"""
try:
cutoff = datetime.now().timestamp() - (days * 24 * 60 * 60)
for log_file in self.log_dir.glob('*.json'):
if log_file.stat().st_mtime < cutoff:
log_file.unlink()
self.logger.info(f"Cleaned up old log file: {log_file}")
except Exception as e:
self.logger.error(f"Error cleaning up logs: {str(e)}")

View File

@ -1,4 +1,7 @@
from sqlalchemy import create_engine, Column, Integer, String, DateTime, ForeignKey, Enum, Text from sqlalchemy import (
create_engine, Column, Integer, String, DateTime,
ForeignKey, Enum, Text, JSON, Boolean, Float
)
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
import enum import enum
@ -11,6 +14,14 @@ class SubscriptionStatus(enum.Enum):
EXPIRED = "expired" EXPIRED = "expired"
PENDING = "pending" PENDING = "pending"
CANCELLED = "cancelled" CANCELLED = "cancelled"
FAILED = "failed" # New status for failed provisions
SUSPENDED = "suspended" # New status for temp suspension
class LogLevel(enum.Enum):
DEBUG = "debug"
INFO = "info"
WARNING = "warning"
ERROR = "error"
class User(Base): class User(Base):
__tablename__ = 'users' __tablename__ = 'users'
@ -18,9 +29,13 @@ class User(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
user_id = Column(String, unique=True, nullable=False) # UUID generated in frontend user_id = Column(String, unique=True, nullable=False) # UUID generated in frontend
created_at = Column(DateTime, default=datetime.datetime.utcnow) created_at = Column(DateTime, default=datetime.datetime.utcnow)
last_login = Column(DateTime, nullable=True) # New: Track last login time
is_active = Column(Boolean, default=True, nullable=True) # New: User account status
user_data = Column(JSON, nullable=True) # New: Optional user metadata
subscriptions = relationship("Subscription", back_populates="user") subscriptions = relationship("Subscription", back_populates="user")
payments = relationship("Payment", back_populates="user") payments = relationship("Payment", back_populates="user")
provision_logs = relationship("ProvisionLog", back_populates="user") # New relationship
class Subscription(Base): class Subscription(Base):
__tablename__ = 'subscriptions' __tablename__ = 'subscriptions'
@ -35,8 +50,17 @@ class Subscription(Base):
warning_sent = Column(Integer, default=0) warning_sent = Column(Integer, default=0)
assigned_ip = Column(String) # WireGuard IP address assigned to this subscription assigned_ip = Column(String) # WireGuard IP address assigned to this subscription
# New fields for monitoring
last_connection = Column(DateTime, nullable=True) # Track last connection time
data_usage = Column(Float, default=0.0) # Track data usage in MB
is_test = Column(Boolean, default=False) # Flag for test subscriptions
provision_attempts = Column(Integer, default=0) # Count provision attempts
cleanup_attempts = Column(Integer, default=0) # Count cleanup attempts
config_data = Column(JSON, nullable=True) # Store additional config data
user = relationship("User", back_populates="subscriptions") user = relationship("User", back_populates="subscriptions")
payments = relationship("Payment", back_populates="subscription") payments = relationship("Payment", back_populates="subscription")
provision_logs = relationship("ProvisionLog", back_populates="subscription")
class Payment(Base): class Payment(Base):
__tablename__ = 'payments' __tablename__ = 'payments'
@ -48,5 +72,26 @@ class Payment(Base):
amount = Column(Integer, nullable=False) # Amount in sats amount = Column(Integer, nullable=False) # Amount in sats
timestamp = Column(DateTime, default=datetime.datetime.utcnow) timestamp = Column(DateTime, default=datetime.datetime.utcnow)
# New payment tracking fields
payment_method = Column(String, nullable=True) # Payment method used
payment_status = Column(String, nullable=True) # Payment status
confirmations = Column(Integer, default=0) # Number of confirmations
payment_data = Column(JSON, nullable=True) # Additional payment data
user = relationship("User", back_populates="payments") user = relationship("User", back_populates="payments")
subscription = relationship("Subscription", back_populates="payments") subscription = relationship("Subscription", back_populates="payments")
class ProvisionLog(Base):
__tablename__ = 'provision_logs'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'))
subscription_id = Column(Integer, ForeignKey('subscriptions.id'))
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
action = Column(String, nullable=False) # 'provision' or 'cleanup'
status = Column(String, nullable=False) # 'success' or 'failure'
ansible_output = Column(Text, nullable=True) # Store Ansible output
error_message = Column(Text, nullable=True) # Store error messages
user = relationship("User", back_populates="provision_logs")
subscription = relationship("Subscription", back_populates="provision_logs")

View File

@ -1,9 +1,10 @@
from datetime import datetime from datetime import datetime, timedelta
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from . import get_session from . import get_session
from .models import User, Subscription, Payment, SubscriptionStatus from .models import User, Subscription, Payment, SubscriptionStatus
import logging import logging
import ipaddress import ipaddress
from .models import User, Subscription, Payment, ProvisionLog, SubscriptionStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,12 +59,14 @@ class DatabaseManager:
with get_session() as session: with get_session() as session:
try: try:
# Get user or create if doesn't exist # Get user or create if doesn't exist
user = DatabaseManager.get_user_by_uuid(user_id) user = session.query(User).filter(User.user_id == user_id).first()
if not user: if not user:
user = DatabaseManager.create_user(user_id) user = User(user_id=user_id)
session.add(user)
session.flush()
start_time = datetime.utcnow() start_time = datetime.utcnow()
expiry_time = start_time + datetime.timedelta(hours=duration_hours) expiry_time = start_time + timedelta(hours=duration_hours)
# Get next available IP # Get next available IP
assigned_ip = DatabaseManager.get_next_available_ip() assigned_ip = DatabaseManager.get_next_available_ip()
@ -77,9 +80,22 @@ class DatabaseManager:
status=SubscriptionStatus.PENDING, status=SubscriptionStatus.PENDING,
assigned_ip=assigned_ip assigned_ip=assigned_ip
) )
session.add(subscription) session.add(subscription)
session.commit() session.commit()
return subscription
# Return a dictionary of values instead of the SQLAlchemy object
return {
'id': subscription.id,
'user_id': user.id,
'invoice_id': subscription.invoice_id,
'public_key': subscription.public_key,
'assigned_ip': subscription.assigned_ip,
'start_time': subscription.start_time,
'expiry_time': subscription.expiry_time,
'status': subscription.status.value
}
except Exception as e: except Exception as e:
logger.error(f"Error creating subscription: {str(e)}") logger.error(f"Error creating subscription: {str(e)}")
session.rollback() session.rollback()
@ -149,4 +165,33 @@ class DatabaseManager:
subscription.warning_sent = 1 subscription.warning_sent = 1
session.commit() session.commit()
return True return True
return False return False
@staticmethod
def create_provision_log(log_data):
"""Create a new provision log entry"""
with get_session() as session:
try:
provision_log = ProvisionLog(
subscription_id=log_data['subscription_id'],
action=log_data['action'],
status=log_data['status'],
ansible_output=log_data['ansible_output'],
error_message=log_data.get('error_message')
)
session.add(provision_log)
session.commit()
return provision_log
except Exception as e:
session.rollback()
logger.error(f"Error creating provision log: {str(e)}")
raise
@staticmethod
def get_provision_logs(subscription_id=None, limit=100):
"""Get provision logs, optionally filtered by subscription"""
with get_session() as session:
query = session.query(ProvisionLog)
if subscription_id:
query = query.filter(ProvisionLog.subscription_id == subscription_id)
return query.order_by(ProvisionLog.timestamp.desc()).limit(limit).all()

View File

@ -1,92 +0,0 @@
{
"__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"
}
}

Binary file not shown.

View File

@ -1,28 +1,157 @@
# scripts/init_db.py # scripts/init_db.py
import os
import sys import sys
import logging
from pathlib import Path from pathlib import Path
from datetime import datetime
# Configure logging before imports
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Add the project root to Python path # Add the project root to Python path
project_root = Path(__file__).resolve().parent.parent project_root = Path(__file__).resolve().parent.parent
sys.path.append(str(project_root)) sys.path.append(str(project_root))
# Import only what's needed for DB initialization
from sqlalchemy import create_engine, text
from app.utils.db.models import Base from app.utils.db.models import Base
from sqlalchemy import create_engine
def init_db(): def get_db_path():
# Create data directory if it doesn't exist """Get the database path"""
data_dir = project_root / 'data' data_dir = project_root / 'data'
data_dir.mkdir(exist_ok=True) data_dir.mkdir(exist_ok=True)
return data_dir / 'vpn.db'
# Create database
db_path = data_dir / 'vpn.db' def backup_existing_db():
db_url = f"sqlite:///{db_path}" """Backup existing database if it exists"""
engine = create_engine(db_url) try:
db_path = get_db_path()
# Create all tables if db_path.exists():
Base.metadata.create_all(engine) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
print(f"Database initialized at: {db_path}") backup_path = db_path.parent / f'vpn_backup_{timestamp}.db'
return engine db_path.rename(backup_path)
logger.info(f"Created backup at: {backup_path}")
return backup_path
return None
except Exception as e:
logger.error(f"Backup failed: {str(e)}")
return None
def init_db(force=False):
"""Initialize the database with all tables"""
try:
db_path = get_db_path()
# Check if database already exists
if db_path.exists() and not force:
logger.warning(f"Database already exists at {db_path}")
logger.warning("Use --force to recreate the database")
return None
# Backup existing database if force is True
if force and db_path.exists():
backup_existing_db()
logger.info(f"Initializing database at: {db_path}")
# Create database URL
db_url = f"sqlite:///{db_path}"
# Create engine with pragma statements for foreign keys
engine = create_engine(
db_url,
connect_args={"check_same_thread": False}
)
# Enable foreign key support using PRAGMA
with engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = ON"))
# Create all tables
Base.metadata.create_all(engine)
logger.info("Successfully created all database tables")
# Log created tables
tables = Base.metadata.tables.keys()
logger.info("Created tables:")
for table in tables:
logger.info(f" - {table}")
return engine
except Exception as e:
logger.error(f"Database initialization failed: {str(e)}")
raise
def verify_tables(engine):
"""Verify that all tables were created correctly"""
try:
# Get list of all tables in the database
with engine.connect() as conn:
# SQLite specific query to get table info
query = text("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
result = conn.execute(query)
existing_tables = {row[0] for row in result}
# Get list of all tables defined in models
expected_tables = set(Base.metadata.tables.keys())
# Check for missing tables
missing_tables = expected_tables - existing_tables
if missing_tables:
logger.error(f"Missing tables: {missing_tables}")
return False
# Verify table schemas
for table_name in existing_tables:
schema_query = text(f"PRAGMA table_info({table_name})")
result = conn.execute(schema_query)
logger.info(f"\nSchema for {table_name}:")
# SQLite PRAGMA table_info returns: (cid, name, type, notnull, dflt_value, pk)
for row in result:
cid, name, type_, notnull, dflt_value, pk = row
pk_str = "PRIMARY KEY" if pk else ""
null_str = "NOT NULL" if notnull else "NULL"
default_str = f"DEFAULT {dflt_value}" if dflt_value is not None else ""
logger.info(f" - {name} ({type_}) {null_str} {default_str} {pk_str}".strip())
logger.info("All expected tables were created successfully")
return True
except Exception as e:
logger.error(f"Table verification failed: {str(e)}")
return False
def main():
"""Main function to handle database initialization"""
try:
import argparse
parser = argparse.ArgumentParser(description='Initialize the VPN database')
parser.add_argument('--force', action='store_true',
help='Force database recreation')
args = parser.parse_args()
logger.info("Starting database initialization")
engine = init_db(force=args.force)
if engine is None:
return 1
# Verify tables were created correctly
if verify_tables(engine):
logger.info("Database initialization completed successfully")
return 0
else:
logger.error("Database initialization failed - tables missing")
return 1
except Exception as e:
logger.error(f"Database initialization failed: {str(e)}")
return 1
if __name__ == "__main__": if __name__ == "__main__":
init_db() sys.exit(main())

View File

@ -58,48 +58,46 @@ def migrate_database():
# Start transaction # Start transaction
session.begin() session.begin()
# Migrate users # Migrate users - note we only use user_id now
logger.info("Migrating users...") logger.info("Migrating users...")
old_cursor.execute("SELECT id, email, created_at FROM users") old_cursor.execute("SELECT id, user_id, created_at FROM users")
users = old_cursor.fetchall() users = old_cursor.fetchall()
user_id_map = {} # Map old IDs to new UUIDs user_id_map = {} # Map old IDs to new UUIDs
for old_id, email, created_at in users: for old_id, user_id, created_at in users:
new_user_id = str(uuid.uuid4()) user_id_map[old_id] = user_id # Keep the same user_id
user_id_map[old_id] = new_user_id
session.execute( session.execute(
"INSERT INTO users (user_id, created_at) VALUES (?, ?)", "INSERT INTO users (user_id, created_at) VALUES (?, ?)",
[new_user_id, created_at or datetime.utcnow()] [user_id, created_at or datetime.utcnow()]
) )
# Migrate subscriptions # Migrate subscriptions
logger.info("Migrating subscriptions...") logger.info("Migrating subscriptions...")
old_cursor.execute(""" old_cursor.execute("""
SELECT id, user_id, invoice_id, start_time, expiry_time, SELECT id, user_id, invoice_id, public_key, start_time,
status, warning_sent expiry_time, status, warning_sent, assigned_ip
FROM subscriptions FROM subscriptions
""") """)
subscriptions = old_cursor.fetchall() subscriptions = old_cursor.fetchall()
for sub in subscriptions: for sub in subscriptions:
old_id, old_user_id, invoice_id, start_time, expiry_time, status, warning_sent = sub old_id, old_user_id, invoice_id, public_key, start_time, \
expiry_time, status, warning_sent, assigned_ip = sub
if old_user_id in user_id_map: if old_user_id in user_id_map:
# Generate a placeholder public key for existing subscriptions
placeholder_pubkey = f"MIGRATED_{uuid.uuid4()}"
session.execute(""" session.execute("""
INSERT INTO subscriptions INSERT INTO subscriptions
(user_id, invoice_id, public_key, start_time, expiry_time, (user_id, invoice_id, public_key, start_time, expiry_time,
status, warning_sent, assigned_ip) status, warning_sent, assigned_ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", [ """, [
user_id_map[old_user_id], old_user_id, # Use the original user_id
invoice_id, invoice_id,
placeholder_pubkey, public_key,
start_time, start_time,
expiry_time, expiry_time,
status, status,
warning_sent, warning_sent,
f"10.8.0.{2 + old_id}" # Simple IP assignment assigned_ip
]) ])
# Migrate payments # Migrate payments
@ -118,7 +116,7 @@ def migrate_database():
(user_id, subscription_id, invoice_id, amount, timestamp) (user_id, subscription_id, invoice_id, amount, timestamp)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", [ """, [
user_id_map[old_user_id], old_user_id, # Use the original user_id
sub_id, sub_id,
invoice_id, invoice_id,
amount, amount,
@ -142,7 +140,7 @@ def migrate_database():
except Exception as e: except Exception as e:
logger.error(f"Migration failed: {str(e)}") logger.error(f"Migration failed: {str(e)}")
raise raise
if __name__ == '__main__': if __name__ == '__main__':
try: try:
migrate_database() migrate_database()