more updates
This commit is contained in:
parent
dd2a1b4c83
commit
b44860a0ab
@ -15,158 +15,223 @@ from ..utils.db.models import SubscriptionStatus
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Enhanced logging configuration
|
||||
logging.basicConfig(
|
||||
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__)
|
||||
|
||||
# Constants
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
PLAYBOOK_PATH = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_provision.yml'
|
||||
CLEANUP_PLAYBOOK = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_cleanup.yml'
|
||||
|
||||
class WebhookError(Exception):
|
||||
"""Custom exception for webhook handling errors"""
|
||||
pass
|
||||
|
||||
def get_vault_values():
|
||||
"""Get decrypted values from Ansible vault"""
|
||||
try:
|
||||
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '')
|
||||
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD')
|
||||
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:
|
||||
vault_pass_file.write(vault_pass)
|
||||
vault_pass_file.flush()
|
||||
|
||||
# Execute ansible-vault with error checking
|
||||
result = subprocess.run(
|
||||
['ansible-vault', 'view', str(BASE_DIR / 'ansible/group_vars/vpn_servers/vault.yml')],
|
||||
capture_output=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)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"Failed to decrypt vault: {result.stderr}")
|
||||
try:
|
||||
vault_contents = yaml.safe_load(result.stdout)
|
||||
if not vault_contents:
|
||||
raise WebhookError("Empty vault contents")
|
||||
|
||||
vault_contents = yaml.safe_load(result.stdout)
|
||||
vault_contents['webhook_full_url'] = (
|
||||
f"{vault_contents['btcpay_base_url']}"
|
||||
f"{vault_contents['btcpay_webhook_path']}"
|
||||
)
|
||||
# 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)}")
|
||||
|
||||
return vault_contents
|
||||
vault_contents['webhook_full_url'] = (
|
||||
f"{vault_contents['btcpay_base_url']}"
|
||||
f"{vault_contents.get('btcpay_webhook_path', '/webhook/vpn')}"
|
||||
)
|
||||
|
||||
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:
|
||||
logger.error(f"Error reading vault: {str(e)}")
|
||||
raise
|
||||
logger.error(f"Error reading vault: {traceback.format_exc()}")
|
||||
raise WebhookError(f"Vault operation failed: {str(e)}")
|
||||
|
||||
def verify_signature(payload_body, signature_header):
|
||||
"""Verify BTCPay webhook signature"""
|
||||
def verify_signature(payload_body: bytes, signature_header: str) -> bool:
|
||||
"""
|
||||
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:
|
||||
vault_values = get_vault_values()
|
||||
secret = vault_values['webhook_secret']
|
||||
if not signature_header:
|
||||
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(
|
||||
secret.encode('utf-8'),
|
||||
webhook_secret.encode('utf-8'),
|
||||
payload_body,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Constant-time comparison
|
||||
return hmac.compare_digest(
|
||||
signature_header.lower(),
|
||||
f"sha256={expected_signature}".lower()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Signature verification failed: {str(e)}")
|
||||
logger.error(f"Signature verification failed: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
def run_ansible_playbook(invoice_id):
|
||||
"""Run the VPN provisioning playbook"""
|
||||
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '')
|
||||
if not vault_pass:
|
||||
raise Exception("Vault password not found in environment variables")
|
||||
def run_ansible_playbook(invoice_id: str, cleanup: bool = False) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Run the appropriate Ansible playbook with proper error handling
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
|
||||
vault_pass_file.write(vault_pass)
|
||||
vault_pass_file.flush()
|
||||
Args:
|
||||
invoice_id: BTCPay invoice ID
|
||||
cleanup: Whether to run cleanup playbook instead of provision
|
||||
|
||||
cmd = [
|
||||
'ansible-playbook',
|
||||
str(PLAYBOOK_PATH),
|
||||
'-i', str(BASE_DIR / 'inventory.ini'),
|
||||
'-e', f'invoice_id={invoice_id}',
|
||||
'--vault-password-file', vault_pass_file.name,
|
||||
'-vvv'
|
||||
]
|
||||
Returns:
|
||||
subprocess.CompletedProcess: Playbook execution result
|
||||
|
||||
logger.info(f"Running ansible-playbook command: {' '.join(cmd)}")
|
||||
Raises:
|
||||
WebhookError: If playbook execution fails
|
||||
"""
|
||||
try:
|
||||
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD')
|
||||
if not vault_pass:
|
||||
raise WebhookError("Vault password not found in environment variables")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
|
||||
vault_pass_file.write(vault_pass)
|
||||
vault_pass_file.flush()
|
||||
|
||||
os.unlink(vault_pass_file.name)
|
||||
return result
|
||||
playbook = CLEANUP_PLAYBOOK if cleanup else PLAYBOOK_PATH
|
||||
|
||||
def handle_subscription_status(data):
|
||||
cmd = [
|
||||
'ansible-playbook',
|
||||
str(playbook),
|
||||
'-i', str(BASE_DIR / 'inventory.ini'),
|
||||
'-e', f'invoice_id={invoice_id}',
|
||||
'--vault-password-file', vault_pass_file.name,
|
||||
'-vvv'
|
||||
]
|
||||
|
||||
logger.info(f"Running ansible-playbook command: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True # This will raise CalledProcessError if playbook fails
|
||||
)
|
||||
|
||||
if "fatal:" in result.stdout or "fatal:" in result.stderr:
|
||||
raise WebhookError("Ansible playbook reported fatal error")
|
||||
|
||||
return result
|
||||
|
||||
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"""
|
||||
sub_id = data['subscriptionId']
|
||||
status = data['status']
|
||||
try:
|
||||
sub_id = data.get('subscriptionId')
|
||||
status = data.get('status')
|
||||
|
||||
logger.info(f"Processing subscription status update: {sub_id} -> {status}")
|
||||
if not sub_id or not status:
|
||||
return jsonify({"error": "Missing required fields"}), 400
|
||||
|
||||
subscription = DatabaseManager.get_subscription_by_invoice(sub_id)
|
||||
if not subscription:
|
||||
logger.error(f"Subscription {sub_id} not found")
|
||||
return jsonify({"error": "Subscription not found"}), 404
|
||||
logger.info(f"Processing subscription status update: {sub_id} -> {status}")
|
||||
|
||||
if status == 'Active':
|
||||
DatabaseManager.activate_subscription(sub_id)
|
||||
else:
|
||||
# Run cleanup for inactive subscriptions
|
||||
result = subprocess.run([
|
||||
'ansible-playbook',
|
||||
str(CLEANUP_PLAYBOOK),
|
||||
'-i', str(BASE_DIR / 'inventory.ini'),
|
||||
'-e', f'subscription_id={sub_id}',
|
||||
'-vvv'
|
||||
], capture_output=True, text=True)
|
||||
subscription = DatabaseManager.get_subscription_by_invoice(sub_id)
|
||||
if not subscription:
|
||||
logger.error(f"Subscription {sub_id} not found")
|
||||
return jsonify({"error": "Subscription not found"}), 404
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to clean up subscription {sub_id}: {result.stderr}")
|
||||
if status == 'Active':
|
||||
DatabaseManager.activate_subscription(sub_id)
|
||||
else:
|
||||
# Run cleanup for inactive subscriptions
|
||||
try:
|
||||
result = run_ansible_playbook(sub_id, cleanup=True)
|
||||
logger.info(f"Cleanup playbook completed for {sub_id}: {result.stdout}")
|
||||
except WebhookError as e:
|
||||
logger.error(f"Failed to clean up subscription {sub_id}: {str(e)}")
|
||||
|
||||
DatabaseManager.expire_subscription(subscription.id)
|
||||
logger.info(f"Subscription {sub_id} is no longer active")
|
||||
DatabaseManager.expire_subscription(subscription.id)
|
||||
logger.info(f"Subscription {sub_id} is no longer active")
|
||||
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": f"Subscription {sub_id} status updated to {status}"
|
||||
})
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": f"Subscription {sub_id} status updated to {status}"
|
||||
}), 200
|
||||
|
||||
def handle_subscription_renewal(data):
|
||||
"""Handle SubscriptionRenewalRequested webhook"""
|
||||
sub_id = data['subscriptionId']
|
||||
logger.info(f"Processing subscription renewal request: {sub_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling subscription status: {traceback.format_exc()}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
subscription = DatabaseManager.get_subscription_by_invoice(sub_id)
|
||||
if not subscription:
|
||||
logger.error(f"Subscription {sub_id} not found")
|
||||
return jsonify({"error": "Subscription not found"}), 404
|
||||
def handle_payment_webhook(request) -> tuple:
|
||||
"""
|
||||
Handle BTCPay Server webhook for VPN provisioning
|
||||
|
||||
# TODO: Send renewal notification to user
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": f"Subscription {sub_id} renewal requested"
|
||||
})
|
||||
|
||||
def handle_payment_webhook(request):
|
||||
"""Handle BTCPay Server webhook for VPN provisioning"""
|
||||
Returns:
|
||||
tuple: (response, status_code)
|
||||
"""
|
||||
try:
|
||||
vault_values = get_vault_values()
|
||||
logger.info(f"Processing webhook on endpoint: {vault_values['webhook_full_url']}")
|
||||
|
||||
# Verify signature
|
||||
signature = request.headers.get('BTCPay-Sig')
|
||||
if not signature:
|
||||
logger.error("Missing BTCPay-Sig header")
|
||||
@ -177,7 +242,16 @@ def handle_payment_webhook(request):
|
||||
logger.error("Invalid signature")
|
||||
return jsonify({"error": "Invalid signature"}), 401
|
||||
|
||||
data = request.json
|
||||
# Parse and validate payload
|
||||
try:
|
||||
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}")
|
||||
|
||||
# Handle test webhooks
|
||||
@ -187,67 +261,61 @@ def handle_payment_webhook(request):
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"message": "Test webhook acknowledged"
|
||||
})
|
||||
}), 200
|
||||
|
||||
webhook_type = data.get('type')
|
||||
if not webhook_type:
|
||||
return jsonify({"error": "Missing webhook type"}), 400
|
||||
|
||||
# Handle different webhook types
|
||||
if webhook_type == 'SubscriptionStatusUpdated':
|
||||
return handle_subscription_status(data)
|
||||
|
||||
elif webhook_type == 'SubscriptionRenewalRequested':
|
||||
return handle_subscription_renewal(data)
|
||||
|
||||
elif webhook_type in ['InvoiceSettled', 'InvoicePaymentSettled']:
|
||||
invoice_id = data.get('invoiceId')
|
||||
elif webhook_type == 'InvoiceSettled' or webhook_type == 'InvoicePaymentSettled':
|
||||
if not invoice_id:
|
||||
logger.error("Missing invoiceId in webhook data")
|
||||
return jsonify({"error": "Missing invoiceId"}), 400
|
||||
|
||||
# Get subscription and run Ansible playbook
|
||||
logger.info(f"Starting VPN provisioning for invoice {invoice_id}")
|
||||
result = run_ansible_playbook(invoice_id)
|
||||
try:
|
||||
# Run VPN provisioning
|
||||
logger.info(f"Starting VPN provisioning for invoice {invoice_id}")
|
||||
result = run_ansible_playbook(invoice_id)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = f"Ansible playbook failed with return code {result.returncode}"
|
||||
logger.error(error_msg)
|
||||
logger.error(f"Ansible stdout: {result.stdout}")
|
||||
logger.error(f"Ansible stderr: {result.stderr}")
|
||||
# Update subscription status
|
||||
subscription = DatabaseManager.get_subscription_by_invoice(invoice_id)
|
||||
if subscription:
|
||||
subscription = DatabaseManager.activate_subscription(invoice_id)
|
||||
DatabaseManager.record_payment(
|
||||
subscription.user_id,
|
||||
subscription.id,
|
||||
invoice_id,
|
||||
data.get('amount', 0)
|
||||
)
|
||||
|
||||
logger.info(f"VPN provisioning completed for invoice {invoice_id}")
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"invoice_id": invoice_id,
|
||||
"message": "VPN provisioning completed"
|
||||
}), 200
|
||||
|
||||
except WebhookError as e:
|
||||
logger.error(f"VPN provisioning failed: {str(e)}")
|
||||
return jsonify({
|
||||
"error": "Provisioning failed",
|
||||
"details": error_msg,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr
|
||||
"details": str(e)
|
||||
}), 500
|
||||
|
||||
# Get subscription and activate it
|
||||
subscription = DatabaseManager.get_subscription_by_invoice(invoice_id)
|
||||
if subscription:
|
||||
subscription = DatabaseManager.activate_subscription(invoice_id)
|
||||
DatabaseManager.record_payment(
|
||||
subscription.user_id,
|
||||
subscription.id,
|
||||
invoice_id,
|
||||
data.get('amount', 0)
|
||||
)
|
||||
|
||||
logger.info(f"VPN provisioning completed for invoice {invoice_id}")
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"invoice_id": invoice_id,
|
||||
"message": "VPN provisioning completed"
|
||||
})
|
||||
|
||||
else:
|
||||
logger.info(f"Received {webhook_type} webhook - no action required")
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"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:
|
||||
logger.error(f"Error processing webhook: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
}), 500
|
||||
logger.error(f"Unexpected error: {traceback.format_exc()}")
|
||||
return jsonify({"error": "Internal server error"}), 500
|
@ -1,137 +1,116 @@
|
||||
// Base64 encoding/decoding utilities
|
||||
const b64 = {
|
||||
encode: array => btoa(String.fromCharCode.apply(null, array)),
|
||||
decode: str => Uint8Array.from(atob(str), c => c.charCodeAt(0))
|
||||
};
|
||||
// Utility functions for duration formatting
|
||||
function formatDuration(hours) {
|
||||
if (hours < 24) {
|
||||
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (hours < 168) {
|
||||
return `${hours / 24} day${hours === 24 ? '' : 's'}`;
|
||||
}
|
||||
if (hours < 720) {
|
||||
return `${Math.floor(hours / 168)} week${hours === 168 ? '' : 's'}`;
|
||||
}
|
||||
return `${Math.floor(hours / 720)} month${hours === 720 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
async function generateKeyPair() {
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'X25519',
|
||||
namedCurve: 'X25519',
|
||||
},
|
||||
true,
|
||||
['deriveKey', 'deriveBits']
|
||||
);
|
||||
// Price calculation with volume discounts
|
||||
async function calculatePrice(hours) {
|
||||
try {
|
||||
const response = await fetch('/api/calculate-price', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hours: parseInt(hours) })
|
||||
});
|
||||
|
||||
const privateKey = await window.crypto.subtle.exportKey('raw', keyPair.privateKey);
|
||||
const publicKey = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to calculate price');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
price: data.price,
|
||||
formattedDuration: formatDuration(hours)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Price calculation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Form initialization and event handling
|
||||
function initializeForm(config) {
|
||||
const {
|
||||
formId = 'subscription-form',
|
||||
sliderId = 'duration-slider',
|
||||
priceDisplayId = 'price-display',
|
||||
durationDisplayId = 'duration-display',
|
||||
presetButtonClass = 'duration-preset'
|
||||
} = config;
|
||||
|
||||
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 {
|
||||
privateKey: b64.encode(new Uint8Array(privateKey)),
|
||||
publicKey: b64.encode(new Uint8Array(publicKey))
|
||||
form,
|
||||
slider,
|
||||
priceDisplay,
|
||||
durationDisplay,
|
||||
presetButtons
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||
if (hours < 168) return `${hours / 24} day${hours === 24 ? '' : 's'}`;
|
||||
if (hours < 720) return `${Math.floor(hours / 168)} week${hours === 168 ? '' : 's'}`;
|
||||
return `${Math.floor(hours / 720)} month${hours === 720 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
async function generateNewKeys() {
|
||||
// Main pricing interface
|
||||
export const Pricing = {
|
||||
async init(config = {}) {
|
||||
try {
|
||||
currentKeyPair = await generateKeyPair();
|
||||
publicKeyInput.value = currentKeyPair.publicKey;
|
||||
const elements = initializeForm(config);
|
||||
const { form, slider, priceDisplay, durationDisplay, presetButtons } = elements;
|
||||
|
||||
// Save private key to localStorage
|
||||
const keyData = {
|
||||
privateKey: currentKeyPair.privateKey,
|
||||
publicKey: currentKeyPair.publicKey,
|
||||
createdAt: new Date().toISOString()
|
||||
// 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';
|
||||
}
|
||||
};
|
||||
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();
|
||||
// Set up event listeners
|
||||
slider.addEventListener('input', () => updateDisplay(slider.value));
|
||||
|
||||
// Generate initial keys
|
||||
await generateNewKeys();
|
||||
}
|
||||
|
||||
async function updatePrice(hours) {
|
||||
try {
|
||||
const response = await fetch('/api/calculate-price', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hours: parseInt(hours) })
|
||||
});
|
||||
const data = await response.json();
|
||||
priceDisplay.textContent = data.price;
|
||||
durationDisplay.textContent = formatDuration(hours);
|
||||
} catch (error) {
|
||||
console.error('Error calculating price:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
slider.addEventListener('input', () => updatePrice(slider.value));
|
||||
|
||||
regenerateButton.addEventListener('click', generateNewKeys);
|
||||
|
||||
presetButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const hours = e.target.dataset.hours;
|
||||
slider.value = hours;
|
||||
updatePrice(hours);
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
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
|
||||
})
|
||||
presetButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const hours = e.target.dataset.hours;
|
||||
slider.value = hours;
|
||||
updateDisplay(hours);
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create invoice');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
window.location.href = data.checkout_url;
|
||||
// Initial price calculation
|
||||
await updateDisplay(slider.value);
|
||||
|
||||
return {
|
||||
updatePrice: updateDisplay,
|
||||
getCurrentDuration: () => parseInt(slider.value)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating invoice:', error);
|
||||
alert('Failed to create payment invoice. Please try again.');
|
||||
console.error('Failed to initialize pricing:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Initialize the form
|
||||
await initializeForm();
|
||||
// Initial price calculation
|
||||
updatePrice(slider.value);
|
||||
});
|
||||
formatDuration,
|
||||
calculatePrice
|
||||
};
|
||||
|
||||
export default Pricing;
|
@ -1,54 +1,134 @@
|
||||
// Base64 encoding/decoding utilities
|
||||
// Base64 encoding/decoding utilities with error handling
|
||||
const b64 = {
|
||||
encode: array => btoa(String.fromCharCode.apply(null, array)),
|
||||
decode: str => Uint8Array.from(atob(str), c => c.charCodeAt(0))
|
||||
};
|
||||
encode: (array) => {
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function generateKeyPair() {
|
||||
// Generate a random key pair using Web Crypto API
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'X25519',
|
||||
namedCurve: 'X25519',
|
||||
},
|
||||
true,
|
||||
['deriveKey', 'deriveBits']
|
||||
);
|
||||
// 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');
|
||||
}
|
||||
},
|
||||
|
||||
// Export keys in raw format
|
||||
const privateKey = await window.crypto.subtle.exportKey('raw', keyPair.privateKey);
|
||||
const publicKey = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
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');
|
||||
}
|
||||
},
|
||||
|
||||
// Convert to base64
|
||||
return {
|
||||
privateKey: b64.encode(new Uint8Array(privateKey)),
|
||||
publicKey: b64.encode(new Uint8Array(publicKey))
|
||||
};
|
||||
remove: (userId) => {
|
||||
try {
|
||||
localStorage.removeItem(`vpn_keys_${userId}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove keys:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Main key generation function
|
||||
async function generateKeyPair() {
|
||||
try {
|
||||
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))
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Key generation failed:', error);
|
||||
throw new Error('Failed to generate WireGuard keys');
|
||||
}
|
||||
}
|
||||
|
||||
// Key validation function
|
||||
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');
|
||||
}
|
||||
|
||||
export async function generateWireGuardConfig(serverPublicKey, serverEndpoint, address) {
|
||||
const keys = await generateKeyPair();
|
||||
return `[Interface]
|
||||
PrivateKey = ${keys.privateKey}
|
||||
Address = ${clientIp}/24
|
||||
DNS = 1.1.1.1
|
||||
|
||||
return {
|
||||
keys,
|
||||
config: `[Interface]
|
||||
PrivateKey = ${keys.privateKey}
|
||||
Address = ${address}
|
||||
DNS = 1.1.1.1
|
||||
[Peer]
|
||||
PublicKey = ${serverPublicKey}
|
||||
Endpoint = ${serverEndpoint}:51820
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
PersistentKeepalive = 25`;
|
||||
}
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${serverPublicKey}
|
||||
Endpoint = ${serverEndpoint}
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
PersistentKeepalive = 25`
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateKeys() {
|
||||
try {
|
||||
// Main interface for key management
|
||||
export const WireGuard = {
|
||||
generateKeys: async () => {
|
||||
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 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>
|
||||
<body class="bg-dark text-gray-100">
|
||||
{% block content %}{% endblock %}
|
||||
|
@ -74,4 +74,36 @@
|
||||
</form>
|
||||
</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 %}
|
@ -22,6 +22,12 @@
|
||||
>
|
||||
Copy Configuration
|
||||
</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>
|
||||
|
||||
<!-- Installation Instructions -->
|
||||
@ -58,53 +64,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
<script type="module">
|
||||
import { WireGuard } from '/static/js/utils/wireguard.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const userId = new URLSearchParams(window.location.search).get('userId');
|
||||
if (userId) {
|
||||
// Retrieve keys from localStorage
|
||||
const keyData = localStorage.getItem(`vpn_keys_${userId}`);
|
||||
if (keyData) {
|
||||
const keys = JSON.parse(keyData);
|
||||
if (!userId) {
|
||||
console.error('No user ID found in URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Make config section visible
|
||||
document.getElementById('config-section').classList.remove('hidden');
|
||||
|
||||
// Get server details from response
|
||||
fetch(`/api/subscription/config?userId=${userId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const config = `[Interface]
|
||||
PrivateKey = ${keys.privateKey}
|
||||
Address = ${data.clientIp}/24
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${data.serverPublicKey}
|
||||
Endpoint = ${data.serverEndpoint}:51820
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
PersistentKeepalive = 25`;
|
||||
|
||||
document.getElementById('wireguard-config').textContent = config;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching configuration:', error);
|
||||
});
|
||||
|
||||
// Setup copy button
|
||||
document.getElementById('copy-config').addEventListener('click', function() {
|
||||
const config = document.getElementById('wireguard-config').textContent;
|
||||
navigator.clipboard.writeText(config).then(() => {
|
||||
this.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
this.textContent = 'Copy Configuration';
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// Clear keys from localStorage after showing config
|
||||
localStorage.removeItem(`vpn_keys_${userId}`);
|
||||
try {
|
||||
// Retrieve keys from storage
|
||||
const keyData = WireGuard.getKeys(userId);
|
||||
if (!keyData) {
|
||||
console.error('No key data found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hardcoded server details for demonstration purposes
|
||||
const serverData = {
|
||||
serverPublicKey: 'your-server-public-key',
|
||||
serverEndpoint: 'your-server-endpoint',
|
||||
clientIp: 'your-client-ip'
|
||||
};
|
||||
|
||||
// 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.');
|
||||
}
|
||||
});
|
||||
|
||||
// Setup download button
|
||||
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>
|
||||
|
Loading…
Reference in New Issue
Block a user