vpn-btcpay-provisioner/app/handlers/webhook_handler.py

299 lines
11 KiB
Python
Raw Normal View History

2024-12-11 09:05:26 +00:00
import tempfile
from flask import jsonify
import subprocess
import os
import logging
import hmac
import hashlib
import yaml
2024-12-13 09:57:12 +00:00
import json
import datetime
import traceback
2024-12-11 09:05:26 +00:00
from pathlib import Path
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'
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 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 09:05:26 +00:00
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
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"""
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-13 09:57:12 +00:00
if webhook_type == 'SubscriptionStatusUpdated':
return handle_subscription_status(data)
2024-12-13 09:57:12 +00:00
elif webhook_type == 'SubscriptionRenewalRequested':
return handle_subscription_renewal(data)
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-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-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}")
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"
})
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())
return jsonify({
"error": str(e),
"traceback": traceback.format_exc()
2024-12-11 09:05:26 +00:00
}), 500