flask update
This commit is contained in:
parent
274f2413cb
commit
1347ca8ea5
11
.env.example
11
.env.example
@ -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
|
|
30
ansible/playbooks/vpn_cleanup.yml
Normal file
30
ansible/playbooks/vpn_cleanup.yml
Normal 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
|
@ -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)
|
144
app/handlers/payment_handler.py
Normal file
144
app/handlers/payment_handler.py
Normal 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
|
@ -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,6 +80,107 @@ 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 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"
|
||||||
|
})
|
||||||
|
|
||||||
def handle_payment_webhook(request):
|
def handle_payment_webhook(request):
|
||||||
"""Handle BTCPay Server webhook for VPN provisioning"""
|
"""Handle BTCPay Server webhook for VPN provisioning"""
|
||||||
try:
|
try:
|
||||||
@ -94,66 +199,100 @@ def handle_payment_webhook(request):
|
|||||||
|
|
||||||
data = request.json
|
data = request.json
|
||||||
logger.info(f"Received webhook data: {data}")
|
logger.info(f"Received webhook data: {data}")
|
||||||
|
webhook_type = data.get('type')
|
||||||
|
|
||||||
invoice_id = data.get('invoiceId')
|
if webhook_type == 'SubscriptionStatusUpdated':
|
||||||
if not invoice_id:
|
return handle_subscription_status(data)
|
||||||
logger.error("Missing invoiceId in webhook data")
|
|
||||||
return jsonify({"error": "Missing invoiceId"}), 400
|
|
||||||
|
|
||||||
if invoice_id.startswith('__test__') and invoice_id.endswith('__test__'):
|
elif webhook_type == 'SubscriptionRenewalRequested':
|
||||||
invoice_id = invoice_id[8:-8]
|
return handle_subscription_renewal(data)
|
||||||
logger.info(f"Stripped test markers from invoice ID: {invoice_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 = [
|
elif webhook_type == 'InvoiceSettled':
|
||||||
'ansible-playbook',
|
# Handle regular invoice payment
|
||||||
str(PLAYBOOK_PATH),
|
invoice_id = data.get('invoiceId')
|
||||||
'-i', str(BASE_DIR / 'inventory.ini'),
|
metadata = data.get('metadata', {})
|
||||||
'-e', f'invoice_id={invoice_id}',
|
|
||||||
'--vault-password-file', vault_pass_file.name,
|
|
||||||
'-vvv'
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info(f"Running ansible-playbook command: {' '.join(cmd)}")
|
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}")
|
||||||
|
|
||||||
result = subprocess.run(
|
# Run Ansible playbook with enhanced logging
|
||||||
cmd,
|
logger.info(f"Starting VPN provisioning for invoice {invoice_id}")
|
||||||
capture_output=True,
|
result = run_ansible_playbook(invoice_id)
|
||||||
text=True
|
|
||||||
)
|
if result.returncode != 0:
|
||||||
|
error_msg = f"Ansible playbook failed with return code {result.returncode}"
|
||||||
os.unlink(vault_pass_file.name)
|
logger.error(error_msg)
|
||||||
|
logger.error(f"Ansible stdout: {result.stdout}")
|
||||||
logger.info(f"Ansible playbook stdout: {result.stdout}")
|
logger.error(f"Ansible stderr: {result.stderr}")
|
||||||
if result.stderr:
|
return jsonify({
|
||||||
logger.error(f"Ansible playbook stderr: {result.stderr}")
|
"error": "Provisioning failed",
|
||||||
|
"details": error_msg,
|
||||||
if result.returncode != 0:
|
"stdout": result.stdout,
|
||||||
error_msg = f"Ansible playbook failed with return code {result.returncode}"
|
"stderr": result.stderr
|
||||||
logger.error(error_msg)
|
}), 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({
|
return jsonify({
|
||||||
"error": "Provisioning failed",
|
"status": "success",
|
||||||
"details": error_msg,
|
"invoice_id": invoice_id,
|
||||||
"stdout": result.stdout,
|
"message": "VPN provisioning completed"
|
||||||
"stderr": result.stderr
|
})
|
||||||
}), 500
|
|
||||||
|
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"
|
||||||
|
})
|
||||||
|
|
||||||
logger.info(f"Successfully processed invoice {invoice_id}")
|
|
||||||
return jsonify({
|
|
||||||
"status": "success",
|
|
||||||
"invoice_id": invoice_id,
|
|
||||||
"message": "VPN provisioning initiated"
|
|
||||||
})
|
|
||||||
|
|
||||||
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
84
app/static/js/pricing.js
Normal 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
25
app/templates/base.html
Normal 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
56
app/templates/index.html
Normal 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 %}
|
18
app/templates/payment_success.html
Normal file
18
app/templates/payment_success.html
Normal 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
92
data/subscriptions.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
253
scripts/subscription_checker.py
Normal file
253
scripts/subscription_checker.py
Normal 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
1
vault_pass.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
Q1w2e3r4t5
|
8
venv/bin/normalizer
Executable file
8
venv/bin/normalizer
Executable 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())
|
Loading…
Reference in New Issue
Block a user