flask update

This commit is contained in:
Enki 2024-12-13 09:57:12 +00:00
parent 274f2413cb
commit 1347ca8ea5
14 changed files with 990 additions and 63 deletions

View File

@ -1,16 +1,7 @@
# Ansible Configuration # Ansible Configuration
ANSIBLE_VAULT_PASSWORD=your_vault_password_here ANSIBLE_VAULT_PASSWORD=your_vault_password_here
# BTCPay Server Configuration
BTCPAY_BASE_URL=https://your-btcpay-server.com
BTCPAY_STORE_ID=your_store_id
BTCPAY_WEBHOOK_SECRET=your_webhook_secret
# Flask Configuration # Flask Configuration
FLASK_ENV=development FLASK_ENV=development
FLASK_APP=app/handlers/webhook_handler.py FLASK_APP=app/handlers/webhook_handler.py
FLASK_DEBUG=1 FLASK_DEBUG=1
# Server Configuration
VPN_SERVER_IP=your_server_ip
WIREGUARD_PORT=51820

View File

@ -0,0 +1,30 @@
---
- name: Cleanup expired VPN configuration
hosts: vpn_servers
become: yes
vars:
client_dir: /etc/wireguard/clients
wg_interface: wg0
tasks:
- name: Debug subscription ID
debug:
msg: "Cleaning up subscription ID: {{ subscription_id }}"
- name: Remove client configuration directory
file:
path: "{{ client_dir }}/{{ subscription_id }}"
state: absent
- name: Remove client from server config
blockinfile:
path: "/etc/wireguard/{{ wg_interface }}.conf"
marker: "# {mark} ANSIBLE MANAGED BLOCK FOR {{ subscription_id }}"
state: absent
notify: restart wireguard
handlers:
- name: restart wireguard
service:
name: "wg-quick@{{ wg_interface }}"
state: restarted

View File

@ -1,6 +1,9 @@
from flask import Flask, request, jsonify # app/__init__.py
from flask import Flask, request, jsonify, render_template
import logging import logging
from .handlers.webhook_handler import handle_payment_webhook from .handlers.webhook_handler import handle_payment_webhook
from .handlers.payment_handler import BTCPayHandler
# Set up logging # Set up logging
logging.basicConfig( logging.basicConfig(
@ -10,10 +13,92 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
btcpay_handler = BTCPayHandler()
# Existing webhook route
@app.route('/webhook/vpn', methods=['POST']) @app.route('/webhook/vpn', methods=['POST'])
def handle_payment(): def handle_payment():
return handle_payment_webhook(request) return handle_payment_webhook(request)
# Frontend routes
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/calculate-price', methods=['POST'])
def calculate_price():
hours = request.json.get('hours', 0)
# Basic price calculation - adjust formula as needed
base_price = hours * 100 # 100 sats per hour
# Apply volume discounts
if hours >= 720: # 1 month
base_price = base_price * 0.85 # 15% discount
elif hours >= 168: # 1 week
base_price = base_price * 0.90 # 10% discount
elif hours >= 24: # 1 day
base_price = base_price * 0.95 # 5% discount
return jsonify({
'price': int(base_price),
'duration': hours
})
@app.route('/create-invoice', methods=['POST'])
def create_invoice():
try:
logger.info("Received invoice creation request")
data = request.json
logger.debug(f"Request data: {data}")
# Validate input data
duration_hours = data.get('duration')
email = data.get('email')
if not email:
logger.error("Email address missing from request")
return jsonify({'error': 'Email is required'}), 400
if not duration_hours:
logger.error("Duration missing from request")
return jsonify({'error': 'Duration is required'}), 400
try:
duration_hours = int(duration_hours)
except ValueError:
logger.error(f"Invalid duration value: {duration_hours}")
return jsonify({'error': 'Invalid duration value'}), 400
# Calculate price using same logic as calculate-price endpoint
base_price = duration_hours * 100 # 100 sats per hour
if duration_hours >= 720: # 1 month
base_price = base_price * 0.85 # 15% discount
elif duration_hours >= 168: # 1 week
base_price = base_price * 0.90 # 10% discount
elif duration_hours >= 24: # 1 day
base_price = base_price * 0.95 # 5% discount
amount_sats = int(base_price)
logger.info(f"Calculated price: {amount_sats} sats for {duration_hours} hours")
# Create BTCPay invoice
invoice_data = btcpay_handler.create_invoice(amount_sats, duration_hours, email)
if not invoice_data:
logger.error("Failed to create invoice - no data returned from BTCPayHandler")
return jsonify({'error': 'Failed to create invoice'}), 500
logger.info(f"Successfully created invoice with ID: {invoice_data.get('invoice_id')}")
return jsonify(invoice_data)
except Exception as e:
logger.error(f"Error in create_invoice endpoint: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/payment/success')
def payment_success():
return render_template('payment_success.html')
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000) app.run(host='0.0.0.0', port=5000)

View File

@ -0,0 +1,144 @@
# app/handlers/payment_handler.py
import logging
import requests
import os
from flask import jsonify
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from pathlib import Path
import traceback
from .webhook_handler import get_vault_values
logger = logging.getLogger(__name__)
class BTCPayHandler:
def __init__(self):
logger.info("Initializing BTCPayHandler")
try:
# Get configuration from Ansible vault
logger.debug("Retrieving vault values")
vault_values = get_vault_values()
self.base_url = vault_values['btcpay_base_url']
self.api_key = vault_values['btcpay_api_key']
self.store_id = vault_values['btcpay_store_id']
# Email configuration
self.smtp_config = vault_values.get('smtp_config', {})
logger.info(f"BTCPayHandler initialized with base URL: {self.base_url}")
except Exception as e:
logger.error(f"Failed to initialize BTCPayHandler: {str(e)}")
logger.error(traceback.format_exc())
raise
def create_invoice(self, amount_sats, duration_hours, email):
"""Create BTCPay invoice for VPN subscription"""
try:
logger.info(f"Creating invoice: {amount_sats} sats, {duration_hours}h for {email}")
headers = {
'Authorization': f'token {self.api_key}',
'Content-Type': 'application/json'
}
# Get app URL from environment or use a default
app_url = os.getenv('APP_BASE_URL', 'http://localhost:5000').rstrip('/')
logger.debug(f"Using app URL for redirect: {app_url}")
payload = {
'amount': amount_sats,
'currency': 'SATS',
'metadata': {
'duration_hours': duration_hours,
'email': email,
'orderId': f'vpn_sub_{duration_hours}h',
},
'checkout': {
'redirectURL': f'{app_url}/payment/success',
'redirectAutomatically': True
}
}
logger.debug(f"Sending request to BTCPay Server: {self.base_url}/api/v1/stores/{self.store_id}/invoices")
logger.debug(f"Request payload: {payload}")
response = requests.post(
f'{self.base_url}/api/v1/stores/{self.store_id}/invoices',
headers=headers,
json=payload
)
logger.debug(f"BTCPay response status: {response.status_code}")
logger.debug(f"BTCPay response content: {response.text}")
if response.status_code != 200:
logger.error(f"BTCPay invoice creation failed: {response.text}")
return None
invoice_data = response.json()
logger.info(f"Successfully created invoice {invoice_data.get('id')}")
return {
'invoice_id': invoice_data['id'],
'checkout_url': invoice_data['checkoutLink']
}
except Exception as e:
logger.error("Error creating BTCPay invoice:")
logger.error(traceback.format_exc())
return None
def send_confirmation_email(self, email, config_data):
"""Send VPN configuration details via email"""
try:
logger.info(f"Sending confirmation email to {email}")
if not self.smtp_config:
logger.warning("SMTP configuration not found in vault")
return False
msg = MIMEMultipart()
msg['From'] = self.smtp_config['sender_email']
msg['To'] = email
msg['Subject'] = "Your VPN Configuration"
body = f"""
Thank you for subscribing to our VPN service!
Please find your WireGuard configuration below:
{config_data}
Installation instructions:
1. Install WireGuard client for your platform from https://www.wireguard.com/install/
2. Save the above configuration to a file named 'wg0.conf'
3. Import the configuration file into your WireGuard client
Need help? Reply to this email for support.
"""
msg.attach(MIMEText(body, 'plain'))
logger.debug("Connecting to SMTP server")
with smtplib.SMTP(
self.smtp_config['server'],
self.smtp_config.get('port', 587)
) as server:
server.starttls()
server.login(
self.smtp_config['username'],
self.smtp_config['password']
)
server.send_message(msg)
logger.info("Confirmation email sent successfully")
return True
except Exception as e:
logger.error("Error sending confirmation email:")
logger.error(traceback.format_exc())
return False

View File

@ -6,6 +6,8 @@ import logging
import hmac import hmac
import hashlib import hashlib
import yaml import yaml
import json
import datetime
import traceback import traceback
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
@ -20,6 +22,8 @@ logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
PLAYBOOK_PATH = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_provision.yml' PLAYBOOK_PATH = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_provision.yml'
CLEANUP_PLAYBOOK = BASE_DIR / 'ansible' / 'playbooks' / 'vpn_cleanup.yml'
SUBSCRIPTION_DB = BASE_DIR / 'data' / 'subscriptions.json'
def get_vault_values(): def get_vault_values():
"""Get decrypted values from Ansible vault""" """Get decrypted values from Ansible vault"""
@ -76,34 +80,33 @@ def verify_signature(payload_body, signature_header):
logger.error(f"Signature verification failed: {str(e)}") logger.error(f"Signature verification failed: {str(e)}")
return False return False
def handle_payment_webhook(request): def load_subscriptions():
"""Handle BTCPay Server webhook for VPN provisioning""" """Load subscription data from JSON file"""
try: if not SUBSCRIPTION_DB.parent.exists():
vault_values = get_vault_values() SUBSCRIPTION_DB.parent.mkdir(parents=True)
logger.info(f"Processing webhook on endpoint: {vault_values['webhook_full_url']}")
signature = request.headers.get('BTCPay-Sig') if not SUBSCRIPTION_DB.exists():
if not signature: return {}
logger.error("Missing BTCPay-Sig header")
return jsonify({"error": "Missing signature"}), 401
is_valid = verify_signature(request.get_data(), signature) with open(SUBSCRIPTION_DB, 'r') as f:
if not is_valid: return json.load(f)
logger.error("Invalid signature")
return jsonify({"error": "Invalid signature"}), 401
data = request.json def save_subscription(subscription_data):
logger.info(f"Received webhook data: {data}") """Save subscription data to JSON file"""
subscriptions = load_subscriptions()
sub_id = subscription_data['subscriptionId']
subscriptions[sub_id] = subscription_data
invoice_id = data.get('invoiceId') with open(SUBSCRIPTION_DB, 'w') as f:
if not invoice_id: json.dump(subscriptions, f, indent=2)
logger.error("Missing invoiceId in webhook data")
return jsonify({"error": "Missing invoiceId"}), 400
if invoice_id.startswith('__test__') and invoice_id.endswith('__test__'): def calculate_expiry(duration_hours):
invoice_id = invoice_id[8:-8] """Calculate expiry date based on subscription duration"""
logger.info(f"Stripped test markers from invoice ID: {invoice_id}") 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', '') vault_pass = os.getenv('ANSIBLE_VAULT_PASSWORD', '')
if not vault_pass: if not vault_pass:
raise Exception("Vault password not found in environment variables") raise Exception("Vault password not found in environment variables")
@ -130,14 +133,102 @@ def handle_payment_webhook(request):
) )
os.unlink(vault_pass_file.name) os.unlink(vault_pass_file.name)
return result
logger.info(f"Ansible playbook stdout: {result.stdout}") def handle_subscription_status(data):
if result.stderr: """Handle SubscriptionStatusUpdated webhook"""
logger.error(f"Ansible playbook stderr: {result.stderr}") 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"
})
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}")
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 == 'InvoiceSettled':
# Handle regular invoice payment
invoice_id = data.get('invoiceId')
metadata = data.get('metadata', {})
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}")
# 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: if result.returncode != 0:
error_msg = f"Ansible playbook failed with return code {result.returncode}" error_msg = f"Ansible playbook failed with return code {result.returncode}"
logger.error(error_msg) logger.error(error_msg)
logger.error(f"Ansible stdout: {result.stdout}")
logger.error(f"Ansible stderr: {result.stderr}")
return jsonify({ return jsonify({
"error": "Provisioning failed", "error": "Provisioning failed",
"details": error_msg, "details": error_msg,
@ -145,15 +236,63 @@ def handle_payment_webhook(request):
"stderr": result.stderr "stderr": result.stderr
}), 500 }), 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}") logger.info(f"Successfully processed invoice {invoice_id}")
return jsonify({ return jsonify({
"status": "success", "status": "success",
"invoice_id": invoice_id, "invoice_id": invoice_id,
"message": "VPN provisioning initiated" "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: except Exception as e:
logger.error(f"Error processing webhook: {str(e)}") logger.error(f"Error processing webhook: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({ return jsonify({
"error": str(e), "error": str(e),
"traceback": traceback.format_exc() "traceback": traceback.format_exc()

84
app/static/js/pricing.js Normal file
View File

@ -0,0 +1,84 @@
// app/static/js/pricing.js
document.addEventListener('DOMContentLoaded', 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 emailInput = document.getElementById('email');
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 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);
}
}
async function createInvoice(duration, email) {
try {
const response = await fetch('/create-invoice', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
duration: parseInt(duration),
email: email
})
});
if (!response.ok) {
throw new Error('Failed to create invoice');
}
const data = await response.json();
window.location.href = data.checkout_url;
} catch (error) {
console.error('Error creating invoice:', error);
alert('Failed to create payment invoice. Please try again.');
}
}
// Event listeners
slider.addEventListener('input', () => updatePrice(slider.value));
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();
const email = emailInput.value.trim();
if (!email) {
alert('Please enter your email address');
return;
}
const duration = slider.value;
await createInvoice(duration, email);
});
// Initial price calculation
updatePrice(slider.value);
});

25
app/templates/base.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VPN Service</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'dark': '#121212',
'dark-lighter': '#1e1e1e'
}
}
}
}
</script>
<script src="{{ url_for('static', filename='js/pricing.js') }}" defer></script>
</head>
<body class="bg-dark text-gray-100">
{% block content %}{% endblock %}
</body>
</html>

56
app/templates/index.html Normal file
View File

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block content %}
<div class="min-h-screen bg-dark py-8 px-4">
<div class="max-w-xl mx-auto bg-dark-lighter rounded-lg shadow-lg p-6">
<h1 class="text-2xl font-bold mb-6 text-center">Subscribe to VPN Service</h1>
<form id="subscription-form" class="space-y-6">
<div>
<label for="email" class="block text-sm font-medium mb-2">Email Address</label>
<input
type="email"
id="email"
required
class="w-full px-3 py-2 bg-dark border border-gray-600 rounded-md text-white focus:outline-none focus:border-blue-500"
placeholder="your@email.com"
>
</div>
<div>
<label class="block text-sm font-medium mb-2">Duration</label>
<div class="space-y-4">
<input
type="range"
id="duration-slider"
min="1"
max="720"
value="24"
class="w-full"
>
<div class="flex justify-between text-sm text-gray-400">
<button type="button" data-hours="1" class="duration-preset hover:text-blue-400">1 Hour</button>
<button type="button" data-hours="24" class="duration-preset hover:text-blue-400">1 Day</button>
<button type="button" data-hours="168" class="duration-preset hover:text-blue-400">1 Week</button>
<button type="button" data-hours="720" class="duration-preset hover:text-blue-400">1 Month</button>
</div>
</div>
<p id="duration-display" class="mt-2 text-center text-gray-400"></p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-blue-400">
<span id="price-display">-</span> sats
</p>
</div>
<button
type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition-colors"
>
Pay with Bitcoin
</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<div class="min-h-screen bg-dark py-8 px-4">
<div class="max-w-xl mx-auto bg-dark-lighter rounded-lg shadow-lg p-6 text-center">
<h1 class="text-2xl font-bold mb-4">Payment Successful!</h1>
<p class="text-gray-300 mb-6">
Thank you for your payment. Your VPN configuration will be sent to your email shortly.
</p>
<p class="text-gray-400 mb-4">
Please check your email for further instructions on setting up your VPN connection.
</p>
<a href="/" class="inline-block bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition-colors">
Return to Home
</a>
</div>
</div>
{% endblock %}

92
data/subscriptions.json Normal file
View File

@ -0,0 +1,92 @@
{
"__test__ee2b820c-d1a0-4c4d-8b7c-0e5550fbb42e__test__": {
"deliveryId": "P4su7aNvuaa7mGEJLFJ5mk",
"webhookId": "CoGqJKKuE3838AWZQkncSJ",
"originalDeliveryId": "__test__73469547-03db-4961-9d2f-da9b1ed7519f__test__",
"isRedelivery": false,
"type": "SubscriptionRenewalRequested",
"timestamp": 1733961630,
"storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY",
"appId": "__test__dda0ab87-3962-435e-9cf6-2c9749dd21dc__test__",
"subscriptionId": "__test__ee2b820c-d1a0-4c4d-8b7c-0e5550fbb42e__test__",
"status": "Active",
"paymentRequestId": null,
"email": null,
"renewal_requested": "2024-12-12T00:00:31.600171"
},
"__test__f3e4e7cf-304b-4a97-a341-9e78e0f0bf7f__test__": {
"deliveryId": "7BPjwRNtFd2k3kzQLH8RoJ",
"webhookId": "CoGqJKKuE3838AWZQkncSJ",
"originalDeliveryId": "__test__50e7d2ea-18d3-46b0-bad3-fc5475a9994f__test__",
"isRedelivery": false,
"type": "SubscriptionRenewalRequested",
"timestamp": 1733972813,
"storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY",
"appId": "__test__aa25dc16-84ac-4687-a2e9-41a97c5cc112__test__",
"subscriptionId": "__test__f3e4e7cf-304b-4a97-a341-9e78e0f0bf7f__test__",
"status": "Active",
"paymentRequestId": null,
"email": null,
"renewal_requested": "2024-12-12T03:06:54.340514"
},
"__test__0caaab4d-026d-4df6-b09b-164fa0edde79__test__": {
"deliveryId": "PhRMLk717pAWj2L1Z3LZtr",
"webhookId": "CoGqJKKuE3838AWZQkncSJ",
"originalDeliveryId": "__test__36b2350d-45df-4fe0-a4fa-c701684c9209__test__",
"isRedelivery": false,
"type": "SubscriptionStatusUpdated",
"timestamp": 1733972824,
"storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY",
"appId": "__test__7dc7f4d8-020a-41b6-b728-84ca1ca0ab4c__test__",
"subscriptionId": "__test__0caaab4d-026d-4df6-b09b-164fa0edde79__test__",
"status": "Active",
"paymentRequestId": null,
"email": null,
"last_updated": "2024-12-12T03:07:05.439848"
},
"__test__9aa786d3-053c-4b70-8b01-6df0f0207b79__test__": {
"deliveryId": "U7stmUDcB4qseDh29mhke7",
"webhookId": "CoGqJKKuE3838AWZQkncSJ",
"originalDeliveryId": "__test__c5eba94d-ea9e-46e9-b127-74234fcd7002__test__",
"isRedelivery": false,
"type": "SubscriptionStatusUpdated",
"timestamp": 1733973182,
"storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY",
"appId": "__test__c3149b37-ad19-428a-aa95-251adc96e5ae__test__",
"subscriptionId": "__test__9aa786d3-053c-4b70-8b01-6df0f0207b79__test__",
"status": "Active",
"paymentRequestId": null,
"email": null,
"last_updated": "2024-12-12T03:13:03.056437"
},
"__test__d57b8102-87f7-4143-b175-32353b6eaec7__test__": {
"deliveryId": "834dSuc5bdeXF2zRxovj2x",
"webhookId": "CoGqJKKuE3838AWZQkncSJ",
"originalDeliveryId": "__test__16c072e3-5702-4f57-bc32-7181f585bef2__test__",
"isRedelivery": false,
"type": "SubscriptionStatusUpdated",
"timestamp": 1733973198,
"storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY",
"appId": "__test__59579b1c-c393-4fa4-b4be-6691b7a6db27__test__",
"subscriptionId": "__test__d57b8102-87f7-4143-b175-32353b6eaec7__test__",
"status": "Active",
"paymentRequestId": null,
"email": null,
"last_updated": "2024-12-12T03:13:19.677073"
},
"__test__5f28edc1-1c9e-4f1a-9fe9-591bc4ae7988__test__": {
"deliveryId": "9LUJAQmU89k8ofSKc7M7Kt",
"webhookId": "CoGqJKKuE3838AWZQkncSJ",
"originalDeliveryId": "__test__c274b638-568b-459f-b4f4-fd10029f5dce__test__",
"isRedelivery": false,
"type": "SubscriptionRenewalRequested",
"timestamp": 1733973203,
"storeId": "DcnEUCckb8eo5WBFBABb7EXGRP49a8UjYQRKkvz7AcJY",
"appId": "__test__6896db98-91f3-458e-b8c8-6c6280a5698f__test__",
"subscriptionId": "__test__5f28edc1-1c9e-4f1a-9fe9-591bc4ae7988__test__",
"status": "Active",
"paymentRequestId": null,
"email": null,
"renewal_requested": "2024-12-12T03:13:24.040298"
}
}

View File

@ -1,5 +1,6 @@
flask==3.0.0 flask==3.0.0
pyyaml==6.0.1 pyyaml==6.0.1
python-dotenv==1.0.0 python-dotenv==1.0.0
cryptography==41.0.7 # For ansible-vault operations cryptography==41.0.7
ansible==9.1.0 ansible==9.1.0
requests==2.31.0

View File

@ -0,0 +1,253 @@
# scripts/subscription_checker.py
import json
import datetime
import logging
import subprocess
import os
import tempfile
from pathlib import Path
from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Path setup
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
SUBSCRIPTION_DB = PROJECT_ROOT / 'data' / 'subscriptions.json'
CLEANUP_PLAYBOOK = PROJECT_ROOT / 'ansible' / 'playbooks' / 'vpn_cleanup.yml'
INVENTORY_FILE = PROJECT_ROOT / 'inventory.ini'
# Notification thresholds configuration
NOTIFICATION_THRESHOLDS = {
'minimum_duration': datetime.timedelta(hours=1), # Minimum subscription duration
'short_term': {
'max_duration': datetime.timedelta(days=1),
'warning_fraction': 0.5, # Warn when 50% of time remains
'grace_fraction': 0.1 # Grace period of 10% of subscription length
},
'medium_term': {
'max_duration': datetime.timedelta(days=7),
'warning_fraction': 0.25, # Warn when 25% of time remains
'grace_hours': 12 # Fixed 12-hour grace period
},
'long_term': {
'warning_days': 7, # 7 days warning for longer subscriptions
'grace_days': 2 # 2 days grace period
}
}
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_subscriptions(subscriptions):
"""Save subscriptions to JSON file"""
with open(SUBSCRIPTION_DB, 'w') as f:
json.dump(subscriptions, f, indent=2)
def run_cleanup_playbook(sub_id):
"""Run the VPN cleanup playbook"""
logger.info(f"Running cleanup playbook for subscription {sub_id}")
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(CLEANUP_PLAYBOOK),
'-i', str(INVENTORY_FILE),
'-e', f'subscription_id={sub_id}',
'--vault-password-file', vault_pass_file.name,
'-vvv'
]
logger.debug(f"Running command: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True
)
if result.returncode != 0:
logger.error(f"Cleanup playbook failed: {result.stderr}")
else:
logger.debug(f"Cleanup playbook output: {result.stdout}")
os.unlink(vault_pass_file.name)
return result
def calculate_notification_times(start_time, end_time):
"""
Calculate warning and grace period times based on subscription duration
Returns:
tuple: (warning_time, grace_end_time)
"""
duration = end_time - start_time
# Handle extremely short subscriptions
if duration < NOTIFICATION_THRESHOLDS['minimum_duration']:
warning_time = start_time # Immediate warning
grace_end_time = end_time # No grace period
return warning_time, grace_end_time
# Short-term subscriptions (less than 1 day)
if duration <= NOTIFICATION_THRESHOLDS['short_term']['max_duration']:
warning_delta = duration * NOTIFICATION_THRESHOLDS['short_term']['warning_fraction']
grace_delta = duration * NOTIFICATION_THRESHOLDS['short_term']['grace_fraction']
warning_time = end_time - warning_delta
grace_end_time = end_time + grace_delta
# Medium-term subscriptions (1-7 days)
elif duration <= NOTIFICATION_THRESHOLDS['medium_term']['max_duration']:
warning_delta = duration * NOTIFICATION_THRESHOLDS['medium_term']['warning_fraction']
grace_hours = NOTIFICATION_THRESHOLDS['medium_term']['grace_hours']
warning_time = end_time - warning_delta
grace_end_time = end_time + datetime.timedelta(hours=grace_hours)
# Long-term subscriptions (> 7 days)
else:
warning_days = NOTIFICATION_THRESHOLDS['long_term']['warning_days']
grace_days = NOTIFICATION_THRESHOLDS['long_term']['grace_days']
warning_time = end_time - datetime.timedelta(days=warning_days)
grace_end_time = end_time + datetime.timedelta(days=grace_days)
return warning_time, grace_end_time
def get_notification_message(sub_data, remaining_time):
"""Generate appropriate notification message based on subscription duration"""
if remaining_time < datetime.timedelta(hours=1):
return f"Your VPN subscription expires in {int(remaining_time.total_seconds() / 60)} minutes!"
elif remaining_time < datetime.timedelta(days=1):
return f"Your VPN subscription expires in {int(remaining_time.total_seconds() / 3600)} hours!"
else:
return f"Your VPN subscription expires in {remaining_time.days} days!"
def notify_user(user_id, message):
"""Send notification to user about subscription status"""
try:
subscriptions = load_subscriptions()
sub_data = subscriptions.get(user_id)
if not sub_data or 'email' not in sub_data:
logger.error(f"No email found for user {user_id}")
return False
# Import BTCPayHandler here to avoid circular imports
from app.handlers.payment_handler import BTCPayHandler
btcpay_handler = BTCPayHandler()
email_sent = btcpay_handler.send_confirmation_email(
sub_data['email'],
f"""
VPN Subscription Update
{message}
If you wish to continue using the VPN service, please renew your subscription.
"""
)
if email_sent:
logger.info(f"Sent notification to {sub_data['email']}: {message}")
else:
logger.warning(f"Failed to send notification to {sub_data['email']}")
return email_sent
except Exception as e:
logger.error(f"Error sending notification: {str(e)}")
return False
def check_subscriptions():
"""Check subscription status and clean up expired ones"""
logger.info("Starting subscription check")
subscriptions = load_subscriptions()
now = datetime.datetime.now()
modified = False
logger.info(f"Checking {len(subscriptions)} subscriptions")
for sub_id, sub_data in list(subscriptions.items()):
try:
logger.debug(f"Processing subscription {sub_id}")
if sub_data.get('status') != 'Active':
logger.debug(f"Skipping inactive subscription {sub_id}")
continue
start_time = parse(sub_data['start_time'])
expiry = parse(sub_data['expiry'])
warning_time, grace_end_time = calculate_notification_times(start_time, expiry)
# Calculate remaining time
remaining_time = expiry - now
logger.debug(f"Subscription {sub_id} has {remaining_time} remaining")
# Handle warnings
if now >= warning_time and not sub_data.get('warning_sent'):
message = get_notification_message(sub_data, remaining_time)
logger.info(f"Sending notification for subscription {sub_id}: {message}")
notify_user(sub_id, message)
sub_data['warning_sent'] = True
modified = True
# Handle expiration
if now >= grace_end_time and sub_data.get('status') == 'Active':
logger.info(f"Processing expiration for subscription {sub_id}")
try:
logger.debug(f"Running cleanup playbook for {sub_id}")
result = run_cleanup_playbook(sub_id)
if result.returncode == 0:
sub_data['status'] = 'Expired'
sub_data['cleanup_date'] = now.isoformat()
modified = True
logger.info(f"Successfully cleaned up subscription {sub_id}")
else:
logger.error(f"Cleanup failed: {result.stderr}")
except Exception as e:
logger.error(f"Error during cleanup: {str(e)}")
logger.error(traceback.format_exc())
except Exception as e:
logger.error(f"Error processing subscription {sub_id}: {str(e)}")
logger.error(traceback.format_exc())
continue
if modified:
save_subscriptions(subscriptions)
logger.info("Updated subscription database")
logger.info("Subscription check completed")
if __name__ == '__main__':
try:
check_subscriptions()
except Exception as e:
logger.error(f"Subscription checker failed: {str(e)}")
raise

1
vault_pass.txt Normal file
View File

@ -0,0 +1 @@
Q1w2e3r4t5

8
venv/bin/normalizer Executable file
View File

@ -0,0 +1,8 @@
#!/home/hashgate/vpn-btcpay-provisioner/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from charset_normalizer.cli import cli_detect
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli_detect())