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

321 lines
12 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 datetime
import traceback
2024-12-11 09:05:26 +00:00
from pathlib import Path
from dotenv import load_dotenv
2024-12-30 06:03:07 +00:00
from ..utils.db.operations import DatabaseManager
from ..utils.db.models import SubscriptionStatus
load_dotenv()
2024-12-30 07:07:53 +00:00
# Enhanced logging configuration
logging.basicConfig(
level=logging.INFO,
2024-12-30 07:07:53 +00:00
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('vpn_provisioner.log')
]
)
logger = logging.getLogger(__name__)
2024-12-30 07:07:53 +00:00
# Constants
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'
2024-12-30 07:07:53 +00:00
class WebhookError(Exception):
"""Custom exception for webhook handling errors"""
pass
def get_vault_values():
"""Get decrypted values from Ansible vault"""
try:
2024-12-30 07:07:53 +00:00
vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD')
2024-12-11 09:05:26 +00:00
if not vault_pass:
2024-12-30 07:07:53 +00:00
raise WebhookError("Vault password not found in environment variables")
2024-12-11 09:05:26 +00:00
with tempfile.NamedTemporaryFile(mode='w', delete=False) as vault_pass_file:
vault_pass_file.write(vault_pass)
vault_pass_file.flush()
2024-12-30 07:07:53 +00:00
# Execute ansible-vault with error checking
2024-12-11 09:05:26 +00:00
result = subprocess.run(
['ansible-vault', 'view', str(BASE_DIR / 'ansible/group_vars/vpn_servers/vault.yml')],
capture_output=True,
text=True,
2024-12-30 07:07:53 +00:00
env={**os.environ, 'ANSIBLE_VAULT_PASSWORD_FILE': vault_pass_file.name},
check=True # This will raise CalledProcessError if command fails
2024-12-11 09:05:26 +00:00
)
2024-12-11 09:05:26 +00:00
os.unlink(vault_pass_file.name)
2024-12-30 07:07:53 +00:00
try:
vault_contents = yaml.safe_load(result.stdout)
if not vault_contents:
raise WebhookError("Empty vault contents")
2024-12-11 09:05:26 +00:00
2024-12-30 07:07:53 +00:00
# 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'] = (
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:
2024-12-30 07:07:53 +00:00
logger.error(f"Error reading vault: {traceback.format_exc()}")
raise WebhookError(f"Vault operation failed: {str(e)}")
2024-12-30 07:07:53 +00:00
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:
2024-12-30 07:07:53 +00:00
if not signature_header:
logger.error("Missing signature header")
return False
vault_values = get_vault_values()
2024-12-30 07:07:53 +00:00
webhook_secret = vault_values.get('webhook_secret')
if not webhook_secret:
logger.error("Webhook secret not found in vault")
return False
2024-12-30 07:07:53 +00:00
# Generate expected signature
expected_signature = hmac.new(
2024-12-30 07:07:53 +00:00
webhook_secret.encode('utf-8'),
payload_body,
hashlib.sha256
).hexdigest()
2024-12-30 07:07:53 +00:00
# Constant-time comparison
return hmac.compare_digest(
signature_header.lower(),
f"sha256={expected_signature}".lower()
)
2024-12-30 07:07:53 +00:00
except Exception as e:
2024-12-30 07:07:53 +00:00
logger.error(f"Signature verification failed: {traceback.format_exc()}")
return False
2024-12-30 07:07:53 +00:00
def run_ansible_playbook(invoice_id: str, cleanup: bool = False) -> subprocess.CompletedProcess:
"""
Run the appropriate Ansible playbook with proper error handling
2024-12-13 09:57:12 +00:00
2024-12-30 07:07:53 +00:00
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:
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()
playbook = CLEANUP_PLAYBOOK if cleanup else PLAYBOOK_PATH
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)
2024-12-13 09:57:12 +00:00
2024-12-30 07:07:53 +00:00
def handle_subscription_status(data: dict) -> tuple:
2024-12-13 09:57:12 +00:00
"""Handle SubscriptionStatusUpdated webhook"""
2024-12-30 07:07:53 +00:00
try:
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}")
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
2024-12-30 06:03:07 +00:00
2024-12-30 07:07:53 +00:00
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")
return jsonify({
"status": "success",
"message": f"Subscription {sub_id} status updated to {status}"
}), 200
except Exception as e:
logger.error(f"Error handling subscription status: {traceback.format_exc()}")
return jsonify({"error": str(e)}), 500
2024-12-13 09:57:12 +00:00
2024-12-30 07:07:53 +00:00
def handle_payment_webhook(request) -> tuple:
"""
Handle BTCPay Server webhook for VPN provisioning
2024-12-13 09:57:12 +00:00
2024-12-30 07:07:53 +00:00
Returns:
tuple: (response, status_code)
"""
try:
vault_values = get_vault_values()
logger.info(f"Processing webhook on endpoint: {vault_values['webhook_full_url']}")
2024-12-30 07:07:53 +00:00
# Verify signature
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
2024-12-30 07:07:53 +00:00
# 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}")
2024-12-30 06:03:07 +00:00
# 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"
2024-12-30 07:07:53 +00:00
}), 200
2024-12-30 06:03:07 +00:00
2024-12-13 09:57:12 +00:00
webhook_type = data.get('type')
2024-12-30 07:07:53 +00:00
if not webhook_type:
return jsonify({"error": "Missing webhook type"}), 400
2024-12-30 07:07:53 +00:00
# Handle different webhook types
2024-12-13 09:57:12 +00:00
if webhook_type == 'SubscriptionStatusUpdated':
return handle_subscription_status(data)
2024-12-30 07:07:53 +00:00
elif webhook_type == 'InvoiceSettled' or webhook_type == 'InvoicePaymentSettled':
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
2024-12-30 07:07:53 +00:00
try:
# Run VPN provisioning
logger.info(f"Starting VPN provisioning for invoice {invoice_id}")
result = run_ansible_playbook(invoice_id)
# 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)}")
2024-12-13 09:57:12 +00:00
return jsonify({
"error": "Provisioning failed",
2024-12-30 07:07:53 +00:00
"details": str(e)
2024-12-13 09:57:12 +00:00
}), 500
else:
logger.info(f"Received {webhook_type} webhook - no action required")
return jsonify({
"status": "success",
"message": f"Webhook {webhook_type} acknowledged"
2024-12-30 07:07:53 +00:00
}), 200
2024-12-30 07:07:53 +00:00
except WebhookError as e:
logger.error(f"Webhook error: {str(e)}")
return jsonify({"error": str(e)}), 500
except Exception as e:
2024-12-30 07:07:53 +00:00
logger.error(f"Unexpected error: {traceback.format_exc()}")
return jsonify({"error": "Internal server error"}), 500