payment flow / provsion is working. need to test wireguard
This commit is contained in:
parent
b44860a0ab
commit
9941aa93f6
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
@ -23,6 +32,17 @@
|
|||||||
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
|
||||||
service:
|
service:
|
||||||
|
@ -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,56 +101,41 @@
|
|||||||
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: Generate client public key
|
- name: Save 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
|
||||||
@ -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
|
@ -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.info(f"Validating request parameters: duration={duration_hours}, user_id={user_id}, has_public_key={bool(public_key)}")
|
||||||
logger.error("Email address missing from request")
|
|
||||||
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
|
||||||
@ -81,19 +91,64 @@ def create_invoice():
|
|||||||
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')
|
||||||
|
BIN
app/data/vpn.db
BIN
app/data/vpn.db
Binary file not shown.
@ -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,21 +130,10 @@ 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")
|
||||||
@ -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(
|
||||||
|
Payment.invoice_id == invoice_id
|
||||||
|
).first()
|
||||||
|
|
||||||
# Update subscription status
|
if existing_payment:
|
||||||
subscription = DatabaseManager.get_subscription_by_invoice(invoice_id)
|
logger.info(f"Payment already recorded for invoice {invoice_id}")
|
||||||
if subscription:
|
return jsonify({
|
||||||
subscription = DatabaseManager.activate_subscription(invoice_id)
|
"status": "success",
|
||||||
DatabaseManager.record_payment(
|
"message": "Payment already processed",
|
||||||
subscription.user_id,
|
"invoice_id": invoice_id
|
||||||
subscription.id,
|
}), 200
|
||||||
invoice_id,
|
|
||||||
data.get('amount', 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"VPN provisioning completed for invoice {invoice_id}")
|
# Run VPN provisioning
|
||||||
return jsonify({
|
logger.info(f"Starting VPN provisioning for invoice {invoice_id}")
|
||||||
"status": "success",
|
result = run_ansible_playbook(invoice_id)
|
||||||
"invoice_id": invoice_id,
|
|
||||||
"message": "VPN provisioning completed"
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except WebhookError as e:
|
# Update subscription status within session
|
||||||
logger.error(f"VPN provisioning failed: {str(e)}")
|
subscription = session.query(Subscription).filter(
|
||||||
return jsonify({
|
Subscription.invoice_id == invoice_id
|
||||||
"error": "Provisioning failed",
|
).options(joinedload(Subscription.user)).first()
|
||||||
"details": str(e)
|
|
||||||
}), 500
|
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")
|
||||||
|
@ -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;
|
|
@ -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) })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
let basePrice = hours * HOURLY_RATE;
|
||||||
throw new Error('Failed to calculate price');
|
if (hours >= 2160) { // 3 months
|
||||||
|
basePrice = basePrice * 0.75;
|
||||||
|
} else if (hours >= 720) { // 30 days
|
||||||
|
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'
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
const form = document.getElementById(formId);
|
// Binary search for the closest hour value
|
||||||
const slider = document.getElementById(sliderId);
|
const binarySearchHours = (min, max, targetSats) => {
|
||||||
const priceDisplay = document.getElementById(priceDisplayId);
|
while (min <= max) {
|
||||||
const durationDisplay = document.getElementById(durationDisplayId);
|
const mid = Math.floor((min + max) / 2);
|
||||||
const presetButtons = document.querySelectorAll(`.${presetButtonClass}`);
|
const price = calculatePrice(mid);
|
||||||
|
|
||||||
if (!form || !slider || !priceDisplay || !durationDisplay) {
|
if (price === targetSats) return mid;
|
||||||
throw new Error('Required elements not found');
|
if (price < targetSats) min = mid + 1;
|
||||||
|
else max = mid - 1;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all displays and inputs
|
||||||
|
function updateDisplays(hours, skipSource = null) {
|
||||||
|
const elements = {
|
||||||
|
priceDisplay: document.getElementById('price-display'),
|
||||||
|
durationDisplay: document.getElementById('duration-display'),
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Update inputs (skip the source of the update)
|
||||||
form,
|
if (skipSource !== 'hours' && elements.customHours) {
|
||||||
slider,
|
elements.customHours.value = hours;
|
||||||
priceDisplay,
|
}
|
||||||
durationDisplay,
|
if (skipSource !== 'sats' && elements.customSats) {
|
||||||
presetButtons
|
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;
|
||||||
|
hours = Math.max(MIN_HOURS, Math.min(MAX_HOURS, hours));
|
||||||
|
updateDisplays(hours, 'hours');
|
||||||
|
});
|
||||||
|
|
||||||
presetButtons.forEach(button => {
|
elements.customSats?.addEventListener('input', (e) => {
|
||||||
button.addEventListener('click', (e) => {
|
let sats = parseInt(e.target.value) || MIN_SATS;
|
||||||
const hours = e.target.dataset.hours;
|
sats = Math.max(MIN_SATS, Math.min(MAX_SATS, sats));
|
||||||
slider.value = hours;
|
const hours = calculateHoursFromPrice(sats);
|
||||||
updateDisplay(hours);
|
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();
|
||||||
|
});
|
@ -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;
|
@ -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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2">WireGuard Public Key</label>
|
<label class="block text-sm font-medium mb-2">Duration & Price</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>
|
|
||||||
<label class="block text-sm font-medium mb-2">Duration</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 %}
|
@ -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
112
app/utils/ansible_logger.py
Normal 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)}")
|
@ -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")
|
@ -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()
|
||||||
@ -150,3 +166,32 @@ class DatabaseManager:
|
|||||||
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()
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
BIN
data/vpn.db
BIN
data/vpn.db
Binary file not shown.
@ -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
|
def backup_existing_db():
|
||||||
db_path = data_dir / 'vpn.db'
|
"""Backup existing database if it exists"""
|
||||||
db_url = f"sqlite:///{db_path}"
|
try:
|
||||||
engine = create_engine(db_url)
|
db_path = get_db_path()
|
||||||
|
if db_path.exists():
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
backup_path = db_path.parent / f'vpn_backup_{timestamp}.db'
|
||||||
|
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
|
||||||
|
|
||||||
# Create all tables
|
def init_db(force=False):
|
||||||
Base.metadata.create_all(engine)
|
"""Initialize the database with all tables"""
|
||||||
print(f"Database initialized at: {db_path}")
|
try:
|
||||||
return engine
|
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())
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user