2024-12-11 09:05:26 +00:00
|
|
|
import tempfile
|
|
|
|
from flask import jsonify
|
2024-12-11 08:27:42 +00:00
|
|
|
import subprocess
|
|
|
|
import os
|
|
|
|
import logging
|
|
|
|
import hmac
|
|
|
|
import hashlib
|
|
|
|
import yaml
|
2024-12-13 09:57:12 +00:00
|
|
|
import json
|
|
|
|
import datetime
|
2024-12-11 08:27:42 +00:00
|
|
|
import traceback
|
2024-12-11 09:05:26 +00:00
|
|
|
from pathlib import Path
|
2024-12-11 08:27:42 +00:00
|
|
|
from dotenv import load_dotenv
|
|
|
|
|
|
|
|
load_dotenv()
|
|
|
|
|
|
|
|
logging.basicConfig(
|
|
|
|
level=logging.INFO,
|
|
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|
|
|
PLAYBOOK_PATH = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_provision.yml'
|
2024-12-13 09:57:12 +00:00
|
|
|
CLEANUP_PLAYBOOK = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_cleanup.yml'
|
|
|
|
SUBSCRIPTION_DB = BASE_DIR / 'data' / 'subscriptions.json'
|
2024-12-11 08:27:42 +00:00
|
|
|
|
|
|
|
def get_vault_values():
|
|
|
|
"""Get decrypted values from Ansible vault"""
|
|
|
|
try:
|
2024-12-11 09:05:26 +00:00
|
|
|
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '')
|
|
|
|
if not vault_pass:
|
|
|
|
raise Exception("Vault password not found in environment variables")
|
|
|
|
|
|
|
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
|
|
|
|
vault_pass_file.write(vault_pass)
|
|
|
|
vault_pass_file.flush()
|
|
|
|
|
|
|
|
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}
|
|
|
|
)
|
2024-12-11 08:27:42 +00:00
|
|
|
|
2024-12-11 09:05:26 +00:00
|
|
|
os.unlink(vault_pass_file.name)
|
|
|
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
raise Exception(f"Failed to decrypt vault: {result.stderr}")
|
|
|
|
|
|
|
|
vault_contents = yaml.safe_load(result.stdout)
|
|
|
|
vault_contents['webhook_full_url'] = (
|
|
|
|
f"{vault_contents['btcpay_base_url']}"
|
|
|
|
f"{vault_contents['btcpay_webhook_path']}"
|
2024-12-11 08:27:42 +00:00
|
|
|
)
|
|
|
|
|
2024-12-11 09:05:26 +00:00
|
|
|
return vault_contents
|
2024-12-11 08:27:42 +00:00
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error reading vault: {str(e)}")
|
|
|
|
raise
|
|
|
|
|
|
|
|
def verify_signature(payload_body, signature_header):
|
|
|
|
"""Verify BTCPay webhook signature"""
|
|
|
|
try:
|
|
|
|
vault_values = get_vault_values()
|
|
|
|
secret = vault_values['webhook_secret']
|
|
|
|
|
|
|
|
expected_signature = hmac.new(
|
|
|
|
secret.encode('utf-8'),
|
|
|
|
payload_body,
|
|
|
|
hashlib.sha256
|
|
|
|
).hexdigest()
|
|
|
|
|
|
|
|
return hmac.compare_digest(
|
|
|
|
signature_header.lower(),
|
|
|
|
f"sha256={expected_signature}".lower()
|
|
|
|
)
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Signature verification failed: {str(e)}")
|
|
|
|
return False
|
|
|
|
|
2024-12-13 09:57:12 +00:00
|
|
|
def load_subscriptions():
|
|
|
|
"""Load subscription data from JSON file"""
|
|
|
|
if not SUBSCRIPTION_DB.parent.exists():
|
|
|
|
SUBSCRIPTION_DB.parent.mkdir(parents=True)
|
|
|
|
|
|
|
|
if not SUBSCRIPTION_DB.exists():
|
|
|
|
return {}
|
|
|
|
|
|
|
|
with open(SUBSCRIPTION_DB, 'r') as f:
|
|
|
|
return json.load(f)
|
|
|
|
|
|
|
|
def save_subscription(subscription_data):
|
|
|
|
"""Save subscription data to JSON file"""
|
|
|
|
subscriptions = load_subscriptions()
|
|
|
|
sub_id = subscription_data['subscriptionId']
|
|
|
|
subscriptions[sub_id] = subscription_data
|
|
|
|
|
|
|
|
with open(SUBSCRIPTION_DB, 'w') as f:
|
|
|
|
json.dump(subscriptions, f, indent=2)
|
|
|
|
|
|
|
|
def calculate_expiry(duration_hours):
|
|
|
|
"""Calculate expiry date based on subscription duration"""
|
|
|
|
return (datetime.datetime.now() +
|
|
|
|
datetime.timedelta(hours=duration_hours)).isoformat()
|
|
|
|
|
|
|
|
def run_ansible_playbook(invoice_id):
|
|
|
|
"""Run the VPN provisioning playbook"""
|
|
|
|
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '')
|
|
|
|
if not vault_pass:
|
|
|
|
raise Exception("Vault password not found in environment variables")
|
|
|
|
|
|
|
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
|
|
|
|
vault_pass_file.write(vault_pass)
|
|
|
|
vault_pass_file.flush()
|
|
|
|
|
|
|
|
cmd = [
|
|
|
|
'ansible-playbook',
|
|
|
|
str(PLAYBOOK_PATH),
|
|
|
|
'-i', str(BASE_DIR / 'inventory.ini'),
|
|
|
|
'-e', f'invoice_id={invoice_id}',
|
|
|
|
'--vault-password-file', vault_pass_file.name,
|
|
|
|
'-vvv'
|
|
|
|
]
|
|
|
|
|
|
|
|
logger.info(f"Running ansible-playbook command: {' '.join(cmd)}")
|
|
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
cmd,
|
|
|
|
capture_output=True,
|
|
|
|
text=True
|
|
|
|
)
|
|
|
|
|
|
|
|
os.unlink(vault_pass_file.name)
|
|
|
|
return result
|
|
|
|
|
|
|
|
def handle_subscription_status(data):
|
|
|
|
"""Handle SubscriptionStatusUpdated webhook"""
|
|
|
|
sub_id = data['subscriptionId']
|
|
|
|
status = data['status']
|
|
|
|
|
|
|
|
logger.info(f"Processing subscription status update: {sub_id} -> {status}")
|
|
|
|
|
|
|
|
# Store subscription data
|
|
|
|
data['last_updated'] = datetime.datetime.now().isoformat()
|
|
|
|
save_subscription(data)
|
|
|
|
|
|
|
|
if status != 'Active':
|
|
|
|
# Run cleanup playbook for inactive subscriptions
|
|
|
|
result = subprocess.run([
|
|
|
|
'ansible-playbook',
|
|
|
|
str(CLEANUP_PLAYBOOK),
|
|
|
|
'-i', str(BASE_DIR / 'inventory.ini'),
|
|
|
|
'-e', f'subscription_id={sub_id}',
|
|
|
|
'-vvv'
|
|
|
|
], capture_output=True, text=True)
|
|
|
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
logger.error(f"Failed to clean up subscription {sub_id}: {result.stderr}")
|
|
|
|
|
|
|
|
logger.info(f"Subscription {sub_id} is no longer active")
|
|
|
|
|
|
|
|
return jsonify({
|
|
|
|
"status": "success",
|
|
|
|
"message": f"Subscription {sub_id} status updated to {status}"
|
|
|
|
})
|
|
|
|
|
|
|
|
def handle_subscription_renewal(data):
|
|
|
|
"""Handle SubscriptionRenewalRequested webhook"""
|
|
|
|
sub_id = data['subscriptionId']
|
|
|
|
logger.info(f"Processing subscription renewal request: {sub_id}")
|
|
|
|
|
|
|
|
# Update subscription data
|
|
|
|
data['renewal_requested'] = datetime.datetime.now().isoformat()
|
|
|
|
save_subscription(data)
|
|
|
|
|
|
|
|
# TODO: Send renewal notification to user
|
|
|
|
return jsonify({
|
|
|
|
"status": "success",
|
|
|
|
"message": f"Subscription {sub_id} renewal requested"
|
|
|
|
})
|
|
|
|
|
2024-12-11 09:05:26 +00:00
|
|
|
def handle_payment_webhook(request):
|
|
|
|
"""Handle BTCPay Server webhook for VPN provisioning"""
|
2024-12-11 08:27:42 +00:00
|
|
|
try:
|
|
|
|
vault_values = get_vault_values()
|
|
|
|
logger.info(f"Processing webhook on endpoint: {vault_values['webhook_full_url']}")
|
|
|
|
|
|
|
|
signature = request.headers.get('BTCPay-Sig')
|
|
|
|
if not signature:
|
|
|
|
logger.error("Missing BTCPay-Sig header")
|
|
|
|
return jsonify({"error": "Missing signature"}), 401
|
|
|
|
|
|
|
|
is_valid = verify_signature(request.get_data(), signature)
|
|
|
|
if not is_valid:
|
|
|
|
logger.error("Invalid signature")
|
|
|
|
return jsonify({"error": "Invalid signature"}), 401
|
|
|
|
|
|
|
|
data = request.json
|
|
|
|
logger.info(f"Received webhook data: {data}")
|
2024-12-13 09:57:12 +00:00
|
|
|
webhook_type = data.get('type')
|
2024-12-11 08:27:42 +00:00
|
|
|
|
2024-12-13 09:57:12 +00:00
|
|
|
if webhook_type == 'SubscriptionStatusUpdated':
|
|
|
|
return handle_subscription_status(data)
|
2024-12-11 08:27:42 +00:00
|
|
|
|
2024-12-13 09:57:12 +00:00
|
|
|
elif webhook_type == 'SubscriptionRenewalRequested':
|
|
|
|
return handle_subscription_renewal(data)
|
2024-12-11 08:27:42 +00:00
|
|
|
|
2024-12-13 09:57:12 +00:00
|
|
|
elif webhook_type == 'InvoiceSettled':
|
|
|
|
# Handle regular invoice payment
|
|
|
|
invoice_id = data.get('invoiceId')
|
|
|
|
metadata = data.get('metadata', {})
|
2024-12-11 08:27:42 +00:00
|
|
|
|
2024-12-13 09:57:12 +00:00
|
|
|
if not invoice_id:
|
|
|
|
logger.error("Missing invoiceId in webhook data")
|
|
|
|
return jsonify({"error": "Missing invoiceId"}), 400
|
|
|
|
|
|
|
|
if invoice_id.startswith('__test__') and invoice_id.endswith('__test__'):
|
|
|
|
invoice_id = invoice_id[8:-8]
|
|
|
|
logger.info(f"Stripped test markers from invoice ID: {invoice_id}")
|
2024-12-11 08:27:42 +00:00
|
|
|
|
2024-12-13 09:57:12 +00:00
|
|
|
# Run Ansible playbook with enhanced logging
|
|
|
|
logger.info(f"Starting VPN provisioning for invoice {invoice_id}")
|
|
|
|
result = run_ansible_playbook(invoice_id)
|
|
|
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
error_msg = f"Ansible playbook failed with return code {result.returncode}"
|
|
|
|
logger.error(error_msg)
|
|
|
|
logger.error(f"Ansible stdout: {result.stdout}")
|
|
|
|
logger.error(f"Ansible stderr: {result.stderr}")
|
|
|
|
return jsonify({
|
|
|
|
"error": "Provisioning failed",
|
|
|
|
"details": error_msg,
|
|
|
|
"stdout": result.stdout,
|
|
|
|
"stderr": result.stderr
|
|
|
|
}), 500
|
|
|
|
|
|
|
|
logger.info(f"VPN provisioning completed for invoice {invoice_id}")
|
|
|
|
|
|
|
|
# Update subscription database
|
|
|
|
try:
|
|
|
|
duration_hours = metadata.get('duration_hours', 24)
|
|
|
|
subscriptions = load_subscriptions()
|
|
|
|
subscriptions[invoice_id] = {
|
|
|
|
'email': metadata.get('email'),
|
|
|
|
'duration_hours': duration_hours,
|
|
|
|
'start_time': datetime.datetime.now().isoformat(),
|
|
|
|
'expiry': calculate_expiry(duration_hours),
|
|
|
|
'status': 'Active'
|
|
|
|
}
|
|
|
|
with open(SUBSCRIPTION_DB, 'w') as f:
|
|
|
|
json.dump(subscriptions, f, indent=2)
|
|
|
|
logger.info(f"Updated subscription database for invoice {invoice_id}")
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error updating subscription database: {str(e)}")
|
|
|
|
|
|
|
|
# Send email confirmation if email is provided
|
|
|
|
try:
|
|
|
|
email = metadata.get('email')
|
|
|
|
if email:
|
|
|
|
config_path = f"/etc/wireguard/clients/{invoice_id}/wg0.conf"
|
|
|
|
if os.path.exists(config_path):
|
|
|
|
with open(config_path, 'r') as f:
|
|
|
|
config_data = f.read()
|
|
|
|
|
|
|
|
btcpay_handler = BTCPayHandler()
|
|
|
|
email_sent = btcpay_handler.send_confirmation_email(email, config_data)
|
|
|
|
if email_sent:
|
|
|
|
logger.info(f"Sent configuration email to {email}")
|
|
|
|
else:
|
|
|
|
logger.warning(f"Failed to send configuration email to {email}")
|
|
|
|
else:
|
|
|
|
logger.warning(f"Config file not found at {config_path}")
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error sending confirmation email: {str(e)}")
|
|
|
|
|
|
|
|
logger.info(f"Successfully processed invoice {invoice_id}")
|
2024-12-11 08:27:42 +00:00
|
|
|
return jsonify({
|
2024-12-13 09:57:12 +00:00
|
|
|
"status": "success",
|
|
|
|
"invoice_id": invoice_id,
|
|
|
|
"message": "VPN provisioning completed"
|
|
|
|
})
|
|
|
|
|
|
|
|
else:
|
|
|
|
# Log other webhook types as info instead of warning
|
|
|
|
logger.info(f"Received {webhook_type} webhook - no action required")
|
|
|
|
return jsonify({
|
|
|
|
"status": "success",
|
|
|
|
"message": f"Webhook {webhook_type} acknowledged"
|
|
|
|
})
|
2024-12-11 08:27:42 +00:00
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
logger.error(f"Error processing webhook: {str(e)}")
|
2024-12-13 09:57:12 +00:00
|
|
|
logger.error(traceback.format_exc())
|
2024-12-11 08:27:42 +00:00
|
|
|
return jsonify({
|
|
|
|
"error": str(e),
|
|
|
|
"traceback": traceback.format_exc()
|
2024-12-11 09:05:26 +00:00
|
|
|
}), 500
|