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_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_ENV=development
FLASK_APP=app/handlers/webhook_handler.py
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
from .handlers.webhook_handler import handle_payment_webhook
from .handlers.payment_handler import BTCPayHandler
# Set up logging
logging.basicConfig(
@ -10,10 +13,92 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
app = Flask(__name__)
btcpay_handler = BTCPayHandler()
# Existing webhook route
@app.route('/webhook/vpn', methods=['POST'])
def handle_payment():
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__':
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 hashlib
import yaml
import json
import datetime
import traceback
from pathlib import Path
from dotenv import load_dotenv
@ -20,6 +22,8 @@ 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'
SUBSCRIPTION_DB = BASE_DIR / 'data' / 'subscriptions.json'
def get_vault_values():
"""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)}")
return False
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']}")
def load_subscriptions():
"""Load subscription data from JSON file"""
if not SUBSCRIPTION_DB.parent.exists():
SUBSCRIPTION_DB.parent.mkdir(parents=True)
signature = request.headers.get('BTCPay-Sig')
if not signature:
logger.error("Missing BTCPay-Sig header")
return jsonify({"error": "Missing signature"}), 401
if not SUBSCRIPTION_DB.exists():
return {}
is_valid = verify_signature(request.get_data(), signature)
if not is_valid:
logger.error("Invalid signature")
return jsonify({"error": "Invalid signature"}), 401
with open(SUBSCRIPTION_DB, 'r') as f:
return json.load(f)
data = request.json
logger.info(f"Received webhook data: {data}")
def save_subscription(subscription_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')
if not invoice_id:
logger.error("Missing invoiceId in webhook data")
return jsonify({"error": "Missing invoiceId"}), 400
with open(SUBSCRIPTION_DB, 'w') as f:
json.dump(subscriptions, f, indent=2)
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}")
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")
@ -130,14 +133,102 @@ def handle_payment_webhook(request):
)
os.unlink(vault_pass_file.name)
return result
logger.info(f"Ansible playbook stdout: {result.stdout}")
if result.stderr:
logger.error(f"Ansible playbook stderr: {result.stderr}")
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"
})
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:
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,
@ -145,15 +236,63 @@ def handle_payment_webhook(request):
"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({
"status": "success",
"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:
logger.error(f"Error processing webhook: {str(e)}")
logger.error(traceback.format_exc())
return jsonify({
"error": str(e),
"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
pyyaml==6.0.1
python-dotenv==1.0.0
cryptography==41.0.7 # For ansible-vault operations
cryptography==41.0.7
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())