# 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