vpn-btcpay-provisioner/app/handlers/webhook_handler.py
2024-12-30 06:03:07 +00:00

253 lines
8.9 KiB
Python

import tempfile
from flask import jsonify
import subprocess
import os
import logging
import hmac
import hashlib
import yaml
import datetime
import traceback
from pathlib import Path
from dotenv import load_dotenv
from ..utils.db.operations import DatabaseManager
from ..utils.db.models import SubscriptionStatus
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'
CLEANUP_PLAYBOOK = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_cleanup.yml'
def get_vault_values():
"""Get decrypted values from Ansible vault"""
try:
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}
)
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']}"
)
return vault_contents
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
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}")
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 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)
if result.returncode != 0:
logger.error(f"Failed to clean up subscription {sub_id}: {result.stderr}")
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}"
})
def handle_subscription_renewal(data):
"""Handle SubscriptionRenewalRequested webhook"""
sub_id = data['subscriptionId']
logger.info(f"Processing subscription renewal request: {sub_id}")
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
# 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"""
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}")
# Handle test webhooks
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"
})
webhook_type = data.get('type')
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')
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)
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
# 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"
})
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