vpn-btcpay-provisioner/scripts/subscription_checker.py

253 lines
9.3 KiB
Python
Raw Normal View History

2024-12-13 09:57:12 +00:00
# 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