more updates
This commit is contained in:
parent
dd2a1b4c83
commit
b44860a0ab
@ -15,84 +15,143 @@ from ..utils.db.models import SubscriptionStatus
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Enhanced logging configuration
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler('vpn_provisioner.log')
|
||||||
|
]
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 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'
|
||||||
CLEANUP_PLAYBOOK = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_cleanup.yml'
|
CLEANUP_PLAYBOOK = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_cleanup.yml'
|
||||||
|
|
||||||
|
class WebhookError(Exception):
|
||||||
|
"""Custom exception for webhook handling errors"""
|
||||||
|
pass
|
||||||
|
|
||||||
def get_vault_values():
|
def get_vault_values():
|
||||||
"""Get decrypted values from Ansible vault"""
|
"""Get decrypted values from Ansible vault"""
|
||||||
try:
|
try:
|
||||||
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '')
|
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD')
|
||||||
if not vault_pass:
|
if not vault_pass:
|
||||||
raise Exception("Vault password not found in environment variables")
|
raise WebhookError("Vault password not found in environment variables")
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
|
||||||
vault_pass_file.write(vault_pass)
|
vault_pass_file.write(vault_pass)
|
||||||
vault_pass_file.flush()
|
vault_pass_file.flush()
|
||||||
|
|
||||||
|
# Execute ansible-vault with error checking
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['ansible-vault', 'view', str(BASE_DIR / 'ansible/group_vars/vpn_servers/vault.yml')],
|
['ansible-vault', 'view', str(BASE_DIR / 'ansible/group_vars/vpn_servers/vault.yml')],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
env={**os.environ, 'ANSIBLE_VAULT_PASSWORD_FILE': vault_pass_file.name}
|
env={**os.environ, 'ANSIBLE_VAULT_PASSWORD_FILE': vault_pass_file.name},
|
||||||
|
check=True # This will raise CalledProcessError if command fails
|
||||||
)
|
)
|
||||||
|
|
||||||
os.unlink(vault_pass_file.name)
|
os.unlink(vault_pass_file.name)
|
||||||
|
|
||||||
if result.returncode != 0:
|
try:
|
||||||
raise Exception(f"Failed to decrypt vault: {result.stderr}")
|
|
||||||
|
|
||||||
vault_contents = yaml.safe_load(result.stdout)
|
vault_contents = yaml.safe_load(result.stdout)
|
||||||
|
if not vault_contents:
|
||||||
|
raise WebhookError("Empty vault contents")
|
||||||
|
|
||||||
|
# Validate required vault values
|
||||||
|
required_keys = ['btcpay_base_url', 'btcpay_api_key', 'btcpay_store_id', 'webhook_secret']
|
||||||
|
missing_keys = [key for key in required_keys if key not in vault_contents]
|
||||||
|
if missing_keys:
|
||||||
|
raise WebhookError(f"Missing required vault values: {', '.join(missing_keys)}")
|
||||||
|
|
||||||
vault_contents['webhook_full_url'] = (
|
vault_contents['webhook_full_url'] = (
|
||||||
f"{vault_contents['btcpay_base_url']}"
|
f"{vault_contents['btcpay_base_url']}"
|
||||||
f"{vault_contents['btcpay_webhook_path']}"
|
f"{vault_contents.get('btcpay_webhook_path', '/webhook/vpn')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return vault_contents
|
return vault_contents
|
||||||
|
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise WebhookError(f"Failed to parse vault contents: {str(e)}")
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"Ansible vault command failed: {e.stderr}")
|
||||||
|
raise WebhookError("Failed to decrypt vault")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error reading vault: {str(e)}")
|
logger.error(f"Error reading vault: {traceback.format_exc()}")
|
||||||
raise
|
raise WebhookError(f"Vault operation failed: {str(e)}")
|
||||||
|
|
||||||
def verify_signature(payload_body, signature_header):
|
def verify_signature(payload_body: bytes, signature_header: str) -> bool:
|
||||||
"""Verify BTCPay webhook signature"""
|
"""
|
||||||
|
Verify BTCPay webhook signature with proper error handling
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload_body: Raw request body bytes
|
||||||
|
signature_header: BTCPay-Sig header value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if signature is valid
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
vault_values = get_vault_values()
|
if not signature_header:
|
||||||
secret = vault_values['webhook_secret']
|
logger.error("Missing signature header")
|
||||||
|
return False
|
||||||
|
|
||||||
|
vault_values = get_vault_values()
|
||||||
|
webhook_secret = vault_values.get('webhook_secret')
|
||||||
|
if not webhook_secret:
|
||||||
|
logger.error("Webhook secret not found in vault")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Generate expected signature
|
||||||
expected_signature = hmac.new(
|
expected_signature = hmac.new(
|
||||||
secret.encode('utf-8'),
|
webhook_secret.encode('utf-8'),
|
||||||
payload_body,
|
payload_body,
|
||||||
hashlib.sha256
|
hashlib.sha256
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
|
# Constant-time comparison
|
||||||
return hmac.compare_digest(
|
return hmac.compare_digest(
|
||||||
signature_header.lower(),
|
signature_header.lower(),
|
||||||
f"sha256={expected_signature}".lower()
|
f"sha256={expected_signature}".lower()
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Signature verification failed: {str(e)}")
|
logger.error(f"Signature verification failed: {traceback.format_exc()}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def run_ansible_playbook(invoice_id):
|
def run_ansible_playbook(invoice_id: str, cleanup: bool = False) -> subprocess.CompletedProcess:
|
||||||
"""Run the VPN provisioning playbook"""
|
"""
|
||||||
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '')
|
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:
|
||||||
|
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD')
|
||||||
if not vault_pass:
|
if not vault_pass:
|
||||||
raise Exception("Vault password not found in environment variables")
|
raise WebhookError("Vault password not found in environment variables")
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
|
||||||
vault_pass_file.write(vault_pass)
|
vault_pass_file.write(vault_pass)
|
||||||
vault_pass_file.flush()
|
vault_pass_file.flush()
|
||||||
|
|
||||||
|
playbook = CLEANUP_PLAYBOOK if cleanup else PLAYBOOK_PATH
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
'ansible-playbook',
|
'ansible-playbook',
|
||||||
str(PLAYBOOK_PATH),
|
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}',
|
||||||
'--vault-password-file', vault_pass_file.name,
|
'--vault-password-file', vault_pass_file.name,
|
||||||
@ -104,16 +163,33 @@ def run_ansible_playbook(invoice_id):
|
|||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
os.unlink(vault_pass_file.name)
|
if "fatal:" in result.stdout or "fatal:" in result.stderr:
|
||||||
|
raise WebhookError("Ansible playbook reported fatal error")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def handle_subscription_status(data):
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"Playbook execution failed: {e.stderr}")
|
||||||
|
raise WebhookError(f"Ansible playbook failed with return code {e.returncode}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error running playbook: {traceback.format_exc()}")
|
||||||
|
raise WebhookError(f"Playbook execution failed: {str(e)}")
|
||||||
|
finally:
|
||||||
|
if 'vault_pass_file' in locals():
|
||||||
|
os.unlink(vault_pass_file.name)
|
||||||
|
|
||||||
|
def handle_subscription_status(data: dict) -> tuple:
|
||||||
"""Handle SubscriptionStatusUpdated webhook"""
|
"""Handle SubscriptionStatusUpdated webhook"""
|
||||||
sub_id = data['subscriptionId']
|
try:
|
||||||
status = data['status']
|
sub_id = data.get('subscriptionId')
|
||||||
|
status = data.get('status')
|
||||||
|
|
||||||
|
if not sub_id or not status:
|
||||||
|
return jsonify({"error": "Missing required fields"}), 400
|
||||||
|
|
||||||
logger.info(f"Processing subscription status update: {sub_id} -> {status}")
|
logger.info(f"Processing subscription status update: {sub_id} -> {status}")
|
||||||
|
|
||||||
@ -126,16 +202,11 @@ def handle_subscription_status(data):
|
|||||||
DatabaseManager.activate_subscription(sub_id)
|
DatabaseManager.activate_subscription(sub_id)
|
||||||
else:
|
else:
|
||||||
# Run cleanup for inactive subscriptions
|
# Run cleanup for inactive subscriptions
|
||||||
result = subprocess.run([
|
try:
|
||||||
'ansible-playbook',
|
result = run_ansible_playbook(sub_id, cleanup=True)
|
||||||
str(CLEANUP_PLAYBOOK),
|
logger.info(f"Cleanup playbook completed for {sub_id}: {result.stdout}")
|
||||||
'-i', str(BASE_DIR / 'inventory.ini'),
|
except WebhookError as e:
|
||||||
'-e', f'subscription_id={sub_id}',
|
logger.error(f"Failed to clean up subscription {sub_id}: {str(e)}")
|
||||||
'-vvv'
|
|
||||||
], capture_output=True, text=True)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(f"Failed to clean up subscription {sub_id}: {result.stderr}")
|
|
||||||
|
|
||||||
DatabaseManager.expire_subscription(subscription.id)
|
DatabaseManager.expire_subscription(subscription.id)
|
||||||
logger.info(f"Subscription {sub_id} is no longer active")
|
logger.info(f"Subscription {sub_id} is no longer active")
|
||||||
@ -143,30 +214,24 @@ def handle_subscription_status(data):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Subscription {sub_id} status updated to {status}"
|
"message": f"Subscription {sub_id} status updated to {status}"
|
||||||
})
|
}), 200
|
||||||
|
|
||||||
def handle_subscription_renewal(data):
|
except Exception as e:
|
||||||
"""Handle SubscriptionRenewalRequested webhook"""
|
logger.error(f"Error handling subscription status: {traceback.format_exc()}")
|
||||||
sub_id = data['subscriptionId']
|
return jsonify({"error": str(e)}), 500
|
||||||
logger.info(f"Processing subscription renewal request: {sub_id}")
|
|
||||||
|
|
||||||
subscription = DatabaseManager.get_subscription_by_invoice(sub_id)
|
def handle_payment_webhook(request) -> tuple:
|
||||||
if not subscription:
|
"""
|
||||||
logger.error(f"Subscription {sub_id} not found")
|
Handle BTCPay Server webhook for VPN provisioning
|
||||||
return jsonify({"error": "Subscription not found"}), 404
|
|
||||||
|
|
||||||
# TODO: Send renewal notification to user
|
Returns:
|
||||||
return jsonify({
|
tuple: (response, status_code)
|
||||||
"status": "success",
|
"""
|
||||||
"message": f"Subscription {sub_id} renewal requested"
|
|
||||||
})
|
|
||||||
|
|
||||||
def handle_payment_webhook(request):
|
|
||||||
"""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']}")
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
signature = request.headers.get('BTCPay-Sig')
|
signature = request.headers.get('BTCPay-Sig')
|
||||||
if not signature:
|
if not signature:
|
||||||
logger.error("Missing BTCPay-Sig header")
|
logger.error("Missing BTCPay-Sig header")
|
||||||
@ -177,7 +242,16 @@ def handle_payment_webhook(request):
|
|||||||
logger.error("Invalid signature")
|
logger.error("Invalid signature")
|
||||||
return jsonify({"error": "Invalid signature"}), 401
|
return jsonify({"error": "Invalid signature"}), 401
|
||||||
|
|
||||||
|
# Parse and validate payload
|
||||||
|
try:
|
||||||
data = request.json
|
data = request.json
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Invalid JSON payload: {str(e)}")
|
||||||
|
return jsonify({"error": "Invalid JSON"}), 400
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "Empty payload"}), 400
|
||||||
|
|
||||||
logger.info(f"Received webhook data: {data}")
|
logger.info(f"Received webhook data: {data}")
|
||||||
|
|
||||||
# Handle test webhooks
|
# Handle test webhooks
|
||||||
@ -187,39 +261,27 @@ def handle_payment_webhook(request):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Test webhook acknowledged"
|
"message": "Test webhook acknowledged"
|
||||||
})
|
}), 200
|
||||||
|
|
||||||
webhook_type = data.get('type')
|
webhook_type = data.get('type')
|
||||||
|
if not webhook_type:
|
||||||
|
return jsonify({"error": "Missing webhook type"}), 400
|
||||||
|
|
||||||
|
# Handle different webhook types
|
||||||
if webhook_type == 'SubscriptionStatusUpdated':
|
if webhook_type == 'SubscriptionStatusUpdated':
|
||||||
return handle_subscription_status(data)
|
return handle_subscription_status(data)
|
||||||
|
|
||||||
elif webhook_type == 'SubscriptionRenewalRequested':
|
elif webhook_type == 'InvoiceSettled' or webhook_type == 'InvoicePaymentSettled':
|
||||||
return handle_subscription_renewal(data)
|
|
||||||
|
|
||||||
elif webhook_type in ['InvoiceSettled', 'InvoicePaymentSettled']:
|
|
||||||
invoice_id = data.get('invoiceId')
|
|
||||||
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
|
||||||
|
|
||||||
# Get subscription and run Ansible playbook
|
try:
|
||||||
|
# Run VPN provisioning
|
||||||
logger.info(f"Starting VPN provisioning for invoice {invoice_id}")
|
logger.info(f"Starting VPN provisioning for invoice {invoice_id}")
|
||||||
result = run_ansible_playbook(invoice_id)
|
result = run_ansible_playbook(invoice_id)
|
||||||
|
|
||||||
if result.returncode != 0:
|
# Update subscription status
|
||||||
error_msg = f"Ansible playbook failed with return code {result.returncode}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
logger.error(f"Ansible stdout: {result.stdout}")
|
|
||||||
logger.error(f"Ansible stderr: {result.stderr}")
|
|
||||||
return jsonify({
|
|
||||||
"error": "Provisioning failed",
|
|
||||||
"details": error_msg,
|
|
||||||
"stdout": result.stdout,
|
|
||||||
"stderr": result.stderr
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
# Get subscription and activate it
|
|
||||||
subscription = DatabaseManager.get_subscription_by_invoice(invoice_id)
|
subscription = DatabaseManager.get_subscription_by_invoice(invoice_id)
|
||||||
if subscription:
|
if subscription:
|
||||||
subscription = DatabaseManager.activate_subscription(invoice_id)
|
subscription = DatabaseManager.activate_subscription(invoice_id)
|
||||||
@ -235,19 +297,25 @@ def handle_payment_webhook(request):
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
"invoice_id": invoice_id,
|
"invoice_id": invoice_id,
|
||||||
"message": "VPN provisioning completed"
|
"message": "VPN provisioning completed"
|
||||||
})
|
}), 200
|
||||||
|
|
||||||
|
except WebhookError as e:
|
||||||
|
logger.error(f"VPN provisioning failed: {str(e)}")
|
||||||
|
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")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Webhook {webhook_type} acknowledged"
|
"message": f"Webhook {webhook_type} acknowledged"
|
||||||
})
|
}), 200
|
||||||
|
|
||||||
|
except WebhookError as e:
|
||||||
|
logger.error(f"Webhook error: {str(e)}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing webhook: {str(e)}")
|
logger.error(f"Unexpected error: {traceback.format_exc()}")
|
||||||
logger.error(traceback.format_exc())
|
return jsonify({"error": "Internal server error"}), 500
|
||||||
return jsonify({
|
|
||||||
"error": str(e),
|
|
||||||
"traceback": traceback.format_exc()
|
|
||||||
}), 500
|
|
@ -1,137 +1,116 @@
|
|||||||
// Base64 encoding/decoding utilities
|
// Utility functions for duration formatting
|
||||||
const b64 = {
|
|
||||||
encode: array => btoa(String.fromCharCode.apply(null, array)),
|
|
||||||
decode: str => Uint8Array.from(atob(str), c => c.charCodeAt(0))
|
|
||||||
};
|
|
||||||
|
|
||||||
async function generateKeyPair() {
|
|
||||||
const keyPair = await window.crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: 'X25519',
|
|
||||||
namedCurve: 'X25519',
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['deriveKey', 'deriveBits']
|
|
||||||
);
|
|
||||||
|
|
||||||
const privateKey = await window.crypto.subtle.exportKey('raw', keyPair.privateKey);
|
|
||||||
const publicKey = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
privateKey: b64.encode(new Uint8Array(privateKey)),
|
|
||||||
publicKey: b64.encode(new Uint8Array(publicKey))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
|
||||||
const form = document.getElementById('subscription-form');
|
|
||||||
const slider = document.getElementById('duration-slider');
|
|
||||||
const durationDisplay = document.getElementById('duration-display');
|
|
||||||
const priceDisplay = document.getElementById('price-display');
|
|
||||||
const presetButtons = document.querySelectorAll('.duration-preset');
|
|
||||||
const userIdInput = document.getElementById('user-id');
|
|
||||||
const publicKeyInput = document.getElementById('public-key');
|
|
||||||
const regenerateButton = document.getElementById('regenerate-keys');
|
|
||||||
|
|
||||||
let currentKeyPair = null;
|
|
||||||
|
|
||||||
function formatDuration(hours) {
|
function formatDuration(hours) {
|
||||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'}`;
|
if (hours < 24) {
|
||||||
if (hours < 168) return `${hours / 24} day${hours === 24 ? '' : 's'}`;
|
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||||
if (hours < 720) return `${Math.floor(hours / 168)} week${hours === 168 ? '' : 's'}`;
|
}
|
||||||
|
if (hours < 168) {
|
||||||
|
return `${hours / 24} day${hours === 24 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
if (hours < 720) {
|
||||||
|
return `${Math.floor(hours / 168)} week${hours === 168 ? '' : 's'}`;
|
||||||
|
}
|
||||||
return `${Math.floor(hours / 720)} month${hours === 720 ? '' : 's'}`;
|
return `${Math.floor(hours / 720)} month${hours === 720 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateNewKeys() {
|
// Price calculation with volume discounts
|
||||||
try {
|
async function calculatePrice(hours) {
|
||||||
currentKeyPair = await generateKeyPair();
|
|
||||||
publicKeyInput.value = currentKeyPair.publicKey;
|
|
||||||
|
|
||||||
// Save private key to localStorage
|
|
||||||
const keyData = {
|
|
||||||
privateKey: currentKeyPair.privateKey,
|
|
||||||
publicKey: currentKeyPair.publicKey,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
localStorage.setItem(`vpn_keys_${userIdInput.value}`, JSON.stringify(keyData));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate keys:', error);
|
|
||||||
alert('Failed to generate WireGuard keys. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeForm() {
|
|
||||||
// Generate user ID
|
|
||||||
userIdInput.value = crypto.randomUUID();
|
|
||||||
|
|
||||||
// Generate initial keys
|
|
||||||
await generateNewKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updatePrice(hours) {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/calculate-price', {
|
const response = await fetch('/api/calculate-price', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ hours: parseInt(hours) })
|
body: JSON.stringify({ hours: parseInt(hours) })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to calculate price');
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
priceDisplay.textContent = data.price;
|
return {
|
||||||
durationDisplay.textContent = formatDuration(hours);
|
price: data.price,
|
||||||
|
formattedDuration: formatDuration(hours)
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error calculating price:', error);
|
console.error('Price calculation failed:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event listeners
|
// Form initialization and event handling
|
||||||
slider.addEventListener('input', () => updatePrice(slider.value));
|
function initializeForm(config) {
|
||||||
|
const {
|
||||||
|
formId = 'subscription-form',
|
||||||
|
sliderId = 'duration-slider',
|
||||||
|
priceDisplayId = 'price-display',
|
||||||
|
durationDisplayId = 'duration-display',
|
||||||
|
presetButtonClass = 'duration-preset'
|
||||||
|
} = config;
|
||||||
|
|
||||||
regenerateButton.addEventListener('click', generateNewKeys);
|
const form = document.getElementById(formId);
|
||||||
|
const slider = document.getElementById(sliderId);
|
||||||
|
const priceDisplay = document.getElementById(priceDisplayId);
|
||||||
|
const durationDisplay = document.getElementById(durationDisplayId);
|
||||||
|
const presetButtons = document.querySelectorAll(`.${presetButtonClass}`);
|
||||||
|
|
||||||
|
if (!form || !slider || !priceDisplay || !durationDisplay) {
|
||||||
|
throw new Error('Required elements not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
slider,
|
||||||
|
priceDisplay,
|
||||||
|
durationDisplay,
|
||||||
|
presetButtons
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main pricing interface
|
||||||
|
export const Pricing = {
|
||||||
|
async init(config = {}) {
|
||||||
|
try {
|
||||||
|
const elements = initializeForm(config);
|
||||||
|
const { form, slider, priceDisplay, durationDisplay, presetButtons } = elements;
|
||||||
|
|
||||||
|
// Update price when duration changes
|
||||||
|
const updateDisplay = async (hours) => {
|
||||||
|
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
|
||||||
|
slider.addEventListener('input', () => updateDisplay(slider.value));
|
||||||
|
|
||||||
presetButtons.forEach(button => {
|
presetButtons.forEach(button => {
|
||||||
button.addEventListener('click', (e) => {
|
button.addEventListener('click', (e) => {
|
||||||
const hours = e.target.dataset.hours;
|
const hours = e.target.dataset.hours;
|
||||||
slider.value = hours;
|
slider.value = hours;
|
||||||
updatePrice(hours);
|
updateDisplay(hours);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!currentKeyPair) {
|
|
||||||
alert('No keys generated. Please refresh the page.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/create-invoice', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
duration: parseInt(slider.value),
|
|
||||||
userId: userIdInput.value,
|
|
||||||
publicKey: currentKeyPair.publicKey
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to create invoice');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
window.location.href = data.checkout_url;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating invoice:', error);
|
|
||||||
alert('Failed to create payment invoice. Please try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize the form
|
|
||||||
await initializeForm();
|
|
||||||
// Initial price calculation
|
// Initial price calculation
|
||||||
updatePrice(slider.value);
|
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;
|
@ -1,11 +1,61 @@
|
|||||||
// Base64 encoding/decoding utilities
|
// Base64 encoding/decoding utilities with error handling
|
||||||
const b64 = {
|
const b64 = {
|
||||||
encode: array => btoa(String.fromCharCode.apply(null, array)),
|
encode: (array) => {
|
||||||
decode: str => Uint8Array.from(atob(str), c => c.charCodeAt(0))
|
try {
|
||||||
|
return btoa(String.fromCharCode.apply(null, array));
|
||||||
|
} 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));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Base64 decoding failed:', error);
|
||||||
|
throw new Error('Failed to decode key data');
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Key storage management
|
||||||
|
const keyStorage = {
|
||||||
|
store: (userId, keyData) => {
|
||||||
|
try {
|
||||||
|
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) => {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(`vpn_keys_${userId}`);
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to retrieve keys:', error);
|
||||||
|
throw new Error('Failed to retrieve key data');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: (userId) => {
|
||||||
|
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() {
|
||||||
// Generate a random key pair using Web Crypto API
|
try {
|
||||||
const keyPair = await window.crypto.subtle.generateKey(
|
const keyPair = await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: 'X25519',
|
name: 'X25519',
|
||||||
@ -15,40 +65,70 @@ const b64 = {
|
|||||||
['deriveKey', 'deriveBits']
|
['deriveKey', 'deriveBits']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Export keys in raw format
|
|
||||||
const privateKey = await window.crypto.subtle.exportKey('raw', keyPair.privateKey);
|
const privateKey = await window.crypto.subtle.exportKey('raw', keyPair.privateKey);
|
||||||
const publicKey = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
|
const publicKey = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||||
|
|
||||||
// Convert to base64
|
|
||||||
return {
|
return {
|
||||||
privateKey: b64.encode(new Uint8Array(privateKey)),
|
privateKey: b64.encode(new Uint8Array(privateKey)),
|
||||||
publicKey: b64.encode(new Uint8Array(publicKey))
|
publicKey: b64.encode(new Uint8Array(publicKey))
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Key generation failed:', error);
|
||||||
|
throw new Error('Failed to generate WireGuard keys');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateWireGuardConfig(serverPublicKey, serverEndpoint, address) {
|
// Key validation function
|
||||||
const keys = await generateKeyPair();
|
function validateKey(key) {
|
||||||
|
try {
|
||||||
|
const decoded = b64.decode(key);
|
||||||
|
return decoded.length === 32;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// WireGuard config generation
|
||||||
keys,
|
function generateConfig(keys, serverPublicKey, serverEndpoint, clientIp) {
|
||||||
config: `[Interface]
|
if (!keys || !serverPublicKey || !serverEndpoint || !clientIp) {
|
||||||
|
throw new Error('Missing required configuration parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[Interface]
|
||||||
PrivateKey = ${keys.privateKey}
|
PrivateKey = ${keys.privateKey}
|
||||||
Address = ${address}
|
Address = ${clientIp}/24
|
||||||
DNS = 1.1.1.1
|
DNS = 1.1.1.1
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
PublicKey = ${serverPublicKey}
|
PublicKey = ${serverPublicKey}
|
||||||
Endpoint = ${serverEndpoint}
|
Endpoint = ${serverEndpoint}:51820
|
||||||
AllowedIPs = 0.0.0.0/0
|
AllowedIPs = 0.0.0.0/0
|
||||||
PersistentKeepalive = 25`
|
PersistentKeepalive = 25`;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateKeys() {
|
// Main interface for key management
|
||||||
try {
|
export const WireGuard = {
|
||||||
|
generateKeys: async () => {
|
||||||
return await generateKeyPair();
|
return await generateKeyPair();
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Failed to generate WireGuard keys:', error);
|
|
||||||
throw error;
|
saveKeys: (userId, keyPair) => {
|
||||||
}
|
if (!validateKey(keyPair.publicKey) || !validateKey(keyPair.privateKey)) {
|
||||||
|
throw new Error('Invalid key data');
|
||||||
}
|
}
|
||||||
|
keyStorage.store(userId, keyPair);
|
||||||
|
},
|
||||||
|
|
||||||
|
getKeys: (userId) => {
|
||||||
|
return keyStorage.retrieve(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeKeys: (userId) => {
|
||||||
|
keyStorage.remove(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
generateConfig,
|
||||||
|
validateKey
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WireGuard;
|
@ -17,7 +17,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/pricing.js') }}" defer></script>
|
<!-- Import pricing.js from the correct path -->
|
||||||
|
<script type="module" src="/static/js/pricing.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-dark text-gray-100">
|
<body class="bg-dark text-gray-100">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
@ -74,4 +74,36 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="module">
|
||||||
|
import { WireGuard } from '/static/js/utils/wireguard.js';
|
||||||
|
import { Pricing } from '/static/js/pricing.js';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
|
const userId = Date.now().toString(); // Generate a unique user ID without prefix
|
||||||
|
document.getElementById('user-id').value = userId;
|
||||||
|
|
||||||
|
// Generate keys and set public key
|
||||||
|
const keyPair = await WireGuard.generateKeys();
|
||||||
|
document.getElementById('public-key').value = keyPair.publicKey;
|
||||||
|
|
||||||
|
// Store keys in local storage
|
||||||
|
WireGuard.saveKeys(userId, keyPair);
|
||||||
|
|
||||||
|
// Initialize pricing
|
||||||
|
Pricing.init({
|
||||||
|
formId: 'subscription-form',
|
||||||
|
sliderId: 'duration-slider',
|
||||||
|
priceDisplayId: 'price-display',
|
||||||
|
durationDisplayId: 'duration-display',
|
||||||
|
presetButtonClass: 'duration-preset'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -22,6 +22,12 @@
|
|||||||
>
|
>
|
||||||
Copy Configuration
|
Copy Configuration
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="download-config"
|
||||||
|
class="mt-2 w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Download Configuration
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Installation Instructions -->
|
<!-- Installation Instructions -->
|
||||||
@ -58,53 +64,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
import { WireGuard } from '/static/js/utils/wireguard.js';
|
||||||
|
|
||||||
|
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) {
|
||||||
// Retrieve keys from localStorage
|
console.error('No user ID found in URL');
|
||||||
const keyData = localStorage.getItem(`vpn_keys_${userId}`);
|
return;
|
||||||
if (keyData) {
|
}
|
||||||
const keys = JSON.parse(keyData);
|
|
||||||
|
|
||||||
// Make config section visible
|
try {
|
||||||
document.getElementById('config-section').classList.remove('hidden');
|
// Retrieve keys from storage
|
||||||
|
const keyData = WireGuard.getKeys(userId);
|
||||||
|
if (!keyData) {
|
||||||
|
console.error('No key data found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get server details from response
|
// Hardcoded server details for demonstration purposes
|
||||||
fetch(`/api/subscription/config?userId=${userId}`)
|
const serverData = {
|
||||||
.then(response => response.json())
|
serverPublicKey: 'your-server-public-key',
|
||||||
.then(data => {
|
serverEndpoint: 'your-server-endpoint',
|
||||||
const config = `[Interface]
|
clientIp: 'your-client-ip'
|
||||||
PrivateKey = ${keys.privateKey}
|
};
|
||||||
Address = ${data.clientIp}/24
|
|
||||||
DNS = 1.1.1.1
|
|
||||||
|
|
||||||
[Peer]
|
// Generate WireGuard config
|
||||||
PublicKey = ${data.serverPublicKey}
|
const config = WireGuard.generateConfig(
|
||||||
Endpoint = ${data.serverEndpoint}:51820
|
keyData,
|
||||||
AllowedIPs = 0.0.0.0/0
|
serverData.serverPublicKey,
|
||||||
PersistentKeepalive = 25`;
|
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;
|
document.getElementById('wireguard-config').textContent = config;
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error fetching configuration:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup copy button
|
// Setup copy button
|
||||||
document.getElementById('copy-config').addEventListener('click', function() {
|
document.getElementById('copy-config').addEventListener('click', async function() {
|
||||||
const config = document.getElementById('wireguard-config').textContent;
|
try {
|
||||||
navigator.clipboard.writeText(config).then(() => {
|
await navigator.clipboard.writeText(config);
|
||||||
this.textContent = 'Copied!';
|
this.textContent = 'Copied!';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.textContent = 'Copy Configuration';
|
this.textContent = 'Copy Configuration';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
} catch (error) {
|
||||||
|
console.error('Failed to copy config:', error);
|
||||||
|
alert('Failed to copy configuration. Please try manually copying.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear keys from localStorage after showing config
|
// Setup download button
|
||||||
localStorage.removeItem(`vpn_keys_${userId}`);
|
document.getElementById('download-config').addEventListener('click', function() {
|
||||||
|
try {
|
||||||
|
const blob = new Blob([config], { 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 keys from storage after successful display
|
||||||
|
WireGuard.removeKeys(userId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting up configuration:', error);
|
||||||
|
alert('Failed to load VPN configuration. Please contact support.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
Loading…
Reference in New Issue
Block a user