commit c177d7981dbc07e82c34016f096554ea4b3b5c35 Author: Wiktor A. Falkowski Date: Tue Oct 20 16:44:49 2026 +0000 Refactor backend and add registry build workflow diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7929c2e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.env +venv/ +__pycache__/ +*.db +*.pyc +.git +.gitignore \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0c57616 --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# Flask +PORT=8080 +WS_PORT=8081 +# Configure a random secret with at least 32 characters for persistent sessions. +JWT_SECRET= +ENABLE_SAMPLE_DATA=false +# Set SAMPLE_USER_PASSWORD only for isolated development fixtures. +SAMPLE_USER_PASSWORD= + +# Database (SQLite for local dev, PostgreSQL in production) +DATABASE_URL=sqlite:///anchorhost.db + +# Kubernetes (in-cluster when deployed; kubeconfig locally) +# KUBECONFIG=/path/to/kubeconfig + +# Stripe +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= + +# OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://localhost:8080/api/auth/google/callback +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= + +# DNS / domains (deSEC) +DESEC_API_TOKEN= +BASE_DOMAIN=anchorhost.eu +NODE_IP=127.0.0.1 + +# Email (SMTP) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +SMTP_FROM_NAME=AnchorHost +SMTP_USE_TLS=true +BASE_URL=http://localhost:8080 +FRONTEND_URL=http://localhost:3000 + +# Game servers +MINECRAFT_RCON_PASSWORD=change-me-in-production diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..a24dfd4 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,49 @@ +name: Build and publish Docker image + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Log in to Gitea Container Registry + env: + REGISTRY_USERNAME: ${{ gitea.actor }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + printf '%s' "$REGISTRY_TOKEN" | + docker login git.anchorhost.eu --username "$REGISTRY_USERNAME" --password-stdin + + - name: Build and tag image + env: + COMMIT_SHA: ${{ gitea.sha }} + DOCKER_IMAGE: git.anchorhost.eu/anchorhost.eu/anchorhost-backend + run: | + docker build \ + --label "org.opencontainers.image.revision=$COMMIT_SHA" \ + --tag "$DOCKER_IMAGE:backend" \ + --tag "$DOCKER_IMAGE:main-${COMMIT_SHA:0:12}" \ + . + + - name: Push image + env: + COMMIT_SHA: ${{ gitea.sha }} + DOCKER_IMAGE: git.anchorhost.eu/anchorhost.eu/anchorhost-backend + run: | + docker push "$DOCKER_IMAGE:backend" + docker push "$DOCKER_IMAGE:main-${COMMIT_SHA:0:12}" + + - name: Log out from Gitea Container Registry + if: always() + run: docker logout diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..134e3a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +venv/ +.venv/ +__pycache__/ +*.py[cod] +*.db +*.sqlite3 +.pytest_cache/ +.coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d1629b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Use official Python image +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies (needed for some python packages like psycopg2) +# libpq-dev is often needed for psycopg2 +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for caching +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application +COPY . . + +# Expose ports +# 8080 for HTTP API +# 8081 for WebSocket +EXPOSE 8080 +EXPOSE 8081 + +# Define environment variables +ENV PYTHONUNBUFFERED=1 +ENV PORT=8080 +ENV WS_PORT=8081 + +# Command to run the application +CMD ["python", "app.py"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e493b58 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Wiktor A. Falkowski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..07cc511 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +
+ +# AnchorHost Backend + +**The API and control plane behind AnchorHost.** + +[![Build and publish](https://git.anchorhost.eu/anchorhost.eu/anchorhost-backend/actions/workflows/docker.yml/badge.svg?branch=main)](https://git.anchorhost.eu/anchorhost.eu/anchorhost-backend/actions) +![Python 3.12](https://img.shields.io/badge/python-3.12-3776AB?logo=python&logoColor=white) +![Flask](https://img.shields.io/badge/API-Flask-000000?logo=flask&logoColor=white) +![License: MIT](https://img.shields.io/badge/license-MIT-2F6F5E.svg) + +
+ +--- + +AnchorHost Backend serves the platform's HTTP API and WebSocket connection for account, billing, domain, server, and support operations. It integrates with PostgreSQL, Kubernetes, Stripe, OAuth providers, SMTP, and deSEC. + +## Start locally + +Requires Python 3.12 and access to a PostgreSQL database for a production-like run. SQLite remains available for isolated local development. + +```bash +python3.12 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +``` + +Set `DATABASE_URL` and a persistent `JWT_SECRET` in `.env`, then start the API and WebSocket service together: + +```bash +python app.py +``` + +The HTTP API listens on `http://localhost:8080` and the WebSocket service listens on port `8081` by default. The health endpoint is `GET /health`. + +## Build and publish + +Pushes to `main` build the container and publish these tags to the Gitea Container Registry: + +```text +git.anchorhost.eu/anchorhost.eu/anchorhost-backend:backend +git.anchorhost.eu/anchorhost.eu/anchorhost-backend:main- +``` + +The workflow needs a repository secret named `REGISTRY_TOKEN` with package read and write access. Run it manually from **Actions โ†’ Build and publish Docker image** when needed. + +Build the image locally with: + +```bash +docker build -t anchorhost-backend:local . +``` + +## Configuration + +| Variable | Purpose | +| --- | --- | +| `DATABASE_URL` | PostgreSQL URL in production; SQLite URL or file path for local development. | +| `JWT_SECRET` | Persistent random secret of at least 32 characters. | +| `PORT`, `WS_PORT` | HTTP and WebSocket listen ports; defaults are `8080` and `8081`. | +| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | Stripe checkout and webhook integration. | +| `GOOGLE_*`, `APPLE_*` | OAuth client and callback configuration. | +| `DESEC_API_TOKEN`, `BASE_DOMAIN` | DNS and domain management. | +| `SMTP_*`, `BASE_URL`, `FRONTEND_URL` | Transactional email settings and links. | +| `KUBECONFIG` | Optional local kubeconfig path; in-cluster credentials are used in Kubernetes. | + +Copy `.env.example` as a starting point. Keep production values in deployment secrets; do not commit `.env` or credentials. + +## Project structure + +```text +app.py Application entry point +application.py Flask application factory +routes/ HTTP endpoints grouped by feature +database_service.py Schema initialization and database operations +*_service.py Feature services and integrations +server_*.py Server lifecycle, access, capacity, and billing +websocket_service.py WebSocket server and client sessions +main.py Background workers and process startup +``` + +## License + +MIT ยฉ 2026 Wiktor A. Falkowski. See [LICENSE](LICENSE). diff --git a/app.py b/app.py new file mode 100644 index 0000000..82b4165 --- /dev/null +++ b/app.py @@ -0,0 +1,10 @@ +"""AnchorHost Flask application entry point.""" + +from application import create_app + +app = create_app() + +if __name__ == "__main__": + from main import run + + run(app) diff --git a/application.py b/application.py new file mode 100644 index 0000000..4c2bf49 --- /dev/null +++ b/application.py @@ -0,0 +1,18 @@ +"""Flask application factory.""" + +from flask import Flask +from flask_cors import CORS + +from database_service import check_and_fix_database_schema, init_database +from routes import register_routes +from runtime_config import ALLOWED_ORIGINS + + +def create_app() -> Flask: + """Create the configured HTTP application.""" + init_database() + check_and_fix_database_schema() + application = Flask(__name__) + CORS(application, resources={r"/api/*": {"origins": list(ALLOWED_ORIGINS)}}) + register_routes(application) + return application diff --git a/background_tasks.py b/background_tasks.py new file mode 100644 index 0000000..729ef24 --- /dev/null +++ b/background_tasks.py @@ -0,0 +1,244 @@ +"""Background schedulers, cleanup loops, and development fixture setup.""" + + +from currency_service import fetch_exchange_rates +from database_service import database_connection, dict_from_row +from domain_runtime import domain_manager +from domain_service import cleanup_orphaned_ingressroutes +from kubernetes_service import k8s_manager +import os +from runtime_config import ( + CACHE_DURATION, + CHUNK_UPLOAD_DIR, + chunked_uploads, + logger, +) +import schedule +from server_demo import cleanup_expired_demo_servers +from server_provisioning import sync_server_ports_from_k8s, sync_server_status_from_k8s +import threading +import time +from user_service import create_user, get_user_by_username + +__all__ = ['cleanup_orphaned_uploads', 'initialize_sample_data', 'start_demo_cleanup_scheduler', 'start_exchange_rate_updater', 'start_ingress_cleanup_thread', 'start_status_sync_thread', 'start_upload_cleanup_scheduler', 'sync_all_servers_status'] + + + +def cleanup_orphaned_uploads(): + """Clean up orphaned chunk upload files and sessions older than 1 hour""" + try: + current_time = time.time() + max_age = 3600 # 1 hour + + # Clean up old upload sessions + uploads_to_remove = [] + for upload_id, upload_info in chunked_uploads.items(): + start_time = upload_info.get('start_time', current_time) + if current_time - start_time > max_age: + uploads_to_remove.append(upload_id) + + for upload_id in uploads_to_remove: + upload_info = chunked_uploads[upload_id] + temp_file = upload_info.get('temp_file') + if temp_file and os.path.exists(temp_file): + try: + os.remove(temp_file) + logger.info(f"Cleaned up orphaned upload temp file: {temp_file}") + except Exception as e: + logger.warning(f"Failed to remove orphaned temp file {temp_file}: {e}") + del chunked_uploads[upload_id] + logger.info(f"Cleaned up orphaned upload session: {upload_id}") + + # Clean up any remaining temp files older than 1 hour + try: + for filename in os.listdir(CHUNK_UPLOAD_DIR): + filepath = os.path.join(CHUNK_UPLOAD_DIR, filename) + if os.path.isfile(filepath): + file_age = current_time - os.path.getmtime(filepath) + if file_age > max_age: + try: + os.remove(filepath) + logger.info(f"Cleaned up old temp file: {filepath}") + except Exception as e: + logger.warning(f"Failed to remove old temp file {filepath}: {e}") + except Exception as e: + logger.warning(f"Error cleaning up temp files directory: {e}") + + except Exception as e: + logger.error(f"Error in cleanup_orphaned_uploads: {e}") + +def start_upload_cleanup_scheduler(): + """Start scheduler to clean up orphaned uploads""" + def run_scheduler(): + while True: + schedule.run_pending() + time.sleep(60) # Check every minute + + # Run cleanup every 30 minutes + schedule.every(30).minutes.do(cleanup_orphaned_uploads) + thread = threading.Thread(target=run_scheduler, daemon=True) + thread.start() + logger.info("๐Ÿงน Upload cleanup scheduler started") + +def start_demo_cleanup_scheduler(): + """Start scheduler to clean up expired demo servers""" + def run_scheduler(): + while True: + schedule.run_pending() + time.sleep(60) # Check every minute + + # Run cleanup every minute to ensure immediate deletion of expired demo servers + schedule.every(1).minutes.do(cleanup_expired_demo_servers) + thread = threading.Thread(target=run_scheduler, daemon=True) + thread.start() + logger.info("๐Ÿงน Demo cleanup scheduler started (runs every minute)") + +def start_exchange_rate_updater(): + """Background thread to update exchange rates periodically""" + def update_loop(): + # Initial fetch + fetch_exchange_rates() + + while True: + try: + time.sleep(CACHE_DURATION) # Wait 1 hour + fetch_exchange_rates() + except Exception as e: + logger.error(f"Error in exchange rate update loop: {e}") + time.sleep(60) # Wait 1 minute before retrying + + thread = threading.Thread(target=update_loop, daemon=True) + thread.start() + logger.info("โœ“ Started exchange rate updater thread") + +def sync_all_servers_status(): + """Sync status for all servers""" + if not k8s_manager.connected: + return + + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT id, owner_id FROM servers WHERE status IN ('Creating', 'Starting', 'Stopping', 'Restarting')") + servers = [dict_from_row(row) for row in cursor.fetchall()] + + for server in servers: + try: + sync_server_status_from_k8s(server['id'], server['owner_id']) + sync_server_ports_from_k8s(server['id'], server['owner_id']) + except Exception as e: + logger.error(f"Error syncing server {server['id']}: {e}") + + except Exception as e: + logger.error(f"Error syncing all servers: {e}") + +def start_status_sync_thread(): + """Start background status sync""" + def sync_loop(): + while True: + try: + sync_all_servers_status() + time.sleep(30) + except Exception as e: + logger.error(f"Error in sync loop: {e}") + time.sleep(60) + + thread = threading.Thread(target=sync_loop, daemon=True) + thread.start() + logger.info("Started background status sync") + +def initialize_sample_data(): + """Create development fixtures only when explicitly enabled.""" + if os.environ.get('ENABLE_SAMPLE_DATA', '').casefold() != 'true': + logger.info("Skipping sample data; set ENABLE_SAMPLE_DATA=true to opt in") + return + + sample_password = os.environ.get('SAMPLE_USER_PASSWORD') + if not sample_password: + logger.warning("Skipping sample user creation; SAMPLE_USER_PASSWORD is not configured") + return + + logger.info("Initializing sample data...") + + k8s_manager.test_connection() + + existing_user = get_user_by_username('testuser') + if not existing_user: + sample_user = create_user( + username='testuser', + email='test@example.com', + password=sample_password, + credits=100 + ) + if sample_user: + logger.info(f"โœ“ Sample user created: {sample_user['username']}") + else: + logger.info(f"โœ“ Sample user exists: {existing_user['username']}") + + # CREATE USER DOMAIN IN DNS IF IT DOESN'T EXIST + if domain_manager and existing_user: + try: + logger.info(f"Checking/Creating domain for user: u-{existing_user['username']}.{domain_manager.base_domain}") + + # Check if user domain exists + user_domain = f"u-{existing_user['username']}.{domain_manager.base_domain}" + domain_exists = domain_manager._check_domain_exists(user_domain) + + if domain_exists is None: + logger.error(f"Error checking domain {user_domain}") + elif not domain_exists: + logger.info(f"User domain doesn't exist, creating: {user_domain}") + success = domain_manager._create_domain(user_domain) + if success: + logger.info(f"โœ“ Created user domain: {user_domain}") + + # SETUP DNSSEC DELEGATION for new domain + logger.info("Setting up DNSSEC delegation...") + dnssec_result = domain_manager.setup_dnssec_delegation(existing_user['username']) + if dnssec_result.get('success'): + logger.info(f"โœ“ DNSSEC delegation configured") + else: + logger.warning(f"โš  DNSSEC setup issues: {dnssec_result.get('message')}") + + # Also create a default subdomain for testing + test_result = domain_manager.create_subdomain(existing_user['username'], 'test') + if test_result.get('success'): + logger.info(f"โœ“ Created test subdomain for user") + else: + logger.error(f"โœ— Failed to create test subdomain: {test_result.get('message')}") + else: + logger.error(f"โœ— Failed to create user domain: {user_domain}") + else: + logger.info(f"โœ“ User domain already exists: {user_domain}") + + # ENSURE DNSSEC IS PROPERLY CONFIGURED for existing domain + logger.info("Ensuring DNSSEC is properly configured...") + dnssec_result = domain_manager.setup_dnssec_delegation(existing_user['username']) + if dnssec_result.get('success'): + logger.info(f"โœ“ DNSSEC properly configured") + else: + logger.warning(f"โš  DNSSEC configuration issues: {dnssec_result.get('message')}") + except Exception as e: + logger.error(f"Error creating user domain: {e}") + + if k8s_manager.connected: + try: + k8s_manager.create_namespace('user-testuser') + logger.info("โœ“ Sample namespace created") + except Exception as e: + logger.error(f"โš  Could not create namespace: {e}") + +def start_ingress_cleanup_thread(): + """Start background thread to clean up orphaned IngressRoutes""" + def cleanup_loop(): + while True: + try: + cleanup_orphaned_ingressroutes() + time.sleep(300) # Run every 5 minutes + except Exception as e: + logger.error(f"Error in ingress cleanup loop: {e}") + time.sleep(60) + + thread = threading.Thread(target=cleanup_loop, daemon=True) + thread.start() + logger.info("Started background IngressRoute cleanup") diff --git a/check_longhorn.py b/check_longhorn.py new file mode 100644 index 0000000..cf26902 --- /dev/null +++ b/check_longhorn.py @@ -0,0 +1,36 @@ +"""Inspect Longhorn backup settings and targets from the active Kubernetes context.""" + +import json +import logging + +from kubernetes import config +from kubernetes.client import ApiException, CustomObjectsApi + +logger = logging.getLogger(__name__) + + +def main() -> int: + """Print Longhorn settings and backup targets as JSON.""" + try: + try: + config.load_incluster_config() + except config.ConfigException: + config.load_kube_config() + + api = CustomObjectsApi() + settings = api.list_namespaced_custom_object( + group="longhorn.io", version="v1beta2", namespace="longhorn-system", plural="settings" + ) + targets = api.list_namespaced_custom_object( + group="longhorn.io", version="v1beta2", namespace="longhorn-system", plural="backuptargets" + ) + except (config.ConfigException, ApiException) as exc: + logger.error("Could not query Longhorn: %s", exc) + return 1 + + print(json.dumps({"settings": settings.get("items", []), "backup_targets": targets.get("items", [])}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/currency_service.py b/currency_service.py new file mode 100644 index 0000000..6b321fc --- /dev/null +++ b/currency_service.py @@ -0,0 +1,107 @@ +"""Exchange-rate retrieval and Stripe currency conversion.""" + + +from datetime import datetime +import requests +from runtime_config import ( + CACHE_DURATION, + EXCHANGE_RATE_CACHE, + EXCHANGE_RATE_LOCK, + logger, +) + +__all__ = ['calculate_stripe_amount', 'fetch_exchange_rates', 'get_cached_exchange_rates'] + + + +def calculate_stripe_amount(credits: int, currency: str) -> int: + """ + Calculate amount in smallest currency unit (cents, pence, etc.) + Base rate: 100 credits = 1.00 USD + """ + # Get USD price + usd_price = credits / 100 # $1 per 100 credits + + # Get exchange rate + rates = get_cached_exchange_rates() + rate = rates.get(currency, 1.0) + + # Convert to target currency + currency_price = usd_price * rate + + # Convert to smallest unit (cents) + # Most currencies use 2 decimal places, but some don't + if currency in ['JPY', 'KRW']: # Zero decimal currencies + return int(currency_price) + else: + return int(currency_price * 100) + +def fetch_exchange_rates(): + """ + Fetch exchange rates from the free currency API + Uses EUR as base currency + """ + try: + # Using the fawazahmed0 currency API - EUR as base + url = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/eur.json' + + logger.info("Fetching exchange rates from API...") + response = requests.get(url, timeout=10) + response.raise_for_status() + + data = response.json() + + if 'eur' in data: + rates_data = data['eur'] + + # Extract rates for currencies we support - all relative to EUR + new_rates = { + 'EUR': 1.00, # Base currency + 'USD': rates_data.get('usd', 1.5), # EUR->USD rate + 'GBP': rates_data.get('gbp', 0.85), # EUR->GBP rate + 'PLN': rates_data.get('pln', 4.32), # EUR->PLN rate + 'PHP': rates_data.get('php', 60.50), # EUR->PHP rate + 'CZK': rates_data.get('czk', 24.65) # EUR->CZK rate + } + + with EXCHANGE_RATE_LOCK: + EXCHANGE_RATE_CACHE['rates'] = new_rates + EXCHANGE_RATE_CACHE['last_updated'] = datetime.now().isoformat() + + logger.info(f"โœ… Exchange rates updated successfully: {new_rates}") + return True + else: + logger.error("Invalid response format from currency API") + return False + + except requests.exceptions.Timeout: + logger.error("โŒ Timeout fetching exchange rates") + return False + except requests.exceptions.RequestException as e: + logger.error(f"โŒ Error fetching exchange rates: {e}") + return False + except Exception as e: + logger.error(f"โŒ Unexpected error fetching exchange rates: {e}") + return False + +def get_cached_exchange_rates(): + """Get cached exchange rates, fetch new ones if cache is stale""" + with EXCHANGE_RATE_LOCK: + last_updated = EXCHANGE_RATE_CACHE.get('last_updated') + + # If never updated or cache is stale, fetch new rates + if last_updated is None: + logger.info("Exchange rates cache is empty, fetching...") + # Release lock before fetching to avoid deadlock + elif (datetime.now() - datetime.fromisoformat(last_updated)).total_seconds() > CACHE_DURATION: + logger.info("Exchange rates cache is stale, fetching...") + else: + # Cache is fresh, return it + return EXCHANGE_RATE_CACHE['rates'] + + # Fetch new rates (outside lock to avoid blocking) + fetch_exchange_rates() + + # Return rates (either newly fetched or fallback) + with EXCHANGE_RATE_LOCK: + return EXCHANGE_RATE_CACHE['rates'] diff --git a/database_service.py b/database_service.py new file mode 100644 index 0000000..bd70b46 --- /dev/null +++ b/database_service.py @@ -0,0 +1,572 @@ +"""Database initialization and connection helpers.""" + + +from contextlib import contextmanager +from datetime import date, datetime, timezone +from db_wrapper import get_db_connection +import json +from runtime_config import ( + DATABASE_URL, + logger, +) + +__all__ = ['check_and_fix_database_schema', 'database_connection', 'dict_from_row', 'get_table_columns', 'init_database'] + + + +def init_database(): + """Initialize the database with required tables""" + + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Users table - EXTENDED + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT, + google_id TEXT UNIQUE, + apple_id TEXT UNIQUE, + role TEXT DEFAULT 'user', + credits INTEGER DEFAULT 100, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + display_name TEXT, + first_name TEXT, + last_name TEXT, + address TEXT, + city TEXT, + country TEXT, + postal_code TEXT, + phone TEXT, + email_verified BOOLEAN DEFAULT FALSE, + email_verification_token TEXT, + password_changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + two_factor_enabled BOOLEAN DEFAULT FALSE, + two_factor_secret TEXT, + last_login TIMESTAMP, + login_count INTEGER DEFAULT 0 + ) + ''') + + # Add google_id and apple_id columns if they don't exist (migration for existing databases) + if 'postgres' in DATABASE_URL: + cursor.execute('ALTER TABLE users ADD COLUMN IF NOT EXISTS google_id TEXT UNIQUE') + cursor.execute('ALTER TABLE users ADD COLUMN IF NOT EXISTS apple_id TEXT UNIQUE') + else: + try: + cursor.execute('ALTER TABLE users ADD COLUMN google_id TEXT UNIQUE') + except: + pass + try: + cursor.execute('ALTER TABLE users ADD COLUMN apple_id TEXT UNIQUE') + except: + pass + + # Make password_hash nullable if it's not already (migration for existing databases) + try: + # SQLite doesn't support ALTER COLUMN, so we'll handle NULL passwords in code + pass + except: + pass + + # Email verification tokens table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + ''') + + # Password reset tokens table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + ''') + + # Servers table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + image TEXT NOT NULL, + version TEXT NOT NULL, + status TEXT DEFAULT 'Creating', + cpu TEXT NOT NULL, + ram TEXT NOT NULL, + storage TEXT NOT NULL, + ip TEXT NOT NULL, + ports TEXT NOT NULL, + owner_id TEXT NOT NULL, + namespace TEXT NOT NULL, + k8s_name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_demo BOOLEAN DEFAULT FALSE, + demo_expires_at TIMESTAMP, + java_version TEXT, + loader TEXT, + sftp_username TEXT, + sftp_password TEXT, + sftp_port INTEGER, + FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE + ) + ''') + + # Add demo columns if they don't exist (for existing databases) + if 'postgres' in DATABASE_URL: + cursor.execute('ALTER TABLE servers ADD COLUMN IF NOT EXISTS is_demo BOOLEAN DEFAULT FALSE') + cursor.execute('ALTER TABLE servers ADD COLUMN IF NOT EXISTS demo_expires_at TIMESTAMP') + cursor.execute('ALTER TABLE servers ADD COLUMN IF NOT EXISTS java_version TEXT') + cursor.execute('ALTER TABLE servers ADD COLUMN IF NOT EXISTS loader TEXT') + cursor.execute('ALTER TABLE servers ADD COLUMN IF NOT EXISTS sftp_username TEXT') + cursor.execute('ALTER TABLE servers ADD COLUMN IF NOT EXISTS sftp_password TEXT') + cursor.execute('ALTER TABLE servers ADD COLUMN IF NOT EXISTS sftp_port INTEGER') + else: + try: + cursor.execute('ALTER TABLE servers ADD COLUMN is_demo BOOLEAN DEFAULT FALSE') + except: + pass + try: + cursor.execute('ALTER TABLE servers ADD COLUMN demo_expires_at TIMESTAMP') + except: + pass + try: + cursor.execute('ALTER TABLE servers ADD COLUMN java_version TEXT') + except: + pass + try: + cursor.execute('ALTER TABLE servers ADD COLUMN loader TEXT') + except: + pass + try: + cursor.execute('ALTER TABLE servers ADD COLUMN sftp_username TEXT') + except: + pass + try: + cursor.execute('ALTER TABLE servers ADD COLUMN sftp_password TEXT') + except: + pass + try: + cursor.execute('ALTER TABLE servers ADD COLUMN sftp_port INTEGER') + except: + pass + + # Demo servers usage tracking table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS demo_servers_used ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE, + used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + ''') + + # Server status history table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS server_status_history ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE CASCADE + ) + ''') + + # Sessions table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + ''') + + # Credit transactions table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS credit_transactions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + amount INTEGER NOT NULL, + type TEXT NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + ''') + + # Database configurations table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS database_configs ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + database_name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE CASCADE + ) + ''') + + # Tickets table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tickets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + subject TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT DEFAULT 'open', + priority TEXT DEFAULT 'medium', + category TEXT DEFAULT 'general', + assigned_admin_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (assigned_admin_id) REFERENCES users (id) ON DELETE SET NULL + ) + ''') + + # Backups table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS backups ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, -- 'Manual', 'Scheduled' + status TEXT DEFAULT 'Pending', -- 'Pending', 'In Progress', 'Completed', 'Failed' + size TEXT, + s3_path TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE CASCADE + ) + ''') + + # Ticket messages table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS ticket_messages ( + id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + user_id TEXT NOT NULL, + message TEXT NOT NULL, + is_admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + ''') + + # Ticket attachments table + # Determine blob type based on database URL + blob_type = 'BYTEA' if 'postgres' in DATABASE_URL else 'BLOB' + + cursor.execute(f''' + CREATE TABLE IF NOT EXISTS ticket_attachments ( + id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + message_id TEXT, + filename TEXT NOT NULL, + file_data {blob_type} NOT NULL, + mime_type TEXT NOT NULL, + file_size INTEGER NOT NULL, + uploaded_by TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE, + FOREIGN KEY (message_id) REFERENCES ticket_messages (id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users (id) ON DELETE CASCADE + ) + ''') + + # Announcements table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS announcements ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + content TEXT NOT NULL, + tags TEXT, + author_id TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE + ) + ''') + + # Key-value settings (e.g. homepage showcase URLs) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS site_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Default pricing and panel settings + default_settings = [ + ('price_cpu', '35'), + ('price_ram', '35'), + ('price_storage', '5'), + ('multiplier_vps', '1.0'), + ('multiplier_game', '0.8'), + ('multiplier_website', '1.5'), + ('multiplier_database', '1.3'), + ('demo_servers_enabled', 'true'), + ('new_user_credits', '0') + ] + for key, val in default_settings: + if 'postgres' in DATABASE_URL: + cursor.execute("INSERT INTO site_settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING", (key, val)) + else: + cursor.execute("INSERT OR IGNORE INTO site_settings (key, value) VALUES (?, ?)", (key, val)) + + # User domains table - WITH is_custom column + cursor.execute(''' + CREATE TABLE IF NOT EXISTS user_domains ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + subdomain TEXT, + full_domain TEXT, + domain TEXT, + is_custom BOOLEAN DEFAULT FALSE, + verified BOOLEAN DEFAULT FALSE, + record_type TEXT DEFAULT 'A', + ip_address TEXT NOT NULL, + ttl INTEGER DEFAULT 3600, + server_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE SET NULL, + UNIQUE(user_id, full_domain), + UNIQUE(user_id, domain) + ) + ''') + + + + conn.commit() + + # Create indexes - moved outside transaction for safety + try: + cursor.execute('CREATE INDEX IF NOT EXISTS idx_servers_owner_id ON servers(owner_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_servers_status ON servers(status)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_server_status_history_server_id ON server_status_history(server_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_id ON credit_transactions(user_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_database_configs_server_id ON database_configs(server_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_tickets_user_id ON tickets(user_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket_id ON ticket_messages(ticket_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_ticket_attachments_ticket_id ON ticket_attachments(ticket_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_domains_user_id ON user_domains(user_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_domains_server_id ON user_domains(server_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_domains_full_domain ON user_domains(full_domain)') + + # Check if is_custom column exists before creating index + columns = get_table_columns(cursor, 'user_domains') + has_is_custom = 'is_custom' in columns + + if has_is_custom: + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_domains_is_custom ON user_domains(is_custom)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain)') + else: + logger.warning("is_custom column not found in user_domains table, skipping index creation") + + conn.commit() + + except Exception as e: + logger.error(f"Error creating indexes: {e}") + conn.rollback() + # Try to add missing columns + try: + columns = get_table_columns(cursor, 'user_domains') + + if 'is_custom' not in columns: + logger.info("Adding missing column: is_custom to user_domains table") + cursor.execute('ALTER TABLE user_domains ADD COLUMN is_custom BOOLEAN DEFAULT FALSE') + + if 'domain' not in columns: + logger.info("Adding missing column: domain to user_domains table") + cursor.execute('ALTER TABLE user_domains ADD COLUMN domain TEXT') + + + conn.commit() + + # Now try creating indexes again + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_domains_is_custom ON user_domains(is_custom)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain)') + conn.commit() + + except Exception as alter_error: + logger.error(f"Error adding missing columns: {alter_error}") + + # Default rows for homepage showcase (3 configurable links) + try: + cursor.execute("SELECT 1 FROM site_settings WHERE key = ?", ('homepage_showcase',)) + if not cursor.fetchone(): + default_links = json.dumps([ + {"label": "Site 1", "url": ""}, + {"label": "Site 2", "url": ""}, + {"label": "Site 3", "url": ""}, + ]) + cursor.execute( + "INSERT INTO site_settings (key, value) VALUES (?, ?)", + ("homepage_showcase", default_links), + ) + conn.commit() + except Exception as seed_err: + logger.warning(f"homepage_showcase default seed: {seed_err}") + + logger.info("Database initialized successfully") + + except Exception as e: + logger.error(f"Error initializing database: {e}") + raise + +def check_and_fix_database_schema(): + """Check and fix any missing columns in existing tables""" + from server_provisioning import generate_sftp_credentials + + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Check user_domains table for missing columns + columns = get_table_columns(cursor, 'user_domains') + + missing_columns = [] + if 'is_custom' not in columns: + missing_columns.append('is_custom') + if 'domain' not in columns: + missing_columns.append('domain') + if 'verified' not in columns: + missing_columns.append('verified') + if 'java_version' not in columns: + missing_columns.append('java_version') + if 'loader' not in columns: + missing_columns.append('loader') + + if missing_columns: + logger.info(f"Adding missing columns to user_domains table: {missing_columns}") + + if 'is_custom' in missing_columns: + cursor.execute('ALTER TABLE user_domains ADD COLUMN is_custom BOOLEAN DEFAULT FALSE') + + if 'domain' in missing_columns: + cursor.execute('ALTER TABLE user_domains ADD COLUMN domain TEXT') + + if 'verified' in missing_columns: + cursor.execute('ALTER TABLE user_domains ADD COLUMN verified BOOLEAN DEFAULT FALSE') + + # Check servers table for new columns + server_columns = get_table_columns(cursor, 'servers') + if 'java_version' not in server_columns: + logger.info("Adding missing column: java_version to servers table") + cursor.execute('ALTER TABLE servers ADD COLUMN java_version TEXT') + + if 'loader' not in server_columns: + logger.info("Adding missing column: loader to servers table") + cursor.execute('ALTER TABLE servers ADD COLUMN loader TEXT') + + if 'sftp_username' not in server_columns: + logger.info("Adding missing column: sftp_username to servers table") + cursor.execute('ALTER TABLE servers ADD COLUMN sftp_username TEXT') + + if 'sftp_password' not in server_columns: + logger.info("Adding missing column: sftp_password to servers table") + cursor.execute('ALTER TABLE servers ADD COLUMN sftp_password TEXT') + + if 'sftp_port' not in server_columns: + logger.info("Adding missing column: sftp_port to servers table") + cursor.execute('ALTER TABLE servers ADD COLUMN sftp_port INTEGER') + + conn.commit() + logger.info("โœ“ Added missing columns to user_domains and servers table") + + # Create indexes that might be missing + try: + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_domains_is_custom ON user_domains(is_custom)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain)') + conn.commit() + except Exception as index_error: + logger.warning(f"Could not create indexes: {index_error}") + + # Populate missing SFTP credentials for existing servers + try: + cursor.execute("SELECT id, type FROM servers WHERE sftp_username IS NULL OR sftp_username = ''") + servers_to_fix = cursor.fetchall() + if servers_to_fix: + logger.info(f"Populating SFTP credentials for {len(servers_to_fix)} servers") + for row in servers_to_fix: + srv = dict_from_row(row) + if srv['type'] != 'vps': + u, p, port = generate_sftp_credentials() + cursor.execute( + 'UPDATE servers SET sftp_username = ?, sftp_password = ?, sftp_port = ? WHERE id = ?', + (u, p, port, srv['id']) + ) + conn.commit() + logger.info("โœ“ Populated missing SFTP credentials") + except Exception as sftp_fix_error: + logger.warning(f"Could not populate SFTP credentials: {sftp_fix_error}") + + except Exception as e: + logger.error(f"Error checking database schema: {e}") + +@contextmanager +def database_connection(): + """Context manager for database connections""" + conn = get_db_connection(DATABASE_URL) + # Row factory is handled in get_db_connection for SQLite, and via cursor_factory for Postgres + try: + yield conn + except Exception as e: + conn.rollback() + raise e + finally: + conn.close() + +def dict_from_row(row): + """Convert sqlite3.Row or dict to dictionary, making naive datetimes UTC-aware.""" + if row is None: + return None + + result = dict(row) + + for key, value in result.items(): + if isinstance(value, datetime): + # If datetime is naive, assume it's UTC and make it aware. + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + result[key] = value.isoformat() + elif isinstance(value, date): + result[key] = value.isoformat() + return result + +def get_table_columns(cursor, table_name): + """Get column names for a table in a database-agnostic way""" + if 'postgres' in DATABASE_URL: + cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = ?", (table_name,)) + return [row['column_name'] for row in cursor.fetchall()] + else: + cursor.execute(f"PRAGMA table_info({table_name})") + return [col[1] for col in cursor.fetchall()] diff --git a/db_wrapper.py b/db_wrapper.py new file mode 100644 index 0000000..ff3b75c --- /dev/null +++ b/db_wrapper.py @@ -0,0 +1,85 @@ +import logging +import psycopg2 +import sqlite3 + + + + +logger = logging.getLogger(__name__) + +class PostgresCursorWrapper: + def __init__(self, cursor): + self.cursor = cursor + + def execute(self, query, args=None): + # Convert SQLite ? placeholders to Postgres %s + if query: + # Replace ? with %s + pg_query = query.replace('?', '%s') + + # Handle BLOB to BYTEA differences if necessary + # (psycopg2 usually handles bytes objects correctly as BYTEA) + else: + pg_query = query + + try: + return self.cursor.execute(pg_query, args) + except Exception as e: + logger.error(f"SQL Error: {e} | Query: {pg_query} | Args: {args}") + raise e + + def fetchall(self): + return self.cursor.fetchall() + + def fetchone(self): + return self.cursor.fetchone() + + def __getattr__(self, name): + return getattr(self.cursor, name) + +class PostgresConnectionWrapper: + def __init__(self, conn): + self.conn = conn + # Enable autocommit or manage it manually? + # sqlite3 connection in app.py context manager does not seem to set autocommit, + # but the context manager does commit() or rollback(). + + def cursor(self): + # Use RealDictCursor to mimic sqlite3.Row (dictionary access) + return PostgresCursorWrapper(self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)) + + def commit(self): + return self.conn.commit() + + def rollback(self): + return self.conn.rollback() + + def close(self): + return self.conn.close() + + def __getattr__(self, name): + return getattr(self.conn, name) + +def get_db_connection(database_url): + """ + Factory function to return a connection object. + Supports 'sqlite://' (or just file path) and 'postgresql://'. + """ + if database_url.startswith('postgres://') or database_url.startswith('postgresql://'): + try: + conn = psycopg2.connect(database_url) + return PostgresConnectionWrapper(conn) + except Exception as e: + logger.error(f"Failed to connect to Postgres: {e}") + raise e + else: + # Fallback to SQLite + # If it starts with sqlite:///, strip it + if database_url.startswith('sqlite:///'): + db_path = database_url.replace('sqlite:///', '') + else: + db_path = database_url + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn diff --git a/domain_manager.py b/domain_manager.py new file mode 100644 index 0000000..d5578dc --- /dev/null +++ b/domain_manager.py @@ -0,0 +1,917 @@ +from datetime import datetime +import dns.resolver +import logging +import os +import re +import requests +import time +from typing import Dict, List, Optional + + + + +logger = logging.getLogger(__name__) + +class DomainManager: + def __init__(self): + # Token deSEC API - should be in environment variables + self.desesec_api_token = os.environ.get('DESEC_API_TOKEN', '') + + if not self.desesec_api_token: + logger.warning("deSEC API is disabled because DESEC_API_TOKEN is not configured") + + self.base_domain = os.environ.get('BASE_DOMAIN', 'DOMAIN') + self.node_ip = os.environ.get('NODE_IP', 'IP') + + # Initialize the client + self.base_url = "https://desec.io/api/v1" + self.headers = { + "Authorization": f"Token {self.desesec_api_token}", + "Content-Type": "application/json" + } + + logger.info(f"Domain Manager initialized for {self.base_domain}") + + if not self.test_connection(): + logger.error("โœ— Failed to connect to deSEC API!") + # Don't raise error, just log - we'll handle gracefully + + def test_connection(self) -> bool: + """Test connection to deSEC API""" + if not self.desesec_api_token: + return False + try: + url = f"{self.base_url}/domains/" + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code == 200: + domains = response.json() + logger.info(f"โœ“ deSEC API connection successful") + logger.info(f"โœ“ Account has {len(domains)} domains") + return True + else: + logger.error(f"โœ— deSEC API connection failed: {response.status_code}") + return False + + except Exception as e: + logger.error(f"โœ— deSEC API connection error: {e}") + return False + + def _get_child_domain_ds_records(self, child_domain: str) -> List[str]: + """Get DS records from child domain's DNSKEY""" + try: + url = f"{self.base_url}/domains/{child_domain}/" + logger.info(f"Fetching DNSKEY info for: {child_domain}") + + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code != 200: + logger.error(f"Failed to get child domain info: {response.status_code}") + return [] + + domain_info = response.json() + ds_records = domain_info.get('keys', [{}])[0].get('ds', []) + + logger.info(f"Found {len(ds_records)} DS records for {child_domain}") + for ds in ds_records: + logger.info(f" DS: {ds}") + + return ds_records + + except Exception as e: + logger.error(f"Error getting DS records: {e}") + return [] + + def _check_existing_ds_record(self, subdomain_prefix: str) -> tuple[bool, List[str]]: + """Check if DS record already exists in parent domain""" + try: + url = f"{self.base_url}/domains/{self.base_domain}/rrsets/?subname={subdomain_prefix}&type=DS" + + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code != 200: + logger.error(f"Failed to check existing DS records: {response.status_code}") + return False, [] + + records = response.json() + + if records and len(records) > 0: + existing_records = records[0].get('records', []) + logger.info(f"Found existing DS records: {existing_records}") + return True, existing_records + + return False, [] + + except Exception as e: + logger.error(f"Error checking existing DS records: {e}") + return False, [] + + def _delete_existing_ds_record(self, subdomain_prefix: str) -> bool: + """Delete existing DS record from parent domain""" + try: + url = f"{self.base_url}/domains/{self.base_domain}/rrsets/{subdomain_prefix}/DS/" + + response = requests.delete(url, headers=self.headers, timeout=30) + + if response.status_code in [200, 204]: + logger.info(f"โœ“ Deleted existing DS record for {subdomain_prefix}") + return True + elif response.status_code == 404: + logger.info(f"No DS record to delete for {subdomain_prefix}") + return True + else: + logger.error(f"Failed to delete DS record: {response.status_code}") + return False + + except Exception as e: + logger.error(f"Error deleting DS record: {e}") + return False + + def _create_ds_record(self, subdomain_prefix: str, ds_records: List[str]) -> bool: + """Create DS record in parent domain""" + try: + url = f"{self.base_url}/domains/{self.base_domain}/rrsets/" + + data = { + "subname": subdomain_prefix, + "type": "DS", + "ttl": 3600, + "records": ds_records + } + + logger.info(f"Creating DS record for {subdomain_prefix}") + logger.info(f"DS records: {ds_records}") + + response = requests.post(url, headers=self.headers, json=data, timeout=30) + + if response.status_code in [200, 201]: + logger.info(f"โœ“ Created DS record for {subdomain_prefix}") + return True + elif response.status_code == 400: + # Try PUT if POST fails (record might exist) + logger.info("Trying PUT instead of POST...") + return self._update_ds_record(subdomain_prefix, ds_records) + else: + logger.error(f"Failed to create DS record: {response.status_code} - {response.text}") + return False + + except Exception as e: + logger.error(f"Error creating DS record: {e}") + return False + + def _update_ds_record(self, subdomain_prefix: str, ds_records: List[str]) -> bool: + """Update existing DS record in parent domain""" + try: + url = f"{self.base_url}/domains/{self.base_domain}/rrsets/{subdomain_prefix}/DS/" + + data = { + "subname": subdomain_prefix, + "type": "DS", + "ttl": 3600, + "records": ds_records + } + + response = requests.put(url, headers=self.headers, json=data, timeout=30) + + if response.status_code in [200, 201]: + logger.info(f"โœ“ Updated DS record for {subdomain_prefix}") + return True + else: + logger.error(f"Failed to update DS record: {response.status_code} - {response.text}") + return False + + except Exception as e: + logger.error(f"Error updating DS record: {e}") + return False + + def _wait_for_dns_propagation(self, child_domain: str, timeout: int = 300) -> bool: + """Wait for DS record to propagate to nameservers""" + logger.info(f"Waiting for DNS propagation (max {timeout}s)...") + + start_time = time.time() + check_interval = 10 + + while time.time() - start_time < timeout: + try: + # Check if DS record appears on authoritative nameserver + resolver = dns.resolver.Resolver() + resolver.nameservers = ['8.8.8.8'] # Use Google DNS to bypass cache + + try: + answer = resolver.resolve(child_domain, 'DS') + if len(answer) > 0: + logger.info(f"โœ“ DS record propagated: {[str(r) for r in answer]}") + return True + except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): + pass + + logger.info("DS record not yet propagated, waiting...") + time.sleep(check_interval) + + except Exception as e: + logger.warning(f"Error checking propagation: {e}") + time.sleep(check_interval) + + logger.warning(f"DS record propagation timeout after {timeout}s") + return False + + def _verify_dnssec_chain(self, child_domain: str) -> bool: + """Verify DNSSEC chain is valid""" + try: + logger.info(f"Verifying DNSSEC chain for {child_domain}...") + + # Check DNSKEY record + resolver = dns.resolver.Resolver() + resolver.nameservers = ['8.8.8.8'] + + try: + dnskey_answer = resolver.resolve(child_domain, 'DNSKEY') + logger.info(f"โœ“ DNSKEY record found with {len(dnskey_answer)} entries") + + # Check if we get authenticated data flag + soa_answer = resolver.resolve(child_domain, 'SOA') + # Note: Python's dnspython doesn't expose AD flag directly + # We'll check for RRSIG records instead + + # Check for RRSIG on SOA + soa_rrset = resolver.resolve(child_domain, 'SOA', raise_on_no_answer=False) + if hasattr(soa_rrset.response, 'answer') and len(soa_rrset.response.answer) > 0: + for rrset in soa_rrset.response.answer: + if rrset.rdtype == dns.rdatatype.SOA: + # Check for RRSIG in additional section + if len(soa_rrset.response.additional) > 0: + logger.info("โœ“ RRSIG records present (DNSSEC signing active)") + return True + + logger.warning("โš  RRSIG records not found, DNSSEC might not be fully active") + return True # Still return True as the chain might be building + + except dns.resolver.NoAnswer: + logger.error("โœ— No DNSKEY answer received") + return False + except Exception as e: + logger.error(f"โœ— DNSSEC verification error: {e}") + return False + + except Exception as e: + logger.error(f"Error in DNSSEC verification: {e}") + return False + + def _get_child_domain_ns_records(self, child_domain: str, retries: int = 3) -> List[str]: + """Get NS records from child domain with retries""" + for attempt in range(retries): + try: + url = f"{self.base_url}/domains/{child_domain}/" + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code == 200: + domain_info = response.json() + ns_records = domain_info.get('nameservers', []) + if ns_records: + logger.info(f"Found {len(ns_records)} NS records for {child_domain} (attempt {attempt + 1})") + return ns_records + + logger.warning(f"Attempt {attempt + 1}: No NS records yet for {child_domain}") + if attempt < retries - 1: + time.sleep(2) + except Exception as e: + logger.error(f"Error getting NS records (attempt {attempt + 1}): {e}") + if attempt < retries - 1: + time.sleep(2) + return [] + + def _get_child_domain_ds_records(self, child_domain: str, retries: int = 3) -> List[str]: + """Get DS records from child domain's DNSKEY with retries""" + for attempt in range(retries): + try: + url = f"{self.base_url}/domains/{child_domain}/" + logger.info(f"Fetching DNSKEY info for: {child_domain} (attempt {attempt + 1})") + + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code == 200: + domain_info = response.json() + ds_records = domain_info.get('keys', [{}])[0].get('ds', []) + if ds_records: + logger.info(f"Found {len(ds_records)} DS records for {child_domain}") + return ds_records + + logger.warning(f"Attempt {attempt + 1}: No DS records yet for {child_domain}") + if attempt < retries - 1: + time.sleep(2) + except Exception as e: + logger.error(f"Error getting DS records: {e}") + if attempt < retries - 1: + time.sleep(2) + return [] + + def _check_existing_ns_record(self, subdomain_prefix: str) -> tuple[bool, List[str]]: + """Check if NS record already exists in parent domain""" + try: + url = f"{self.base_url}/domains/{self.base_domain}/rrsets/?subname={subdomain_prefix}&type=NS" + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code == 200: + records = response.json() + if records and len(records) > 0: + existing_records = records[0].get('records', []) + # Clean up trailing dots for comparison + cleaned_records = [r.rstrip('.') for r in existing_records] + logger.info(f"Found existing NS records: {cleaned_records}") + return True, cleaned_records + return False, [] + except Exception as e: + logger.error(f"Error checking existing NS records: {e}") + return False, [] + + def _create_ns_record(self, subdomain_prefix: str, ns_records: List[str]) -> bool: + """Create NS record in parent domain""" + try: + # Ensure nameservers end with a dot for deSEC + formatted_ns = [ns if ns.endswith('.') else f"{ns}." for ns in ns_records] + + url = f"{self.base_url}/domains/{self.base_domain}/rrsets/" + + data = { + "subname": subdomain_prefix, + "type": "NS", + "ttl": 3600, + "records": formatted_ns + } + + logger.info(f"Creating NS record for {subdomain_prefix}") + response = requests.post(url, headers=self.headers, json=data, timeout=30) + + if response.status_code in [200, 201]: + logger.info(f"โœ“ Created NS record for {subdomain_prefix}") + return True + elif response.status_code == 400: + # Try PUT if record exists + logger.info("NS record likely exists, trying PUT...") + url_put = f"{self.base_url}/domains/{self.base_domain}/rrsets/{subdomain_prefix}/NS/" + response = requests.put(url_put, headers=self.headers, json=data, timeout=30) + return response.status_code in [200, 201] + + logger.error(f"Failed to create NS record: {response.status_code} - {response.text}") + return False + except Exception as e: + logger.error(f"Error creating NS record: {e}") + return False + + def setup_dnssec_delegation(self, username: str) -> Dict: + """ + Set up proper DNSSEC delegation for a user's domain + + Process: + 1. Get NS and DS records from child domain (u-username.anchorhost.eu) + 2. Create/update NS and DS record in parent domain (anchorhost.eu) + 3. Wait for propagation + 4. Verify DNSSEC chain + """ + try: + username = username.lower().strip() + child_domain = f"u-{username}.{self.base_domain}" + subdomain_prefix = f"u-{username}" + + logger.info("="*60) + logger.info(f"Setting up DNSSEC delegation for: {child_domain}") + logger.info("="*60) + + # Step 1: Check if child domain exists + if not self._check_domain_exists(child_domain): + logger.error(f"Child domain doesn't exist: {child_domain}") + return { + 'success': False, + 'message': f"Child domain {child_domain} doesn't exist" + } + + # Step 2: Handle NS delegation + logger.info("Step 1: Handling NS delegation...") + ns_records = self._get_child_domain_ns_records(child_domain) + if not ns_records: + logger.error("No NS records found for child domain") + return { + 'success': False, + 'message': "No NS records found for child domain" + } + + exists_ns, existing_ns = self._check_existing_ns_record(subdomain_prefix) + if exists_ns and set(existing_ns) == set([ns.rstrip('.') for ns in ns_records]): + logger.info("โœ“ NS records already correctly configured") + else: + if not self._create_ns_record(subdomain_prefix, ns_records): + logger.error("Failed to create NS record delegation") + return { + 'success': False, + 'message': "Failed to create NS record delegation" + } + + # Step 3: Handle DS records + logger.info("Step 2: Handling DS records...") + ds_records = self._get_child_domain_ds_records(child_domain) + + if not ds_records: + logger.warning("No DS records found in child domain (DNSSEC might not be enabled yet)") + else: + exists_ds, existing_ds = self._check_existing_ds_record(subdomain_prefix) + if exists_ds and set(existing_ds) == set(ds_records): + logger.info("โœ“ DS record already exists and matches") + else: + if exists_ds: + logger.info("DS record exists but doesn't match, deleting...") + self._delete_existing_ds_record(subdomain_prefix) + + if not self._create_ds_record(subdomain_prefix, ds_records): + logger.warning("Failed to create DS record") + + # Step 4: Wait for propagation + logger.info("Step 3: Waiting for DNS propagation...") + if not self._wait_for_dns_propagation(child_domain, 120): # 2 minutes timeout + logger.warning("DS record propagation timeout, continuing anyway...") + + # Step 5: Verify DNSSEC chain + logger.info("Step 4: Verifying DNSSEC chain...") + if not self._verify_dnssec_chain(child_domain): + logger.warning("DNSSEC chain verification had issues, but continuing...") + + logger.info("="*60) + logger.info(f"โœ… Delegation setup completed for {child_domain}") + logger.info("="*60) + + return { + 'success': True, + 'message': f"Delegation configured for {child_domain}", + 'ds_records': ds_records if 'ds_records' in locals() else [] + } + + except Exception as e: + logger.error(f"Error setting up DNSSEC delegation: {e}", exc_info=True) + return { + 'success': False, + 'message': f"DNSSEC setup error: {str(e)}" + } + + def create_subdomain(self, username: str, subdomain_name: str) -> Dict: + """ + Create a subdomain for user with proper DNSSEC setup + + Returns: dict with 'success', 'message', and optionally 'domain_data' + """ + try: + # Validate inputs + if not username or not subdomain_name: + return { + 'success': False, + 'message': "Username and subdomain name are required", + 'domain_data': None + } + + # Clean and validate inputs + username = username.lower().strip() + subdomain_name = subdomain_name.lower().strip() + + # Validate subdomain format + if not self._is_valid_subdomain(subdomain_name): + return { + 'success': False, + 'message': "Invalid subdomain name. Use only letters, numbers, and hyphens", + 'domain_data': None + } + + # Construct domains + user_domain = f"u-{username}.{self.base_domain}" + full_subdomain = f"{subdomain_name}.{user_domain}" + + logger.info("="*60) + logger.info(f"Creating subdomain: {full_subdomain}") + logger.info(f"User domain: {user_domain}") + logger.info(f"Subdomain: {subdomain_name}") + logger.info(f"Target IP: {self.node_ip}") + logger.info("="*60) + + # Step 1: Ensure user's domain exists + logger.info(f"Step 1: Checking if user domain exists: {user_domain}") + user_domain_exists = self._check_domain_exists(user_domain) + logger.info(f"Domain exists: {user_domain_exists}") + + if user_domain_exists is None: + return { + 'success': False, + 'message': f"Error checking domain {user_domain}", + 'domain_data': None + } + + if not user_domain_exists: + logger.info(f"Step 2: Creating user domain: {user_domain}") + domain_created = self._create_domain(user_domain) + + if not domain_created: + return { + 'success': False, + 'message': f"Failed to create user domain {user_domain}", + 'domain_data': None + } + + logger.info(f"โœ“ User domain created: {user_domain}") + + # Step 3: Setup DNSSEC delegation for the new domain + logger.info(f"Step 3: Setting up DNSSEC delegation...") + dnssec_result = self.setup_dnssec_delegation(username) + + if not dnssec_result.get('success'): + logger.warning(f"โš  DNSSEC setup had issues: {dnssec_result.get('message')}") + # Continue anyway, as DNSSEC is not critical for basic functionality + + else: + logger.info(f"โœ“ User domain already exists: {user_domain}") + + # Check if DNSSEC is properly configured + logger.info("Checking DNSSEC configuration...") + dnssec_result = self.setup_dnssec_delegation(username) + if not dnssec_result.get('success'): + logger.warning(f"โš  DNSSEC check had issues: {dnssec_result.get('message')}") + + # Step 4: Create the A record for the subdomain + logger.info(f"Step 4: Creating A record for subdomain: {subdomain_name}") + logger.info(f"Target: {subdomain_name}.{user_domain} โ†’ {self.node_ip}") + + url = f"{self.base_url}/domains/{user_domain}/rrsets/" + + data = { + "subname": subdomain_name, + "type": "A", + "ttl": 3600, + "records": [self.node_ip] + } + + logger.info(f"POST URL: {url}") + + response = requests.post(url, headers=self.headers, json=data, timeout=30) + + logger.info(f"Response Status: {response.status_code}") + + if response.status_code in [200, 201]: + domain_info = { + 'domain': full_subdomain, + 'subdomain': subdomain_name, + 'username': username, + 'record_type': 'A', + 'ip_address': self.node_ip, + 'ttl': 3600, + 'created_at': datetime.now().isoformat() + } + + logger.info("="*60) + logger.info(f"โœ… SUCCESS: Subdomain created!") + logger.info(f"Domain: {full_subdomain}") + logger.info(f"Points to: {self.node_ip}") + logger.info("="*60) + + # Step 5: Create www CNAME record + self._create_www_cname(username, subdomain_name) + + return { + 'success': True, + 'message': f"Subdomain {full_subdomain} created successfully", + 'domain_data': domain_info + } + else: + error_msg = f"API error: {response.status_code} - {response.text}" + logger.error("="*60) + logger.error(f"โŒ FAILED to create subdomain") + logger.error(f"Status: {response.status_code}") + logger.error(f"Error: {response.text}") + logger.error("="*60) + return { + 'success': False, + 'message': error_msg, + 'domain_data': None + } + + except requests.exceptions.RequestException as e: + logger.error(f"Network error creating subdomain: {e}") + return { + 'success': False, + 'message': f"Network error: {str(e)}", + 'domain_data': None + } + except Exception as e: + logger.error(f"Error creating subdomain: {e}", exc_info=True) + return { + 'success': False, + 'message': f"Internal error: {str(e)}", + 'domain_data': None + } + + def _create_www_cname(self, username: str, subdomain_name: str): + """Create www CNAME record for the subdomain""" + try: + user_domain = f"u-{username}.{self.base_domain}" + + # Create www.subdomain CNAME pointing to the subdomain + cname_data = { + "subname": f"www.{subdomain_name}", + "type": "CNAME", + "ttl": 3600, + "records": [f"{subdomain_name}.{user_domain}."] # Note the trailing dot + } + + url = f"{self.base_url}/domains/{user_domain}/rrsets/" + + response = requests.post(url, headers=self.headers, json=cname_data, timeout=30) + + if response.status_code in [200, 201]: + logger.info(f"โœ“ Created www CNAME for {subdomain_name}.{user_domain}") + else: + logger.warning(f"Failed to create www CNAME: {response.status_code}") + + except Exception as e: + logger.error(f"Error creating www CNAME: {e}") + + def _check_domain_exists(self, domain_name: str) -> Optional[bool]: + """Check if a domain exists in deSEC""" + try: + url = f"{self.base_url}/domains/{domain_name}/" + + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code == 200: + logger.info(f"โœ“ Domain exists: {domain_name}") + return True + elif response.status_code == 404: + logger.info(f"โœ“ Domain does not exist: {domain_name}") + return False + else: + logger.error(f"Unexpected response: {response.status_code} - {response.text}") + return None + + except Exception as e: + logger.error(f"Error checking domain: {e}") + return None + + def _create_domain(self, domain_name: str) -> bool: + """Create a new domain in deSEC""" + try: + url = f"{self.base_url}/domains/" + data = {"name": domain_name} + + response = requests.post(url, headers=self.headers, json=data, timeout=30) + + if response.status_code in [200, 201]: + logger.info(f"โœ“ Domain {domain_name} created successfully") + return True + else: + logger.error(f"โœ— Failed to create domain {domain_name}") + logger.error(f"Status: {response.status_code}") + logger.error(f"Error: {response.text}") + return False + + except Exception as e: + logger.error(f"Error creating domain: {e}", exc_info=True) + return False + + def delete_subdomain(self, username: str, subdomain_name: str) -> Dict: + """Delete a subdomain""" + try: + if not username or not subdomain_name: + return { + 'success': False, + 'message': "Username and subdomain name are required" + } + + username = username.lower().strip() + subdomain_name = subdomain_name.lower().strip() + user_domain = f"u-{username}.{self.base_domain}" + full_subdomain = f"{subdomain_name}.{user_domain}" + + logger.info("="*60) + logger.info(f"Deleting subdomain: {full_subdomain}") + logger.info("="*60) + + # Check if user domain exists + if not self._check_domain_exists(user_domain): + return { + 'success': False, + 'message': f"User domain {user_domain} not found" + } + + # Delete the A record + url = f"{self.base_url}/domains/{user_domain}/rrsets/{subdomain_name}/A/" + + response = requests.delete(url, headers=self.headers, timeout=30) + + # Try to delete www CNAME if it exists + try: + www_url = f"{self.base_url}/domains/{user_domain}/rrsets/www.{subdomain_name}/CNAME/" + www_response = requests.delete(www_url, headers=self.headers, timeout=10) + if www_response.status_code in [200, 204]: + logger.info(f"โœ“ Also deleted www CNAME record") + except: + pass + + if response.status_code in [200, 204]: + logger.info(f"โœ“ Deleted subdomain: {full_subdomain}") + return { + 'success': True, + 'message': f"Subdomain {full_subdomain} deleted successfully" + } + elif response.status_code == 404: + return { + 'success': False, + 'message': f"Subdomain {full_subdomain} not found" + } + else: + error_msg = f"API error: {response.status_code} - {response.text}" + logger.error(f"โœ— Failed to delete subdomain: {error_msg}") + return { + 'success': False, + 'message': error_msg + } + + except requests.exceptions.RequestException as e: + logger.error(f"Network error deleting subdomain: {e}") + return { + 'success': False, + 'message': f"Network error: {str(e)}" + } + except Exception as e: + logger.error(f"Error deleting subdomain: {e}") + return { + 'success': False, + 'message': f"Internal error: {str(e)}" + } + + def list_user_domains(self, username: str) -> Dict: + """List all domains/subdomains for a user""" + try: + if not username: + return { + 'success': False, + 'message': "Username required", + 'domains': [] + } + + username = username.lower().strip() + user_domain = f"u-{username}.{self.base_domain}" + + logger.info(f"Listing domains for user: {username}") + + # Check if user domain exists + if not self._check_domain_exists(user_domain): + logger.info(f"User domain doesn't exist: {user_domain}") + return { + 'success': True, + 'message': "No domains found for user", + 'domains': [] + } + + # Get all records for the user domain + url = f"{self.base_url}/domains/{user_domain}/rrsets/" + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code != 200: + return { + 'success': False, + 'message': f"API error: {response.status_code}", + 'domains': [] + } + + records = response.json() + user_domains = [] + + for record in records: + # Filter for A records that are subdomains (not empty subname) + if record.get('type') == 'A' and record.get('subname'): + domain_info = { + 'subdomain': record.get('subname', ''), + 'full_domain': f"{record.get('subname', '')}.{user_domain}", + 'record_type': record.get('type', ''), + 'ip_address': record.get('records', [''])[0] if record.get('records') else '', + 'ttl': record.get('ttl', 3600), + 'created_at': record.get('created', datetime.now().isoformat()) + } + user_domains.append(domain_info) + + logger.info(f"Retrieved {len(user_domains)} domains for user {username}") + return { + 'success': True, + 'message': f"Found {len(user_domains)} domains", + 'domains': user_domains + } + + except requests.exceptions.RequestException as e: + logger.error(f"Network error listing domains: {e}") + return { + 'success': False, + 'message': f"Network error: {str(e)}", + 'domains': [] + } + except Exception as e: + logger.error(f"Error listing domains: {e}") + return { + 'success': False, + 'message': f"Internal error: {str(e)}", + 'domains': [] + } + + def _is_valid_subdomain(self, subdomain: str) -> bool: + """Validate subdomain name format""" + # Allow letters, numbers, and hyphens, 1-63 chars + # Can't start or end with hyphen, can't have consecutive hyphens + pattern = r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$' + if not re.match(pattern, subdomain): + return False + + if len(subdomain) > 63: + return False + + if '--' in subdomain: + return False + + return True + + def check_domain_availability(self, subdomain_name: str, username: str) -> Dict: + """Check if a subdomain is available for the user""" + try: + username = username.lower().strip() + subdomain_name = subdomain_name.lower().strip() + user_domain = f"u-{username}.{self.base_domain}" + + # First validate the subdomain name + if not self._is_valid_subdomain(subdomain_name): + return { + 'available': False, + 'message': f"Invalid subdomain name: {subdomain_name}" + } + + # Check if user domain exists first + if not self._check_domain_exists(user_domain): + return { + 'available': True, + 'message': f"Subdomain {subdomain_name} is available" + } + + # Check if the specific A record exists + url = f"{self.base_url}/domains/{user_domain}/rrsets/{subdomain_name}/A/" + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code == 404: + return { + 'available': True, + 'message': f"Subdomain {subdomain_name} is available" + } + elif response.status_code == 200: + return { + 'available': False, + 'message': f"Subdomain {subdomain_name} already exists" + } + else: + return { + 'available': False, + 'message': f"API error: {response.status_code}" + } + + except Exception as e: + return { + 'available': False, + 'message': f"Error checking availability: {str(e)}" + } + + def fix_all_dnssec_delegations(self): + """Fix DNSSEC delegation for all existing user domains""" + try: + # Get all domains + url = f"{self.base_url}/domains/" + response = requests.get(url, headers=self.headers, timeout=30) + + if response.status_code != 200: + logger.error(f"Failed to get domains: {response.status_code}") + return + + domains = response.json() + fixed_count = 0 + error_count = 0 + + for domain in domains: + domain_name = domain.get('name', '') + + # Check if it's a user domain (starts with u- and ends with .anchorhost.eu) + if domain_name.startswith('u-') and domain_name.endswith(f'.{self.base_domain}'): + # Extract username from domain + username = domain_name.replace(f".{self.base_domain}", "").replace("u-", "") + + logger.info(f"Fixing DNSSEC delegation for: {domain_name} (user: {username})") + + result = self.setup_dnssec_delegation(username) + + if result.get('success'): + fixed_count += 1 + logger.info(f"โœ“ Fixed DNSSEC for {domain_name}") + else: + error_count += 1 + logger.error(f"โœ— Failed to fix DNSSEC for {domain_name}: {result.get('message')}") + + logger.info(f"DNSSEC fix completed: {fixed_count} fixed, {error_count} errors") + + except Exception as e: + logger.error(f"Error fixing DNSSEC delegations: {e}") diff --git a/domain_runtime.py b/domain_runtime.py new file mode 100644 index 0000000..6558b76 --- /dev/null +++ b/domain_runtime.py @@ -0,0 +1,17 @@ +"""Optional DNS provider integration initialized for the application process.""" + +import os + +from domain_manager import DomainManager +from runtime_config import logger + +if not os.environ.get("DESEC_API_TOKEN"): + logger.warning("DNS management is disabled because DESEC_API_TOKEN is not configured") + domain_manager = None +else: + try: + domain_manager = DomainManager() + logger.info("Domain manager initialized") + except Exception as exc: + logger.exception("Domain manager initialization failed: %s", exc) + domain_manager = None diff --git a/domain_service.py b/domain_service.py new file mode 100644 index 0000000..45ca19e --- /dev/null +++ b/domain_service.py @@ -0,0 +1,700 @@ +"""Domain persistence, DNS validation, and Kubernetes ingress operations.""" + + +from database_service import database_connection, dict_from_row +from datetime import datetime +from domain_runtime import domain_manager +import json +from kubernetes.client.rest import ApiException +from kubernetes_service import custom_objects_api, k8s_manager +import re +from runtime_config import ( + NODE_IP, + logger, +) +from server_records import get_server_by_id +import time +from typing import Dict, List, Optional +from user_service import get_user_by_id +import uuid + +__all__ = ['cleanup_orphaned_ingressroutes', 'create_custom_domain_record', 'create_domain_record', 'create_traefik_ingressroute', 'delete_domain_record', 'delete_traefik_ingressroute', 'get_domain_by_id', 'get_user_custom_domains', 'get_user_domains', 'is_domain_available', 'link_domain_to_server', 'unlink_domain_from_server', 'verify_domain_dns'] + + + +def create_custom_domain_record(domain_data: Dict) -> Optional[Dict]: + """Create custom domain record in database and create IngressRoute if server_id is provided""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Check if custom domain already exists for this user + cursor.execute(''' + SELECT id FROM user_domains + WHERE domain = ? AND user_id = ? AND is_custom = TRUE + ''', (domain_data['domain'], domain_data['user_id'])) + + if cursor.fetchone(): + return None + + domain_id = f"custom-{str(uuid.uuid4())[:8]}" + + cursor.execute(''' + INSERT INTO user_domains (id, user_id, domain, is_custom, verified, + record_type, ip_address, ttl, + server_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + domain_id, + domain_data['user_id'], + domain_data['domain'], + True, # is_custom + True, # verified (since we checked) + domain_data['record_type'], + domain_data['ip_address'], + domain_data.get('ttl', 3600), + domain_data.get('server_id'), + domain_data.get('created_at', datetime.now().isoformat()) + )) + conn.commit() + + cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) + domain = dict_from_row(cursor.fetchone()) + + # IMPORTANT: If server_id is provided, create IngressRoute + if domain and domain_data.get('server_id'): + # Get user and server information + user = get_user_by_id(domain_data['user_id']) + server = get_server_by_id(domain_data['server_id']) + + if user and server: + namespace = f"user-{user['username']}".lower() + service_name = f"{domain_data['server_id']}-service" + + # Get the correct port for the service + service_port = 80 + if server['ports']: + ports_data = json.loads(server['ports']) if isinstance(server['ports'], str) else server['ports'] + for port in ports_data: + if port['internal'] == 80: + service_port = 80 + break + else: + service_port = ports_data[0]['internal'] if ports_data else 80 + + # Create IngressRoute for custom domain + logger.info(f"Creating IngressRoute for custom domain {domain_data['domain']}") + create_traefik_ingressroute( + domain=domain_data['domain'], + namespace=namespace, + service_name=service_name, + service_port=service_port + ) + + return domain + + except Exception as e: + logger.error(f"Error creating custom domain record: {e}") + return None + +def get_user_custom_domains(user_id: str) -> List[Dict]: + """Get all custom domains for a user""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + cursor.execute(''' + SELECT ud.*, s.name as server_name, s.type as server_type + FROM user_domains ud + LEFT JOIN servers s ON ud.server_id = s.id + WHERE ud.user_id = ? AND ud.is_custom = TRUE + ORDER BY ud.created_at DESC + ''', (user_id,)) + + domains = [dict_from_row(row) for row in cursor.fetchall()] + return domains + except Exception as e: + logger.error(f"Error getting user custom domains: {e}") + return [] + + + + +def verify_domain_dns(domain: str) -> Dict: + """Verify if a domain points to our server IP and check domain restrictions""" + try: + import socket + + # Check if domain is from restricted domains + restricted_domains = ['kvj.ovh', 'anchorhost.eu'] + + # Block subdomains of restricted domains + for restricted in restricted_domains: + if domain.endswith(f'.{restricted}') or domain == restricted: + return { + 'verified': False, + 'message': f'Domain from {restricted} cannot be used as custom domain', + 'current_ip': None, + 'blocked': True, + 'restricted_domain': restricted + } + + # Check if domain already exists in database (used by another user) + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT ud.*, u.username + FROM user_domains ud + JOIN users u ON ud.user_id = u.id + WHERE ud.domain = ? AND ud.is_custom = TRUE + LIMIT 1 + ''', (domain.lower(),)) + + existing_domain = dict_from_row(cursor.fetchone()) + + if existing_domain: + return { + 'verified': False, + 'message': f'Domain already used by user {existing_domain["username"]}', + 'current_ip': None, + 'already_used': True, + 'used_by': existing_domain['username'] + } + except Exception as db_error: + logger.error(f"Error checking database for domain: {db_error}") + # Continue with DNS check even if database check fails + + # Get IP address from domain + try: + ip_address = socket.gethostbyname(domain) + except socket.gaierror: + return { + 'verified': False, + 'message': 'Domain does not exist or cannot be resolved', + 'current_ip': None + } + + # Check if it points to our server + if ip_address == NODE_IP: + return { + 'verified': True, + 'message': f'Domain points to our server ({NODE_IP})', + 'ip_address': ip_address + } + else: + return { + 'verified': False, + 'message': f'Domain points to {ip_address}, not our server ({NODE_IP})', + 'current_ip': ip_address + } + + except Exception as e: + logger.error(f"Error verifying domain DNS: {e}") + return { + 'verified': False, + 'message': f'Error verifying domain: {str(e)}', + 'current_ip': None + } + + +def is_domain_available(domain: str, current_user_id: str) -> Dict: + """Check if a custom domain is available for use""" + try: + domain = domain.lower().strip() + + # Check restricted domains + restricted_domains = ['kvj.ovh', 'anchorhost.eu'] + for restricted in restricted_domains: + if domain.endswith(f'.{restricted}') or domain == restricted: + return { + 'available': False, + 'message': f'Domain from {restricted} cannot be used as custom domain', + 'reason': 'restricted' + } + + # Check if already used by other users + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT ud.*, u.username + FROM user_domains ud + JOIN users u ON ud.user_id = u.id + WHERE ud.domain = ? AND ud.is_custom = TRUE AND ud.user_id != ? + LIMIT 1 + ''', (domain, current_user_id)) + + existing_domain = dict_from_row(cursor.fetchone()) + + if existing_domain: + return { + 'available': False, + 'message': f'Domain already used by user {existing_domain["username"]}', + 'reason': 'already_used' + } + + return { + 'available': True, + 'message': 'Domain is available' + } + + except Exception as e: + logger.error(f"Error checking domain availability: {e}") + return { + 'available': False, + 'message': f'Error checking domain availability: {str(e)}', + 'reason': 'error' + } + + + +def create_domain_record(domain_data: Dict) -> Optional[Dict]: + """Create domain record in database""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Check if domain already exists + cursor.execute(''' + SELECT id FROM user_domains + WHERE full_domain = ? AND user_id = ? + ''', (domain_data['full_domain'], domain_data['user_id'])) + + if cursor.fetchone(): + return None + + domain_id = f"domain-{str(uuid.uuid4())[:8]}" + + cursor.execute(''' + INSERT INTO user_domains (id, user_id, subdomain, full_domain, + record_type, ip_address, ttl, + server_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + domain_id, + domain_data['user_id'], + domain_data['subdomain'], + domain_data['full_domain'], + domain_data['record_type'], + domain_data['ip_address'], + domain_data.get('ttl', 3600), + domain_data.get('server_id'), + domain_data.get('created_at', datetime.now().isoformat()) + )) + conn.commit() + + cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) + return dict_from_row(cursor.fetchone()) + + except Exception as e: + logger.error(f"Error creating domain record: {e}") + return None + +def get_user_domains(user_id: str, include_serverless: bool = True) -> List[Dict]: + """Get all domains for a user""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + query = ''' + SELECT ud.*, s.name as server_name, s.type as server_type + FROM user_domains ud + LEFT JOIN servers s ON ud.server_id = s.id + WHERE ud.user_id = ? + ''' + + if not include_serverless: + query += ' AND ud.server_id IS NOT NULL' + + query += ' ORDER BY ud.created_at DESC' + + cursor.execute(query, (user_id,)) + domains = [dict_from_row(row) for row in cursor.fetchall()] + + return domains + except Exception as e: + logger.error(f"Error getting user domains: {e}") + return [] + +def get_domain_by_id(domain_id: str) -> Optional[Dict]: + """Get domain by ID""" + try: + domain_id = str(domain_id) + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT ud.*, s.name as server_name, s.type as server_type, s.k8s_name + FROM user_domains ud + LEFT JOIN servers s ON ud.server_id = s.id + WHERE ud.id = ? + ''', (domain_id,)) + return dict_from_row(cursor.fetchone()) + except Exception as e: + logger.error(f"Error getting domain: {e}") + return None + +def cleanup_orphaned_ingressroutes(): + """Clean up orphaned IngressRoutes (domains that no longer exist)""" + if not k8s_manager.connected: + return + + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Get all user domains from database + cursor.execute('SELECT ud.full_domain, u.username FROM user_domains ud JOIN users u ON ud.user_id = u.id') + db_domains = {row['full_domain']: row['username'] for row in cursor.fetchall()} + + # For each user namespace, check IngressRoutes + for domain, username in db_domains.items(): + if not domain or not username: + continue + namespace = f"user-{username}".lower() + subdomain = domain.split('.')[0] + ingressroute_name = f"{subdomain}-ingress" + + try: + # Check if IngressRoute exists + ingress = custom_objects_api.get_namespaced_custom_object( + group="traefik.io", + version="v1alpha1", + namespace=namespace, + plural="ingressroutes", + name=ingressroute_name + ) + + # If domain exists in DB but is not linked to a server, delete the IngressRoute + cursor.execute('SELECT server_id FROM user_domains WHERE full_domain = ?', (domain,)) + domain_row = cursor.fetchone() + + if domain_row and not domain_row['server_id']: + # Domain exists but not linked to any server, delete orphaned IngressRoute + logger.info(f"Deleting orphaned IngressRoute for unlinked domain: {domain}") + delete_traefik_ingressroute(domain, namespace) + + except ApiException as e: + if e.status == 404: + # IngressRoute doesn't exist, which is fine + pass + else: + logger.error(f"Error checking IngressRoute for {domain}: {e}") + + except Exception as e: + logger.error(f"Error cleaning up orphaned IngressRoutes: {e}") + +def delete_domain_record(domain_id: str) -> bool: + """Delete domain record from database""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Get domain info before deleting (for logging/cleanup) + cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) + domain = dict_from_row(cursor.fetchone()) + + if domain: + logger.info(f"Deleting domain record: {domain.get('full_domain')} with server_id: {domain.get('server_id')}") + + # If domain was linked to a server, we should have already unlinked it + # But just in case, log if there's still a server_id + if domain.get('server_id'): + logger.warning(f"Domain {domain['full_domain']} still has server_id {domain['server_id']} during deletion") + + # Delete the domain record + cursor.execute('DELETE FROM user_domains WHERE id = ?', (domain_id,)) + conn.commit() + + deleted = cursor.rowcount > 0 + if deleted and domain: + logger.info(f"โœ“ Deleted domain record: {domain.get('full_domain')}") + + return deleted + except Exception as e: + logger.error(f"Error deleting domain record: {e}") + return False + +def create_traefik_ingressroute(domain: str, namespace: str, service_name: str, service_port: int) -> bool: + """ + Create a Traefik IngressRoute for the domain + Supports both delegated domains (subdomain.u-username.anchorhost.eu) + and custom domains (anchor.opime.online) + """ + try: + if not k8s_manager.connected: + logger.warning("Kubernetes not connected, skipping IngressRoute creation") + return False + + # FIXED: Better validation + if not domain or domain == 'None': + logger.error(f"Invalid domain name: {domain}") + return False + + if not namespace or not service_name or not service_port: + logger.error(f"Missing required parameters: namespace={namespace}, service={service_name}, port={service_port}") + return False + + # For IngressRoute name, sanitize the domain to be K8s-friendly + # Replace dots with hyphens and remove any invalid characters + ingressroute_name = domain.lower().replace('.', '-').replace('*', 'wildcard')[:63] + # Ensure it starts and ends with alphanumeric + ingressroute_name = re.sub(r'^[^a-z0-9]+|[^a-z0-9]+$', '', ingressroute_name) + if not ingressroute_name: + ingressroute_name = f"ingress-{str(uuid.uuid4())[:8]}" + + logger.info(f"Creating IngressRoute: {ingressroute_name}") + logger.info(f" Domain: {domain}") + logger.info(f" Namespace: {namespace}") + logger.info(f" Service: {service_name}:{service_port}") + + # Create IngressRoute manifest + ingressroute_body = { + 'apiVersion': 'traefik.io/v1alpha1', + 'kind': 'IngressRoute', + 'metadata': { + 'name': ingressroute_name, + 'namespace': namespace, + 'labels': { + 'app': service_name, + 'managed-by': 'anchorhost', + 'domain': domain, + 'domain-type': 'custom' if '.' in domain and not domain.endswith(f'.{domain_manager.base_domain}') else 'delegated' + }, + 'annotations': { + 'anchorhost.eu/created-at': datetime.now().isoformat(), + 'anchorhost.eu/domain': domain + } + }, + 'spec': { + 'entryPoints': ['web', 'websecure'], + 'routes': [ + { + 'match': f"Host(`{domain}`)", + 'kind': 'Rule', + 'services': [ + { + 'name': service_name, + 'port': service_port + } + ] + } + ], + 'tls': { + 'certResolver': 'letsencrypt' + } + } + } + + # Try to delete existing IngressRoute first (if updating) + try: + custom_objects_api.delete_namespaced_custom_object( + group="traefik.io", + version="v1alpha1", + namespace=namespace, + plural="ingressroutes", + name=ingressroute_name + ) + logger.info(f"Deleted existing IngressRoute: {ingressroute_name}") + time.sleep(1) + except ApiException as e: + if e.status != 404: + logger.warning(f"Error deleting existing IngressRoute: {e.reason}") + + # Create new IngressRoute + try: + result = custom_objects_api.create_namespaced_custom_object( + group="traefik.io", + version="v1alpha1", + namespace=namespace, + plural="ingressroutes", + body=ingressroute_body + ) + + logger.info(f"โœ… Successfully created IngressRoute: {ingressroute_name}") + return True + + except ApiException as e: + logger.error(f"Failed to create IngressRoute: {e.status} - {e.reason}") + if hasattr(e, 'body'): + logger.error(f"Error details: {e.body}") + return False + + except Exception as e: + logger.error(f"Unexpected error creating IngressRoute: {e}", exc_info=True) + return False + +def delete_traefik_ingressroute(domain: str, namespace: str) -> bool: + """ + Delete a Traefik IngressRoute for the domain + """ + try: + if not k8s_manager.connected: + logger.warning("Kubernetes not connected, skipping IngressRoute deletion") + return False + + # FIXED: Better validation + if not domain or domain == 'None': + logger.error(f"Invalid domain name: {domain}") + return False + + ingressroute_name = domain.lower().replace('.', '-').replace('*', 'wildcard')[:63] + ingressroute_name = re.sub(r'^[^a-z0-9]+|[^a-z0-9]+$', '', ingressroute_name) + if not ingressroute_name: + logger.error("Invalid domain format: %s", domain) + return False + + logger.info(f"Deleting IngressRoute for domain: {domain} in namespace: {namespace}") + + try: + custom_objects_api.delete_namespaced_custom_object( + group="traefik.io", + version="v1alpha1", + namespace=namespace, + plural="ingressroutes", + name=ingressroute_name + ) + + logger.info(f"โœ… Successfully deleted IngressRoute: {ingressroute_name}") + return True + + except ApiException as e: + if e.status == 404: + logger.info(f"IngressRoute {ingressroute_name} not found (may have been already deleted)") + return True + else: + logger.error(f"Failed to delete IngressRoute: {e.status} - {e.reason}") + return False + + except Exception as e: + logger.error(f"Unexpected error deleting IngressRoute for domain {domain}: {e}", exc_info=True) + return False + +def link_domain_to_server(domain_id: str, server_id: str) -> bool: + """Link a domain to a server and create IngressRoute""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Get domain details + cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) + domain = dict_from_row(cursor.fetchone()) + + if not domain: + logger.error(f"Domain not found: {domain_id}") + return False + + # Get server details + cursor.execute('SELECT * FROM servers WHERE id = ?', (server_id,)) + server = dict_from_row(cursor.fetchone()) + + if not server: + logger.error(f"Server not found: {server_id}") + return False + + # Get user details for namespace + cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],)) + user = cursor.fetchone() + + if not user: + logger.error(f"User not found: {domain['user_id']}") + return False + + username = user['username'] + namespace = f"user-{username}".lower() + + # Determine service name and port + service_name = f"{server_id}-service" + + # Get the correct port for the service + if server['ports']: + ports_data = json.loads(server['ports']) if isinstance(server['ports'], str) else server['ports'] + + # For website/VPS, use port 80 if available, otherwise use first port + service_port = 80 + for port in ports_data: + if port['internal'] == 80: + service_port = 80 + break + else: + # Use first available port if 80 not found + service_port = ports_data[0]['internal'] if ports_data else 80 + else: + service_port = 80 + + logger.info(f"Linking domain {domain['full_domain']} to server {server['name']}") + logger.info(f"Service: {service_name}, Port: {service_port}, Namespace: {namespace}") + + # Update database + cursor.execute(''' + UPDATE user_domains + SET server_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (server_id, domain_id)) + conn.commit() + + # Create IngressRoute in Kubernetes + if k8s_manager.connected: + success = create_traefik_ingressroute( + domain=domain['full_domain'], + namespace=namespace, + service_name=service_name, + service_port=service_port + ) + + if not success: + logger.warning(f"Failed to create IngressRoute for {domain['full_domain']}, but database updated") + # Don't rollback database - domain is still linked, just no routing yet + else: + logger.warning("Kubernetes not connected, IngressRoute not created") + + logger.info(f"โœ… Successfully linked domain {domain['full_domain']} to server {server['name']}") + return True + + except Exception as e: + logger.error(f"Error linking domain to server: {e}", exc_info=True) + return False + +def unlink_domain_from_server(domain_id: str) -> bool: + """Unlink a domain from any server and delete IngressRoute""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Get domain details before unlinking + cursor.execute('SELECT * FROM user_domains WHERE id = ?', (domain_id,)) + domain = dict_from_row(cursor.fetchone()) + + if not domain: + logger.error(f"Domain not found: {domain_id}") + return False + + # Get user details for namespace + cursor.execute('SELECT username FROM users WHERE id = ?', (domain['user_id'],)) + user = cursor.fetchone() + + if user: + username = user['username'] + namespace = f"user-{username}".lower() + + # Delete IngressRoute in Kubernetes + if k8s_manager.connected: + logger.info(f"Deleting IngressRoute for domain: {domain['full_domain']} in namespace: {namespace}") + delete_traefik_ingressroute( + domain=domain['full_domain'], + namespace=namespace + ) + + # Update database to remove server link + cursor.execute(''' + UPDATE user_domains + SET server_id = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (domain_id,)) + conn.commit() + + logger.info(f"โœ… Successfully unlinked domain {domain['full_domain']} from server") + return True + + except Exception as e: + logger.error(f"Error unlinking domain: {e}", exc_info=True) + return False diff --git a/email_service.py b/email_service.py new file mode 100644 index 0000000..dbd9c6c --- /dev/null +++ b/email_service.py @@ -0,0 +1,513 @@ +from datetime import datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +import logging +import os +import smtplib + + + + +logger = logging.getLogger(__name__) + +class EmailService: + def __init__(self): + # Load SMTP configuration from environment + self.smtp_host = os.environ.get('SMTP_HOST', '') + self.smtp_port = int(os.environ.get('SMTP_PORT', 587)) + self.smtp_user = os.environ.get('SMTP_USER', '') + self.smtp_password = os.environ.get('SMTP_PASSWORD', '') + self.smtp_from = os.environ.get('SMTP_FROM', '') + self.smtp_from_name = os.environ.get('SMTP_FROM_NAME', 'AnchorHost') + + # TLS configuration - IMPORTANT! + use_tls_str = os.environ.get('SMTP_USE_TLS', '').lower() + # Default to True for port 587, False for other ports + if use_tls_str: + self.use_tls = use_tls_str in ('true', '1', 'yes', 'on') + else: + # Default: Use TLS on port 587, not on other ports + self.use_tls = (self.smtp_port == 587) + + # Base URL for verification links + self.base_url = os.environ.get('BASE_URL', 'http://localhost:8080') + + # Validate configuration + if not self.smtp_password: + logger.warning("โš ๏ธ SMTP_PASSWORD not set - email sending will fail!") + + logger.info(f"Email Service initialized") + logger.info(f"SMTP Host: {self.smtp_host}:{self.smtp_port}") + logger.info(f"SMTP User: {self.smtp_user}") + logger.info(f"From: {self.smtp_from_name} <{self.smtp_from}>") + logger.info(f"Use TLS: {self.use_tls}") + + def send_email(self, to_email: str, subject: str, html_body: str, text_body: str = None) -> bool: + """ + Send an email using SMTP + + Args: + to_email: Recipient email address + subject: Email subject + html_body: HTML content of the email + text_body: Plain text fallback (optional) + + Returns: + bool: True if email was sent successfully + """ + if not all((self.smtp_host, self.smtp_user, self.smtp_password, self.smtp_from)): + logger.warning("Email not sent because SMTP settings are incomplete") + return False + + try: + # Create message + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = f"{self.smtp_from_name} <{self.smtp_from}>" + msg['To'] = to_email + + # Add text part (fallback) + if text_body: + part1 = MIMEText(text_body, 'plain') + msg.attach(part1) + + # Add HTML part + part2 = MIMEText(html_body, 'html') + msg.attach(part2) + + # Connect and send + logger.info(f"Connecting to SMTP server: {self.smtp_host}:{self.smtp_port}") + + with smtplib.SMTP(self.smtp_host, self.smtp_port) as server: + server.ehlo() + + # Check if TLS should be used (ONLY if explicitly enabled) + # Default behavior: Use TLS on port 587 UNLESS SMTP_USE_TLS=false + use_tls = self.smtp_port == 587 and self.use_tls + + if use_tls: + logger.info("Starting TLS...") + try: + server.starttls() + server.ehlo() + except smtplib.SMTPNotSupportedError: + logger.warning("TLS not supported by server, continuing without encryption") + else: + logger.info("Skipping TLS (not enabled or not port 587)") + + # Login + logger.info(f"Logging in as: {self.smtp_user}") + server.login(self.smtp_user, self.smtp_password) + + # Send email + logger.info(f"Sending email to: {to_email}") + server.send_message(msg) + + logger.info(f"โœ… Email sent successfully to {to_email}") + return True + + except smtplib.SMTPAuthenticationError as e: + logger.error(f"โŒ SMTP Authentication failed: {e}") + return False + except smtplib.SMTPException as e: + logger.error(f"โŒ SMTP error: {e}") + return False + except Exception as e: + logger.error(f"โŒ Error sending email: {e}", exc_info=True) + return False + + def send_verification_email(self, to_email: str, username: str, token: str) -> bool: + """ + Send email verification email + + Args: + to_email: User's email address + username: Username + token: Verification token + + Returns: + bool: True if email was sent successfully + """ + verification_url = f"{self.base_url}/verify-email?token={token}" + + subject = "Verify your AnchorHost account" + + # HTML email template + html_body = f""" + + + + + + Verify Your Email + + + + + + +
+ + + + + + + + + + + + + + + +
+

+ AnchorHost +

+
+

+ Welcome to AnchorHost, {username}! +

+ +

+ Thank you for registering with AnchorHost. To complete your registration and start using our services, please verify your email address. +

+ +

+ Click the button below to verify your email: +

+ + + + + + +
+ + Verify Email Address + +
+ +

+ If the button doesn't work, copy and paste this link into your browser: +

+ +

+ {verification_url} +

+ +

+ Important: This verification link will expire in 24 hours. +

+ +

+ If you didn't create an account with AnchorHost, please ignore this email. +

+
+

+ ยฉ 2024 AnchorHost. All rights reserved. +

+

+ AnchorHost.eu | + Support +

+
+
+ + +""" + + # Plain text fallback + text_body = f""" +Welcome to AnchorHost, {username}! + +Thank you for registering. Please verify your email address by clicking the link below: + +{verification_url} + +This link will expire in 24 hours. + +If you didn't create an account with AnchorHost, please ignore this email. + +--- +AnchorHost.eu +""" + + return self.send_email(to_email, subject, html_body, text_body) + + def send_password_reset_email(self, to_email: str, username: str, token: str) -> bool: + """ + Send password reset email + + Args: + to_email: User's email address + username: Username + token: Reset token + + Returns: + bool: True if email was sent successfully + """ + reset_url = f"{self.base_url}/reset-password?token={token}" + + subject = "Reset your AnchorHost password" + + html_body = f""" + + + + + + Reset Your Password + + + + + + +
+ + + + + + + + + + + + +
+

+ Password Reset +

+
+

+ Reset your password, {username} +

+ +

+ We received a request to reset your password. Click the button below to create a new password: +

+ + + + + +
+ + Reset Password + +
+ +

+ Or copy this link: {reset_url} +

+ +

+ This link expires in 1 hour. If you didn't request this, please ignore this email. +

+
+

+ ยฉ 2024 AnchorHost. All rights reserved. +

+
+
+ + +""" + + text_body = f""" +Reset your password, {username} + +We received a request to reset your password. Click the link below: + +{reset_url} + +This link expires in 1 hour. If you didn't request this, ignore this email. + +--- +AnchorHost.eu +""" + + return self.send_email(to_email, subject, html_body, text_body) + + def send_welcome_email(self, to_email: str, username: str) -> bool: + """Send welcome email after successful verification""" + subject = "Welcome to AnchorHost!" + + html_body = f""" + + + + + + + + + + + +
+ + + + + + + + + + + + +
+

๐ŸŽ‰ Welcome to AnchorHost!

+
+

+ You're all set, {username}! +

+ +

+ Your email has been verified and your account is now active. You can start deploying servers right away! +

+ + + + + +
+ + Go to Dashboard + +
+
+

+ ยฉ 2024 AnchorHost. All rights reserved. +

+
+
+ + +""" + + return self.send_email(to_email, subject, html_body) + + def send_monthly_fee_email(self, to_email: str, username: str, server_name: str, amount: int, new_balance: int) -> bool: + """Send monthly fee confirmation email""" + try: + subject = f"AnchorHost - Monthly Fee for {server_name}" + + # Convert credits to the displayed euro amount. + amount_euro = amount / 100 + + body = f""" + + +
+

Monthly Server Fee

+

Hello {username},

+

Your monthly fee for server {server_name} has been processed.

+ +
+

Payment Details:

+

Server: {server_name}

+

Amount: {amount} credits ({amount_euro:.2f} EUR)

+

Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

+

New Balance: {new_balance} credits

+
+ +

Your server will remain active as long as you maintain sufficient credits.

+

You can add more credits at any time from your dashboard.

+ +
+ +

+ If you have any questions, please contact our support team. +

+
+ + + """ + + return self.send_email(to_email, subject, body) + + except Exception as e: + logger.error(f"Error sending monthly fee email: {e}") + return False + + def send_server_suspended_email(self, to_email: str, username: str, server_name: str, required_amount: int, current_balance: int) -> bool: + """Send server suspension email due to insufficient credits""" + try: + subject = f"AnchorHost - Server {server_name} Suspended" + + required_euro = required_amount / 100 + + body = f""" + + +
+

Server Suspended

+

Hello {username},

+

Your server {server_name} has been suspended due to insufficient credits.

+ +
+

Important Notice:

+

Required: {required_amount} credits ({required_euro:.2f} EUR)

+

Current Balance: {current_balance} credits

+

Missing: {required_amount - current_balance} credits

+
+ +

To reactivate your server:

+
    +
  1. Log in to your AnchorHost account
  2. +
  3. Add credits to your account (minimum {required_amount - current_balance} credits)
  4. +
  5. Start your server from the dashboard
  6. +
+ +
+ + Add Credits Now + +
+ +

Note: Your server data is preserved for 30 days. After this period, it may be permanently deleted.

+ +
+ +

+ If you believe this is an error, please contact our support team immediately. +

+
+ + + """ + + return self.send_email(to_email, subject, body) + + except Exception as e: + logger.error(f"Error sending suspension email: {e}") + return False + +# Initialize global email service instance +email_service = None + +def init_email_service(): + """Initialize the email service""" + global email_service + try: + email_service = EmailService() + logger.info("โœ… Email service initialized") + return email_service + except Exception as e: + logger.error(f"โŒ Failed to initialize email service: {e}") + return None + +def get_email_service(): + """Get the email service instance""" + global email_service + if email_service is None: + return init_email_service() + return email_service diff --git a/kubernetes_service.py b/kubernetes_service.py new file mode 100644 index 0000000..9892c55 --- /dev/null +++ b/kubernetes_service.py @@ -0,0 +1,990 @@ +"""Kubernetes client initialization and cluster operations.""" +from datetime import datetime +from kubernetes import client, config +from kubernetes.client.rest import ApiException +from kubernetes.stream import stream +import os +import random +from runtime_config import ( + logger, +) +import time +from typing import Dict, List + + + +def initialize_kubernetes(): + """Initialize Kubernetes client with robust configuration""" + try: + config.load_incluster_config() + logger.info("โœ“ Loaded in-cluster Kubernetes config") + return True + except config.ConfigException: + try: + config.load_kube_config() + logger.info("โœ“ Loaded Kubernetes config from kubeconfig file") + return True + except config.ConfigException as e: + logger.warning(f"โš  Could not configure kubernetes client: {e}") + logger.warning("โš  Continuing in development mode without Kubernetes") + return False + +k8s_connected = initialize_kubernetes() +if k8s_connected: + v1 = client.CoreV1Api() + apps_v1 = client.AppsV1Api() + networking_v1 = client.NetworkingV1Api() + custom_objects_api = client.CustomObjectsApi() +else: + v1 = None + apps_v1 = None + networking_v1 = None + custom_objects_api = None + + +class KubernetesManager: + def __init__(self): + self.connected = k8s_connected + self.last_error = None + + def test_connection(self): + if not self.connected: + return False + try: + namespaces = v1.list_namespace() + self.last_error = None + namespace_names = [ns.metadata.name for ns in namespaces.items] + logger.info(f"โœ“ Kubernetes connection successful! Available namespaces: {namespace_names}") + return True + except ApiException as e: + self.last_error = e + logger.error(f"โœ— Kubernetes connection failed: {e.reason}") + return False + + def create_namespace(self, namespace_name: str, labels: Dict = None) -> bool: + # Kubernetes namespaces MUST be lowercase + namespace_name = namespace_name.lower() + + if not self.connected: + logger.info(f"โš  Kubernetes not connected, skipping namespace creation for {namespace_name}") + return True + try: + v1.read_namespace(name=namespace_name) + logger.info(f"Namespace {namespace_name} already exists") + return True + except ApiException as e: + if e.status == 404: + namespace_body = client.V1Namespace( + metadata=client.V1ObjectMeta( + name=namespace_name, + labels=labels or { + 'created-by': 'anchorhost', + 'user': namespace_name.replace('user-', '') + } + ) + ) + try: + v1.create_namespace(body=namespace_body) + logger.info(f"โœ“ Created namespace {namespace_name}") + return True + except ApiException as create_error: + logger.error(f"โœ— Failed to create namespace {namespace_name}: {create_error.reason}") + return False + else: + logger.error(f"โœ— Error checking namespace {namespace_name}: {e.reason}") + return False + + def create_pvc(self, namespace: str, pvc_name: str, storage_size: str, storage_class: str = 'longhorn') -> bool: + """Create a Persistent Volume Claim""" + if not self.connected: + logger.info(f"โš  Kubernetes not connected, skipping PVC creation for {pvc_name}") + return True + + try: + # Check if PVC already exists + try: + existing_pvc = v1.read_namespaced_persistent_volume_claim(name=pvc_name, namespace=namespace) + logger.info(f"PVC {pvc_name} already exists") + return True + except ApiException as e: + if e.status != 404: + logger.error(f"โœ— Error checking PVC {pvc_name}: {e.reason}") + return False + + # PVC doesn't exist, create it + pvc_body = client.V1PersistentVolumeClaim( + metadata=client.V1ObjectMeta(name=pvc_name), + spec=client.V1PersistentVolumeClaimSpec( + access_modes=["ReadWriteOnce"], + resources=client.V1ResourceRequirements( + requests={"storage": storage_size} + ), + storage_class_name=storage_class + ) + ) + v1.create_namespaced_persistent_volume_claim(namespace=namespace, body=pvc_body) + logger.info(f"โœ“ Created PVC {pvc_name} with {storage_size} storage") + return True + except ApiException as e: + logger.error(f"โœ— Failed to create PVC {pvc_name}: {e.reason}") + if e.status == 400: + logger.error(f" Bad Request details: {e.body}") + return False + + def get_vm_status(self, namespace: str, vm_name: str) -> Dict: + """Get VirtualMachine status with detailed information""" + if not self.connected: + return {'phase': 'Running', 'ready': True, 'conditions': []} + + try: + # Get VirtualMachine + vm = custom_objects_api.get_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + name=vm_name + ) + + # Get VirtualMachineInstance if it exists + try: + vmi = custom_objects_api.get_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachineinstances", + name=vm_name + ) + + status = vmi.get('status', {}) + phase = status.get('phase', 'Unknown') + conditions = status.get('conditions', []) + + # Check if VM is ready + ready_condition = next((c for c in conditions if c.get('type') == 'Ready'), None) + ready = ready_condition.get('status', 'False') == 'True' if ready_condition else False + + # Also get runStrategy from VM spec + vm_spec = vm.get('spec', {}) + run_strategy = vm_spec.get('runStrategy', 'Always') + + return { + 'phase': phase, + 'ready': ready, + 'conditions': conditions, + 'interfaces': status.get('interfaces', []), + 'nodeName': status.get('nodeName'), + 'runStrategy': run_strategy + } + + except ApiException as e: + if e.status == 404: + # VMI doesn't exist yet, check VM status and runStrategy + vm_spec = vm.get('spec', {}) + run_strategy = vm_spec.get('runStrategy', 'Always') + vm_status = vm.get('status', {}) + ready = vm_status.get('ready', False) + + # If runStrategy is 'Halted', the VM is stopped + if run_strategy == 'Halted': + return { + 'phase': 'Stopped', + 'ready': False, + 'conditions': vm_status.get('conditions', []), + 'runStrategy': run_strategy + } + + return { + 'phase': 'Pending' if not ready else 'Scheduling', + 'ready': ready, + 'conditions': vm_status.get('conditions', []), + 'runStrategy': run_strategy + } + raise e + + except Exception as e: + logger.error(f"Error getting VM status for {vm_name}: {e}") + return {'phase': 'Unknown', 'ready': False, 'conditions': [], 'error': str(e)} + + def get_service_ports(self, namespace: str, service_name: str) -> List[Dict]: + """Get service ports with node port information""" + if not self.connected: + return [] + + try: + service = v1.read_namespaced_service(name=service_name, namespace=namespace) + ports = [] + + for port in service.spec.ports: + ports.append({ + 'name': port.name, + 'protocol': port.protocol, + 'port': port.port, + 'target_port': port.target_port, + 'node_port': port.node_port + }) + + return ports + except Exception as e: + logger.error(f"Error getting service ports for {service_name}: {e}") + return [] + + def wait_for_vm_ready(self, namespace: str, vm_name: str, timeout: int = 600) -> bool: + """Wait for VM to be ready with timeout""" + if not self.connected: + return True + + logger.info(f"โณ Waiting for VM {vm_name} to be ready (timeout: {timeout}s)") + + start_time = time.time() + while time.time() - start_time < timeout: + try: + status = self.get_vm_status(namespace, vm_name) + + if status.get('ready') and status.get('phase') == 'Running': + logger.info(f"โœ… VM {vm_name} is ready and running") + return True + + # Log progress every 30 seconds + if int(time.time() - start_time) % 30 == 0: + logger.info(f"โณ VM {vm_name} status: {status.get('phase')}, ready: {status.get('ready')}") + + time.sleep(5) + + except Exception as e: + logger.error(f"Error checking VM status: {e}") + time.sleep(10) + + logger.warning(f"โš  VM {vm_name} not ready within {timeout} seconds") + return False + + def get_pod_logs(self, namespace: str, pod_name: str, tail_lines: int = 100, container_name: str = None) -> str: + """Get logs from a pod""" + if not self.connected: + return f"Mock logs for pod {pod_name} in namespace {namespace}" + + try: + logs = v1.read_namespaced_pod_log( + name=pod_name, + namespace=namespace, + container=container_name, + tail_lines=tail_lines + ) + return logs + except ApiException as e: + return f"Error fetching logs: {e.reason}" + + def attach_and_send_command(self, namespace: str, pod_name: str, command: str, container_name: str = None) -> str: + """Attach to pod and send command to stdin using RCON or direct stdin injection""" + if not self.connected: + return f"Mock: Command '{command}' sent to game server" + + try: + # For game servers, we need to use the appropriate method to send commands + # Most game servers use RCON or a control file/pipe + + # Method 1: Try to execute directly with rcon-cli if available + try: + resp = stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + namespace, + command=["sh", "-c", f"rcon-cli {command} 2>&1 || echo 'Command executed: {command}'"], + container=container_name, + stderr=True, + stdin=False, + stdout=True, + tty=False + ) + + logger.info(f"RCON command result: {resp}") + return resp + except Exception as rcon_error: + logger.info(f"RCON not available, trying alternative method: {rcon_error}") + + # Method 2: For Minecraft specifically, write to console input file if it exists + try: + resp = stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + namespace, + command=["sh", "-c", f"echo '{command}' >> /data/console.input 2>&1 || echo 'Command queued: {command}'"], + container=container_name, + stderr=True, + stdin=False, + stdout=True, + tty=False + ) + logger.info(f"Console file write result: {resp}") + return resp + except Exception as file_error: + logger.info(f"Console file method failed: {file_error}") + + # Method 3: Try to find and write to the main process stdin using tmux/screen if available + try: + resp = stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + namespace, + command=["sh", "-c", f"tmux send-keys -t 0 '{command}' Enter 2>&1 || screen -S minecraft -X stuff '{command}^M' 2>&1 || echo 'Command: {command}'"], + container=container_name, + stderr=True, + stdin=False, + stdout=True, + tty=False + ) + logger.info(f"Terminal multiplexer result: {resp}") + return resp + except Exception as tmux_error: + logger.warning(f"Terminal multiplexer method failed: {tmux_error}") + + # Fallback: Just return success message + return f"Command sent to game server: {command}\nNote: Command delivery confirmation not available. Check server logs." + + except Exception as e: + logger.error(f"Error sending command to pod: {e}") + return f"Error: {str(e)}" + + def get_pod_metrics(self, namespace: str, pod_name: str, server_ram_gb: int = 1, server_cpu_cores: int = 1) -> Dict: + """Get CPU and memory usage for a pod""" + # Fallback values when k8s is not connected or metrics unavailable + def get_fallback_metrics(): + cpu_millicores = random.randint(50, 300) + memory_mebibytes = random.randint(128, 1024) + max_cpu_millicores = server_cpu_cores * 1000 + cpu_percentage = min((cpu_millicores / max_cpu_millicores) * 100, 100) if max_cpu_millicores > 0 else 0 + memory_percentage = min((memory_mebibytes / (server_ram_gb * 1024)) * 100, 100) + + return { + 'cpu': f'{cpu_millicores}m', + 'memory': f'{memory_mebibytes}Mi', + 'cpu_percentage': round(cpu_percentage, 1), + 'memory_percentage': round(memory_percentage, 1), + 'timestamp': datetime.now().isoformat() + } + + if not self.connected: + return get_fallback_metrics() + + try: + api = client.CustomObjectsApi() + metrics = api.get_namespaced_custom_object( + group="metrics.k8s.io", + version="v1beta1", + namespace=namespace, + plural="pods", + name=pod_name + ) + + containers = metrics.get('containers', []) + if not containers: + return get_fallback_metrics() + + total_cpu_millicores = 0 + total_memory_mebibytes = 0 + max_cpu_millicores = server_cpu_cores * 1000 + + # Accumulate CPU and memory across all containers + for container in containers: + usage = container.get('usage', {}) + + # Parse CPU - Kubernetes returns CPU in various formats: "123456789n" (nanoseconds), "123m" (millicores), "1" (cores) + if 'cpu' in usage: + cpu_str = str(usage['cpu']).strip() + cpu_millicores = 0 + + if cpu_str.endswith('n'): + # Nanoseconds: convert to millicores (1 millicore = 1/1000 core = 1000000 nanoseconds) + try: + cpu_nanoseconds = int(cpu_str[:-1]) + cpu_millicores = int(cpu_nanoseconds / 1000000) + except (ValueError, IndexError): + pass + elif cpu_str.endswith('m'): + # Already in millicores + try: + cpu_millicores = int(cpu_str[:-1]) + except (ValueError, IndexError): + pass + elif cpu_str.endswith('u'): + # Microcores: convert to millicores (1 millicore = 1000 microcores) + try: + cpu_microcores = int(cpu_str[:-1]) + cpu_millicores = int(cpu_microcores / 1000) + except (ValueError, IndexError): + pass + else: + # Try to parse as cores or raw number + try: + cpu_cores = float(cpu_str) + cpu_millicores = int(cpu_cores * 1000) + except ValueError: + pass + + total_cpu_millicores += cpu_millicores + + # Parse memory - Kubernetes returns memory as "123Ki", "123Mi", "123Gi", or bytes + if 'memory' in usage: + memory_str = str(usage['memory']).strip() + memory_mebibytes = 0 + + if memory_str.endswith('Ki'): + try: + memory_kib = int(memory_str[:-2]) + memory_mebibytes = memory_kib / 1024 + except (ValueError, IndexError): + pass + elif memory_str.endswith('Mi'): + try: + memory_mebibytes = int(memory_str[:-2]) + except (ValueError, IndexError): + pass + elif memory_str.endswith('Gi'): + try: + memory_gib = float(memory_str[:-2]) + memory_mebibytes = memory_gib * 1024 + except (ValueError, IndexError): + pass + else: + # Try to parse as bytes + try: + memory_bytes = int(memory_str) + memory_mebibytes = memory_bytes / (1024 * 1024) + except ValueError: + pass + + total_memory_mebibytes += memory_mebibytes + + # If we couldn't parse any metrics, use fallback + if total_cpu_millicores == 0 and total_memory_mebibytes == 0: + return get_fallback_metrics() + + cpu_percentage = min((total_cpu_millicores / max_cpu_millicores) * 100, 100) if max_cpu_millicores > 0 else 0 + memory_percentage = min((total_memory_mebibytes / (server_ram_gb * 1024)) * 100, 100) if server_ram_gb > 0 else 0 + + return { + 'cpu': f'{int(total_cpu_millicores)}m', + 'memory': f'{int(total_memory_mebibytes)}Mi', + 'cpu_percentage': round(cpu_percentage, 1), + 'memory_percentage': round(memory_percentage, 1), + 'timestamp': datetime.now().isoformat() + } + except Exception as e: + logger.warning(f"Failed to get pod metrics for {pod_name}: {e}") + return get_fallback_metrics() + + def get_pods_for_server(self, namespace: str, server_id: str) -> List[str]: + """Get all pods for a server""" + if not self.connected: + return [f"{server_id}-pod"] + + try: + selectors = [ + f"kubevirt.io/domain={server_id}", + f"app={server_id}", + f"server-id={server_id}" + ] + + for selector in selectors: + try: + pods = v1.list_namespaced_pod( + namespace=namespace, + label_selector=selector + ) + if pods.items: + return [pod.metadata.name for pod in pods.items] + except Exception: + continue + + pods = v1.list_namespaced_pod(namespace=namespace) + return [pod.metadata.name for pod in pods.items if server_id in pod.metadata.name] + + except Exception as e: + logger.error(f"Error getting pods for server {server_id}: {e}") + return [] + + def exec_command(self, namespace: str, pod_name: str, command: List[str], container_name: str = None) -> str: + """Execute command in pod""" + if not self.connected: + if command and 'pwd' in command: + return "/data\n" + return f"Mock command execution: {' '.join(command)}\n" + + try: + resp = stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + namespace, + command=command, + container=container_name, + stderr=True, + stdin=False, + stdout=True, + tty=False + ) + return resp + except Exception as e: + return f"Error executing command: {str(e)}" + + def exec_command_with_stdin(self, namespace: str, pod_name: str, command: List[str], stdin_data: str, container_name: str = None) -> str: + """Execute command in pod with stdin input - used for large file uploads to avoid command-line limits""" + if not self.connected: + return f"Mock command execution with stdin: {' '.join(command)}\n" + + try: + resp = stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + namespace, + command=command, + container=container_name, + stderr=True, + stdin=True, + stdout=True, + tty=False, + _preload_content=False + ) + # Write stdin data in chunks to avoid memory issues + chunk_size = 8192 # 8KB chunks + data_bytes = stdin_data.encode('utf-8') if isinstance(stdin_data, str) else stdin_data + + for i in range(0, len(data_bytes), chunk_size): + chunk = data_bytes[i:i + chunk_size] + resp.write_stdin(chunk) + + # Close stdin by closing the stream or sending EOF if supported + # WSClient doesn't have close_stdin(), so we use close() or just wait + # if the command is expected to finish (like head -c) + try: + # Some versions might support sending EOF character + resp.write_stdin('\x04') + except: + pass + + # Read response + output = "" + max_wait_time = 300 # 5 minutes max wait for large files + start_time = time.time() + + while resp.is_open() and (time.time() - start_time) < max_wait_time: + resp.update(timeout=1) + if resp.peek_stdout(): + output += resp.read_stdout(timeout=1) + if resp.peek_stderr(): + error = resp.read_stderr(timeout=1) + if error: + output += error + + resp.close() + + if (time.time() - start_time) >= max_wait_time: + logger.warning(f"Command with stdin timed out after {max_wait_time}s") + + return output + except Exception as e: + logger.error(f"Error executing command with stdin: {e}", exc_info=True) + return f"Error executing command: {str(e)}" + + def list_files(self, namespace: str, pod_name: str, path: str = "/", container_name: str = None) -> List[Dict]: + """List files and directories in a path - IMPROVED VERSION""" + if not self.connected: + # Return mock data for development + mock_files = [ + {"name": "eula.txt", "type": "file", "size": 1024, "modified": "2024-01-01T00:00:00Z"}, + {"name": "server.properties", "type": "file", "size": 2048, "modified": "2024-01-01T00:00:00Z"}, + {"name": "logs", "type": "directory", "size": 4096, "modified": "2024-01-01T00:00:00Z"}, + {"name": "world", "type": "directory", "size": 4096, "modified": "2024-01-01T00:00:00Z"} + ] + return mock_files + + try: + # Use ls command for reliable file listing + cmd = ["ls", "-la", path] + result = self.exec_command(namespace, pod_name, cmd, container_name=container_name) + logger.info(f"LS result for {path}: {result}") + + files = [] + lines = result.strip().split('\n') + + for line in lines: + # Skip the "total X" line and empty lines + if line.startswith('total ') or not line.strip(): + continue + + try: + parts = line.split() + if len(parts) < 9: + continue + + permissions = parts[0] + name = ' '.join(parts[8:]) + + if name in ['.', '..']: + continue + + file_type = "directory" if permissions.startswith('d') else "file" + + # Get file size from ls output (field 5) + size = 0 + try: + size = int(parts[4]) + except (ValueError, IndexError): + pass + + # Parse modification time (fields 5-8) + try: + # Format: "Jan 01 12:00" or "Jan 01 2023" + date_parts = parts[5:8] + if len(date_parts) == 3: + month, day, time_or_year = date_parts + if ':' in time_or_year: + # Current year, use current year + year = datetime.now().year + modified_str = f"{month} {day} {year} {time_or_year}" + modified = datetime.strptime(modified_str, "%b %d %Y %H:%M") + else: + # Different year + year = time_or_year + modified_str = f"{month} {day} {year}" + modified = datetime.strptime(modified_str, "%b %d %Y") + else: + modified = datetime.now() + except Exception as e: + logger.warning(f"Error parsing date: {e}") + modified = datetime.now() + + files.append({ + "name": name, + "type": file_type, + "size": size, + "modified": modified.isoformat() + }) + + except (ValueError, IndexError) as e: + logger.warning(f"Error parsing file entry: {line}, error: {e}") + continue + + return files + + except Exception as e: + logger.error(f"Error listing files in {path}: {e}") + return [] + + def read_file(self, namespace: str, pod_name: str, file_path: str, container_name: str = None) -> str: + """Read file content from pod""" + if not self.connected: + return "Mock file content for development" + + try: + cmd = ["cat", file_path] + result = self.exec_command(namespace, pod_name, cmd, container_name=container_name) + return result + except Exception as e: + logger.error(f"Error reading file {file_path}: {e}") + return "" + + def read_file_base64(self, namespace: str, pod_name: str, file_path: str, container_name: str = None) -> str: + """Read file content from pod as base64 encoded string""" + if not self.connected: + return "" + + try: + import base64 + cmd = ["base64", "-w", "0", file_path] + result = self.exec_command(namespace, pod_name, cmd, container_name=container_name) + return result.strip() + except Exception as e: + logger.error(f"Error reading file as base64 {file_path}: {e}") + return "" + + def write_file(self, namespace: str, pod_name: str, file_path: str, content: str, container_name: str = None) -> bool: + """Write content to file in pod using base64 for reliability""" + if not self.connected: + return True + + try: + import base64 + # Encode content to base64 + b64_content = base64.b64encode(content.encode('utf-8')).decode('utf-8') + + # Use base64 -d to decode on the pod side + # This is much more reliable than heredocs for content with special characters + cmd = ["sh", "-c", f"echo '{b64_content}' | base64 -d > '{file_path}'"] + self.exec_command(namespace, pod_name, cmd, container_name=container_name) + return True + except Exception as e: + logger.error(f"Error writing file {file_path}: {e}") + return False + def create_directory(self, namespace: str, pod_name: str, dir_path: str, container_name: str = None) -> bool: + """Create directory in pod""" + if not self.connected: + return True + + try: + cmd = ["mkdir", "-p", dir_path] + self.exec_command(namespace, pod_name, cmd, container_name=container_name) + return True + except Exception as e: + logger.error(f"Error creating directory {dir_path}: {e}") + return False + + def delete_path(self, namespace: str, pod_name: str, path: str, container_name: str = None) -> bool: + """Delete file or directory in pod""" + if not self.connected: + return True + + try: + cmd = ["rm", "-rf", path] + self.exec_command(namespace, pod_name, cmd, container_name=container_name) + return True + except Exception as e: + logger.error(f"Error deleting path {path}: {e}") + return False + + def move_path(self, namespace: str, pod_name: str, src_path: str, dest_path: str, container_name: str = None) -> bool: + """Move file or directory in pod""" + if not self.connected: + return True + try: + cmd = ["mv", src_path, dest_path] + self.exec_command(namespace, pod_name, cmd, container_name=container_name) + return True + except Exception as e: + logger.error(f"Error moving path {src_path} to {dest_path}: {e}") + return False + + def copy_path(self, namespace: str, pod_name: str, src_path: str, dest_path: str, container_name: str = None) -> bool: + """Copy file or directory in pod""" + if not self.connected: + return True + try: + cmd = ["cp", "-r", src_path, dest_path] + self.exec_command(namespace, pod_name, cmd, container_name=container_name) + return True + except Exception as e: + logger.error(f"Error copying path {src_path} to {dest_path}: {e}") + return False + + def upload_file(self, namespace: str, pod_name: str, file_path: str, file_content: str, is_base64: bool = True) -> bool: + """Upload file to pod""" + if not self.connected: + return True + + try: + if is_base64: + # Decode base64 content + import base64 + content = base64.b64decode(file_content).decode('utf-8', errors='ignore') + else: + content = file_content + + return self.write_file(namespace, pod_name, file_path, content) + except Exception as e: + logger.error(f"Error uploading file {file_path}: {e}") + return False + + def exec_command_with_file_stdin(self, namespace: str, pod_name: str, command: List[str], file_path: str, base64_encode: bool = True, container_name: str = None) -> str: + """Execute command in pod with stdin input from a file - streams file in chunks to avoid memory issues + Used for large file uploads to avoid loading entire file into memory""" + if not self.connected: + return f"Mock command execution with file stdin: {' '.join(command)}\n" + + try: + resp = stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + namespace, + command=command, + container=container_name, + stderr=True, + stdin=True, + stdout=True, + tty=False, + _preload_content=False + ) + # Stream file in chunks to avoid memory issues + # IMPORTANT: chunk_size must be a multiple of 3 for base64 encoding to work correctly across chunks + chunk_size = 48 * 1024 # 48KB chunks (multiple of 3) + base64_chunk_size = 8192 # 8KB chunks for base64 string writing + + with open(file_path, 'rb') as f: + if base64_encode: + # Read binary file, encode to base64, and stream to stdin + import base64 + encoder = base64.b64encode + + while True: + # Read binary chunk + binary_chunk = f.read(chunk_size) + if not binary_chunk: + break + + # Encode to base64 + base64_chunk = encoder(binary_chunk).decode('utf-8') + + # Write base64 string in smaller chunks to stdin + base64_bytes = base64_chunk.encode('utf-8') + for i in range(0, len(base64_bytes), base64_chunk_size): + chunk = base64_bytes[i:i + base64_chunk_size] + resp.write_stdin(chunk) + else: + # Stream binary directly + while True: + chunk = f.read(chunk_size) + if not chunk: + break + resp.write_stdin(chunk) + + # Close stdin by sending EOF if supported + try: + resp.write_stdin('\x04') + except: + pass + + # Read response + output = "" + max_wait_time = 600 # 10 minutes max wait for very large files + start_time = time.time() + + while resp.is_open() and (time.time() - start_time) < max_wait_time: + resp.update(timeout=1) + if resp.peek_stdout(): + output += resp.read_stdout(timeout=1) + if resp.peek_stderr(): + error = resp.read_stderr(timeout=1) + if error: + output += error + + resp.close() + + if (time.time() - start_time) >= max_wait_time: + logger.warning(f"Command with file stdin timed out after {max_wait_time}s") + + return output + except Exception as e: + logger.error(f"Error executing command with file stdin: {e}", exc_info=True) + return f"Error executing command: {str(e)}" + + def write_file_binary(self, namespace: str, pod_name: str, file_path: str, base64_content: str, container_name: str = None) -> bool: + """Write binary file to pod using base64 decode on pod side (for large files) + Uses stdin to avoid command-line length limits for large files""" + if not self.connected: + return True + + try: + # Ensure directory exists + dir_path = '/'.join(file_path.split('/')[:-1]) if '/' in file_path else '.' + if dir_path and dir_path != '.': + self.create_directory(namespace, pod_name, dir_path, container_name=container_name) + + escaped_path = file_path.replace("'", "'\\''") + temp_file = f"/tmp/upload_{int(time.time() * 1000000)}.b64" + escaped_temp = temp_file.replace("'", "'\\''") + + # Use stdin to write base64 data to avoid command-line length limits + # Using head -c ensures the command terminates after reading the expected size + b64_data = base64_content.encode('utf-8') if isinstance(base64_content, str) else base64_content + content_len = len(b64_data) + write_base64_cmd = ["sh", "-c", f"head -c {content_len} > '{escaped_temp}'"] + write_result = self.exec_command_with_stdin(namespace, pod_name, write_base64_cmd, base64_content, container_name=container_name) + + # Check if write succeeded by verifying temp file exists + check_temp_cmd = ["sh", "-c", f"test -f '{escaped_temp}' && echo 'temp_exists' || echo 'temp_missing'"] + temp_check_result = self.exec_command(namespace, pod_name, check_temp_cmd, container_name=container_name) + if 'temp_exists' not in temp_check_result: + logger.error(f"Failed to write temp base64 file: {write_result}") + return False + + # Decode base64 file to final location with error checking + decode_cmd = ["sh", "-c", f"base64 -d < '{escaped_temp}' > '{escaped_path}' 2>&1 && rm -f '{escaped_temp}' && echo 'decode_success' || (rm -f '{escaped_temp}' && echo 'decode_failed')"] + decode_result = self.exec_command(namespace, pod_name, decode_cmd, container_name=container_name) + + if 'decode_failed' in decode_result: + logger.error(f"Failed to decode base64 file: {decode_result}") + return False + + # Verify file was created and has content + check_cmd = ["sh", "-c", f"test -f '{escaped_path}' && test -s '{escaped_path}' && echo 'exists' || echo 'missing'"] + result = self.exec_command(namespace, pod_name, check_cmd, container_name=container_name) + + if 'exists' not in result: + logger.error(f"File verification failed: {result}") + return False + + return True + except Exception as e: + logger.error(f"Error writing binary file {file_path}: {e}") + import traceback + logger.error(traceback.format_exc()) + return False + + def write_file_binary_from_disk(self, namespace: str, pod_name: str, file_path: str, local_file_path: str, container_name: str = None) -> bool: + """Write binary file to pod from a local file on disk - streams file to avoid memory issues + This is the preferred method for large files as it doesn't load the entire file into memory""" + if not self.connected: + return True + + try: + # Ensure directory exists + dir_path = '/'.join(file_path.split('/')[:-1]) if '/' in file_path else '.' + if dir_path and dir_path != '.': + self.create_directory(namespace, pod_name, dir_path, container_name=container_name) + + escaped_path = file_path.replace("'", "'\\''") + temp_file = f"/tmp/upload_{int(time.time() * 1000000)}.b64" + escaped_temp = temp_file.replace("'", "'\\''") + + # Stream file from disk, base64 encode, and write to pod temp file + # Calculate expected base64 size to use head -c for automatic termination + import math + local_size = os.path.getsize(local_file_path) + b64_size = math.ceil(local_size / 3) * 4 + + write_base64_cmd = ["sh", "-c", f"head -c {b64_size} > '{escaped_temp}'"] + write_result = self.exec_command_with_file_stdin(namespace, pod_name, write_base64_cmd, local_file_path, base64_encode=True, container_name=container_name) + + # Check if write succeeded by verifying temp file exists + check_temp_cmd = ["sh", "-c", f"test -f '{escaped_temp}' && echo 'temp_exists' || echo 'temp_missing'"] + temp_check_result = self.exec_command(namespace, pod_name, check_temp_cmd, container_name=container_name) + if 'temp_exists' not in temp_check_result: + logger.error(f"Failed to write temp base64 file: {write_result}") + return False + + # Decode base64 file to final location with error checking + decode_cmd = ["sh", "-c", f"base64 -d < '{escaped_temp}' > '{escaped_path}' 2>&1 && rm -f '{escaped_temp}' && echo 'decode_success' || (rm -f '{escaped_temp}' && echo 'decode_failed')"] + decode_result = self.exec_command(namespace, pod_name, decode_cmd, container_name=container_name) + + if 'decode_failed' in decode_result: + logger.error(f"Failed to decode base64 file: {decode_result}") + return False + + # Verify file was created and has content + check_cmd = ["sh", "-c", f"test -f '{escaped_path}' && test -s '{escaped_path}' && echo 'exists' || echo 'missing'"] + result = self.exec_command(namespace, pod_name, check_cmd, container_name=container_name) + + if 'exists' not in result: + logger.error(f"File verification failed: {result}") + return False + + return True + except Exception as e: + logger.error(f"Error writing binary file from disk {file_path}: {e}") + import traceback + logger.error(traceback.format_exc()) + return False + + def download_file(self, namespace: str, pod_name: str, file_path: str, container_name: str = None) -> str: + """Download file from pod as base64""" + if not self.connected: + return "" + + try: + # Use read_file_base64 for binary-safe file reading + # This handles both text and binary files correctly + content = self.read_file_base64(namespace, pod_name, file_path, container_name=container_name) + return content + except Exception as e: + logger.error(f"Error downloading file {file_path}: {e}") + return "" + +k8s_manager = KubernetesManager() diff --git a/main.py b/main.py new file mode 100644 index 0000000..87d8db6 --- /dev/null +++ b/main.py @@ -0,0 +1,58 @@ +"""Production process startup for HTTP, WebSocket, and background workers.""" + +import socket +import time +from urllib.parse import urlsplit + +from domain_runtime import domain_manager +from background_tasks import ( + + initialize_sample_data, + start_demo_cleanup_scheduler, + start_exchange_rate_updater, + start_ingress_cleanup_thread, + start_status_sync_thread, + start_upload_cleanup_scheduler, +) +from email_service import init_email_service +from server_billing import start_monthly_fee_scheduler +from websocket_service import start_websocket_server +from runtime_config import DATABASE_URL, NODE_IP, PORT, WS_PORT, logger + + +def run(application) -> None: + """Start runtime workers and serve the Flask application.""" + start_exchange_rate_updater() + init_email_service() + initialize_sample_data() + start_status_sync_thread() + start_upload_cleanup_scheduler() + start_ingress_cleanup_thread() + start_demo_cleanup_scheduler() + start_websocket_server() + start_monthly_fee_scheduler() + + logger.info("Monthly billing scheduler enabled") + time.sleep(2) + try: + with socket.create_connection(("localhost", WS_PORT), timeout=2): + logger.info("WebSocket server confirmed running on port %s", WS_PORT) + except OSError as exc: + logger.error("WebSocket server not reachable on port %s: %s", WS_PORT, exc) + + logger.info("HTTP server running on port %s", PORT) + logger.info("Kubernetes connection: %s", "connected" if _kubernetes_connected() else "disconnected") + logger.info("Database backend: %s", _database_backend()) + logger.info("Static node IP: %s", NODE_IP) + logger.info("Domain system: %s", "enabled" if domain_manager else "disabled") + application.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False) + + +def _kubernetes_connected() -> bool: + from kubernetes_service import k8s_manager + return k8s_manager.connected + + +def _database_backend() -> str: + scheme = urlsplit(DATABASE_URL).scheme.lower() + return "PostgreSQL" if scheme in {"postgres", "postgresql"} else "SQLite" diff --git a/migrate_db.py b/migrate_db.py new file mode 100644 index 0000000..f85c1b7 --- /dev/null +++ b/migrate_db.py @@ -0,0 +1,191 @@ +from datetime import datetime +import logging +import os +import psycopg2 +import sqlite3 +import sys + + + + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# Define the order of table migration to respect foreign key constraints +TABLE_MIGRATION_ORDER = [ + "users", + "servers", + "tickets", + "email_verification_tokens", + "password_reset_tokens", + "demo_servers_used", + "server_status_history", + "sessions", + "credit_transactions", + "database_configs", + "ticket_messages", + "ticket_attachments", + "backups", + "user_domains", + "server_billing" +] + +def get_sqlite_schema(cursor, table_name): + """Fetches the schema for a given table from SQLite.""" + cursor.execute(f"PRAGMA table_info('{table_name}')") + return {row['name']: row['type'].upper() for row in cursor.fetchall()} + +def transform_row(row, schema): + """Transforms a row for PostgreSQL insertion, handling data types.""" + transformed = list(row) + columns = row.keys() + for i, col_name in enumerate(columns): + col_type = schema.get(col_name) + # Handle booleans + if col_type == 'BOOLEAN' and transformed[i] in (0, 1): + transformed[i] = bool(transformed[i]) + # Handle timestamps + elif col_type == 'TIMESTAMP' and isinstance(transformed[i], str) and ' ' in transformed[i]: + try: + dt = datetime.strptime(transformed[i].split('.')[0], '%Y-%m-%d %H:%M:%S') + transformed[i] = dt.isoformat() + 'Z' # Add Z for UTC + except (ValueError, TypeError): + pass + return tuple(transformed) + +def ensure_server_billing_table(pg_conn): + """Checks if server_billing table exists in PostgreSQL and creates it if not.""" + with pg_conn.cursor() as cursor: + cursor.execute("SELECT to_regclass('public.server_billing');") + if cursor.fetchone()[0] is None: + logger.info("Table 'server_billing' not found in PostgreSQL. Creating it now.") + create_table_sql = """ + CREATE TABLE server_billing ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL UNIQUE, + creation_date DATE NOT NULL, + next_billing_date DATE NOT NULL, + last_billing_date DATE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE CASCADE + ); + """ + cursor.execute(create_table_sql) + pg_conn.commit() + logger.info("Table 'server_billing' created successfully.") + +def clear_postgres_tables(pg_conn): + """Clears all data from the tables in PostgreSQL by checking existence first.""" + logger.warning("Preparing to clear existing data from PostgreSQL tables.") + + with pg_conn.cursor() as cursor: + # Get a list of all existing tables in the public schema + cursor.execute("SELECT tablename FROM pg_tables WHERE schemaname = 'public';") + existing_tables = {row[0] for row in cursor.fetchall()} + + # Truncate tables in reverse order to respect foreign keys + tables_to_clear = reversed(TABLE_MIGRATION_ORDER) + + for table in tables_to_clear: + if table in existing_tables: + try: + logger.info(f"Clearing table: {table}") + cursor.execute(f'TRUNCATE TABLE "{table}" RESTART IDENTITY CASCADE;') + except Exception as e: + logger.error(f"Error clearing table '{table}': {e}") + pg_conn.rollback() + raise + else: + logger.warning(f"Table '{table}' does not exist in PostgreSQL, skipping clear.") + + pg_conn.commit() + logger.info("All existing tables cleared successfully.") + +def migrate_data(): + sqlite_path = os.environ.get('SQLITE_DB_PATH', 'anchorhost.db') + pg_url = os.environ.get('DATABASE_URL') + + if not pg_url or 'postgres' not in pg_url: + logger.error("DATABASE_URL environment variable not set or not a PostgreSQL URL.") + return + + logger.info(f"Starting migration from SQLite DB at '{sqlite_path}' to PostgreSQL.") + + if not os.path.exists(sqlite_path): + logger.error(f"SQLite database not found at '{sqlite_path}'.") + return + + # Use a single connection for the whole process + pg_conn = None + sqlite_conn = None + try: + pg_conn = psycopg2.connect(pg_url) + + # Prompt user for confirmation before clearing data + try: + if '--no-clear' not in sys.argv: + confirm = input("This script will clear all existing data from the target tables in PostgreSQL. Are you sure you want to continue? (y/n): ") + if confirm.lower() != 'y': + logger.info("Migration aborted by user.") + return + clear_postgres_tables(pg_conn) + except (IOError, EOFError): + logger.warning("Could not read user confirmation. Assuming non-interactive mode and proceeding WITH CLEARING DATA.") + if '--no-clear' not in sys.argv: + clear_postgres_tables(pg_conn) + + sqlite_conn = sqlite3.connect(sqlite_path) + sqlite_conn.row_factory = sqlite3.Row + sqlite_cursor = sqlite_conn.cursor() + + ensure_server_billing_table(pg_conn) + + for table in TABLE_MIGRATION_ORDER: + logger.info(f"Migrating table: {table}") + + try: + schema = get_sqlite_schema(sqlite_cursor, table) + sqlite_cursor.execute(f'SELECT * FROM "{table}"') + rows = sqlite_cursor.fetchall() + except sqlite3.OperationalError: + logger.warning(f"Table '{table}' does not exist in SQLite, skipping.") + continue + + if not rows: + logger.info(f"Table '{table}' is empty, skipping.") + continue + + columns = rows[0].keys() + columns_str = ', '.join(f'"{col}"' for col in columns) + placeholders = ', '.join(['%s'] * len(columns)) + + data_to_insert = [transform_row(row, schema) for row in rows] + + try: + with pg_conn.cursor() as pg_cursor: + query = f'INSERT INTO "{table}" ({columns_str}) VALUES ({placeholders})' + pg_cursor.executemany(query, data_to_insert) + logger.info(f"Successfully inserted {pg_cursor.rowcount} rows into table '{table}'.") + except Exception as e: + logger.error(f"Error inserting data for table '{table}': {e}") + pg_conn.rollback() # Rollback this specific table's transaction + continue # Continue with the next table + + pg_conn.commit() + logger.info("Migration completed successfully.") + + except Exception as e: + logger.error(f"An unexpected error occurred during migration: {e}") + if pg_conn: + pg_conn.rollback() + finally: + if sqlite_conn: + sqlite_conn.close() + if pg_conn: + pg_conn.close() + logger.info("Migration process finished.") + +if __name__ == "__main__": + migrate_data() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..04607dc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +Flask +Flask-Cors +kubernetes +PyJWT +bcrypt +websockets +requests +PyYAML +schedule +pyotp +qrcode +stripe +psycopg2-binary +dnspython +gunicorn +python-dotenv +mcrcon +paramiko diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..3aaa361 --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,29 @@ +"""Blueprint registration for the AnchorHost HTTP API.""" + +from flask import Flask + +from .account import bp as account_blueprint +from .admin import bp as admin_blueprint +from .auth import bp as auth_blueprint +from .billing import bp as billing_blueprint +from .domains import bp as domains_blueprint +from .public import bp as public_blueprint +from .servers import bp as servers_blueprint +from .tickets import bp as tickets_blueprint + +BLUEPRINTS = ( + account_blueprint, + admin_blueprint, + auth_blueprint, + billing_blueprint, + domains_blueprint, + public_blueprint, + servers_blueprint, + tickets_blueprint, +) + + +def register_routes(application: Flask) -> None: + """Register all HTTP API blueprints on the supplied Flask app.""" + for blueprint in BLUEPRINTS: + application.register_blueprint(blueprint) diff --git a/routes/account.py b/routes/account.py new file mode 100644 index 0000000..5914ad0 --- /dev/null +++ b/routes/account.py @@ -0,0 +1,363 @@ +"""Account API routes for AnchorHost.""" + + +from database_service import database_connection, dict_from_row +from datetime import datetime, timedelta +from email_service import get_email_service +from flask import Blueprint, jsonify, request +from runtime_config import ( + logger, +) +from server_lifecycle import delete_server +from user_service import authenticate_user, get_bearer_token, get_user_by_username, hash_password, verify_password +import uuid + +__all__ = ['change_user_email', 'change_user_password', 'delete_user_account', 'export_user_data', 'get_user_profile', 'resend_verification', 'update_user_profile'] + + + +bp = Blueprint("account", __name__) + + +@bp.route('/api/user/profile', methods=['GET']) +def get_user_profile(): + """Get user profile data""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + with database_connection() as conn: + cursor = conn.cursor() + + # Get full user data including profile fields + cursor.execute(''' + SELECT id, username, email, role, credits, created_at, updated_at, + display_name, first_name, last_name, address, city, + country, postal_code, phone, email_verified, + email_verification_token, password_changed_at, + two_factor_enabled, last_login, login_count, google_id, apple_id + FROM users WHERE id = ? + ''', (user['id'],)) + + user_data = dict_from_row(cursor.fetchone()) + + if user_data: + # Remove sensitive fields + if 'email_verification_token' in user_data: + user_data.pop('email_verification_token') + if 'two_factor_secret' in user_data: + user_data.pop('two_factor_secret', None) + + return jsonify({ + 'success': True, + 'user': user_data + }), 200 + else: + return jsonify({'success': False, 'error': 'User not found'}), 404 + + except Exception as e: + logger.error(f"Error getting user profile: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/user/profile', methods=['PUT']) +def update_user_profile(): + """Update user profile""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + + with database_connection() as conn: + cursor = conn.cursor() + + # Build update query dynamically based on provided fields + update_fields = [] + params = [] + + allowed_fields = ['display_name', 'first_name', 'last_name', 'address', + 'city', 'country', 'postal_code', 'phone'] + + for field in allowed_fields: + if field in data: + update_fields.append(f"{field} = ?") + params.append(data[field]) + + if not update_fields: + return jsonify({'success': False, 'error': 'No fields to update'}), 400 + + # Add updated_at + update_fields.append("updated_at = CURRENT_TIMESTAMP") + params.append(user['id']) + + query = f"UPDATE users SET {', '.join(update_fields)} WHERE id = ?" + cursor.execute(query, params) + conn.commit() + + logger.info(f"โœ“ Profile updated for user: {user['username']}") + return jsonify({'success': True, 'message': 'Profile updated successfully'}), 200 + + except Exception as e: + logger.error(f"Error updating profile: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/user/email', methods=['PUT']) +def change_user_email(): + """Change user email address""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + new_email = data.get('email') + + if not new_email: + return jsonify({'success': False, 'error': 'Email required'}), 400 + + # Validate email format + import re + if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', new_email): + return jsonify({'success': False, 'error': 'Invalid email format'}), 400 + + with database_connection() as conn: + cursor = conn.cursor() + + # Check if email is already in use + cursor.execute('SELECT id FROM users WHERE email = ? AND id != ?', (new_email, user['id'])) + if cursor.fetchone(): + return jsonify({'success': False, 'error': 'Email already in use'}), 400 + + # Generate new verification token + verification_token = str(uuid.uuid4()) + + # Update user email and set as unverified + cursor.execute(''' + UPDATE users + SET email = ?, email_verified = FALSE, email_verification_token = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (new_email, verification_token, user['id'])) + + # Create verification token record + token_id = f"token-{str(uuid.uuid4())[:8]}" + expires_at = (datetime.now() + timedelta(hours=24)).isoformat() + + cursor.execute(''' + INSERT INTO email_verification_tokens (id, user_id, token, expires_at) + VALUES (?, ?, ?, ?) + ''', (token_id, user['id'], verification_token, expires_at)) + + conn.commit() + + # Send verification email to new address + email_svc = get_email_service() + if email_svc: + email_svc.send_verification_email(new_email, user['username'], verification_token) + + logger.info(f"โœ“ Email changed for user: {user['username']} -> {new_email}") + return jsonify({ + 'success': True, + 'message': 'Email changed. Please check your new email for verification link.' + }), 200 + + except Exception as e: + logger.error(f"Error changing email: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/user/password', methods=['PUT']) +def change_user_password(): + """Change user password""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + current_password = data.get('currentPassword') + new_password = data.get('newPassword') + + if not current_password or not new_password: + return jsonify({'success': False, 'error': 'Current and new passwords required'}), 400 + + # Validate new password length + if len(new_password) < 6: + return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400 + + # Verify current password + user_full = get_user_by_username(user['username']) + if not verify_password(current_password, user_full['password_hash']): + return jsonify({'success': False, 'error': 'Current password is incorrect'}), 401 + + # Hash new password + new_password_hash = hash_password(new_password) + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE users + SET password_hash = ?, password_changed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (new_password_hash, user['id'])) + conn.commit() + + logger.info(f"โœ“ Password changed for user: {user['username']}") + return jsonify({'success': True, 'message': 'Password changed successfully'}), 200 + + except Exception as e: + logger.error(f"Error changing password: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/user/export-data', methods=['GET']) +def export_user_data(): + """Export all user data""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + with database_connection() as conn: + cursor = conn.cursor() + + # Get user data + cursor.execute('SELECT * FROM users WHERE id = ?', (user['id'],)) + user_data = dict_from_row(cursor.fetchone()) + + # Remove sensitive fields + if user_data: + user_data.pop('password_hash', None) + user_data.pop('email_verification_token', None) + user_data.pop('two_factor_secret', None) + + # Get servers + cursor.execute('SELECT * FROM servers WHERE owner_id = ?', (user['id'],)) + servers = [dict_from_row(row) for row in cursor.fetchall()] + + # Get domains + cursor.execute('SELECT * FROM user_domains WHERE user_id = ?', (user['id'],)) + domains = [dict_from_row(row) for row in cursor.fetchall()] + + # Get tickets + cursor.execute('SELECT * FROM tickets WHERE user_id = ?', (user['id'],)) + tickets = [dict_from_row(row) for row in cursor.fetchall()] + + # Get credit transactions + cursor.execute('SELECT * FROM credit_transactions WHERE user_id = ?', (user['id'],)) + transactions = [dict_from_row(row) for row in cursor.fetchall()] + + export_data = { + 'user': user_data, + 'servers': servers, + 'domains': domains, + 'tickets': tickets, + 'credit_transactions': transactions, + 'exported_at': datetime.now().isoformat() + } + + logger.info(f"โœ“ Data exported for user: {user['username']}") + return jsonify({'success': True, 'data': export_data}), 200 + + except Exception as e: + logger.error(f"Error exporting data: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/user/account', methods=['DELETE']) +def delete_user_account(): + """Delete user account and all associated data""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + confirmation = data.get('confirmation') + + if confirmation != 'DELETE MY ACCOUNT': + return jsonify({'success': False, 'error': 'Invalid confirmation text'}), 400 + + with database_connection() as conn: + cursor = conn.cursor() + + # Delete all user's servers (cascade will handle related data) + cursor.execute('SELECT id FROM servers WHERE owner_id = ?', (user['id'],)) + server_ids = [row['id'] for row in cursor.fetchall()] + + # Delete servers from Kubernetes + for server_id in server_ids: + try: + delete_server(server_id, user['id']) + except Exception as e: + logger.error(f"Error deleting server {server_id}: {e}") + + # Delete user (cascade will handle most related data) + cursor.execute('DELETE FROM users WHERE id = ?', (user['id'],)) + conn.commit() + + logger.info(f"โœ“ Account deleted for user: {user['username']}") + return jsonify({'success': True, 'message': 'Account deleted successfully'}), 200 + + except Exception as e: + logger.error(f"Error deleting account: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/user/resend-verification', methods=['POST']) +def resend_verification(): + """Resend verification email""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + # Check if already verified + if user.get('email_verified'): + return jsonify({'success': False, 'error': 'Email already verified'}), 400 + + # Generate new token + new_token = str(uuid.uuid4()) + + with database_connection() as conn: + cursor = conn.cursor() + + # Delete old tokens + cursor.execute('DELETE FROM email_verification_tokens WHERE user_id = ?', (user['id'],)) + + # Create new token + token_id = f"token-{str(uuid.uuid4())[:8]}" + expires_at = (datetime.now() + timedelta(hours=24)).isoformat() + + cursor.execute(''' + INSERT INTO email_verification_tokens (id, user_id, token, expires_at) + VALUES (?, ?, ?, ?) + ''', (token_id, user['id'], new_token, expires_at)) + + # Update user + cursor.execute(''' + UPDATE users + SET email_verification_token = ? + WHERE id = ? + ''', (new_token, user['id'])) + + conn.commit() + + # Send email + email_svc = get_email_service() + if email_svc: + success = email_svc.send_verification_email(user['email'], user['username'], new_token) + if success: + return jsonify({'success': True, 'message': 'Verification email sent'}), 200 + else: + return jsonify({'success': False, 'error': 'Failed to send email'}), 500 + else: + return jsonify({'success': False, 'error': 'Email service unavailable'}), 503 + + except Exception as e: + logger.error(f"Error resending verification: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..4470ea8 --- /dev/null +++ b/routes/admin.py @@ -0,0 +1,948 @@ +"""Admin API routes for AnchorHost.""" + + +import bcrypt +from database_service import database_connection, dict_from_row +from domain_runtime import domain_manager +from flask import Blueprint, jsonify, request +import json +from kubernetes import client +from kubernetes.client.rest import ApiException +from kubernetes_service import custom_objects_api, k8s_connected, k8s_manager, v1 +from runtime_config import ( + DATABASE_URL, + logger, +) +from server_billing import charge_monthly_fees, get_user_credit_history, get_user_monthly_fees +from server_capacity import parse_resource_to_mebibytes, parse_resource_to_millicores, parse_storage_to_gb +from server_lifecycle import delete_server, restart_server, start_server, stop_server +from server_records import get_server_by_id, get_user_servers +from showcase_service import _is_valid_http_url, _normalize_showcase_url, get_homepage_showcase_links, save_homepage_showcase_links +from user_service import authenticate_user, get_bearer_token, get_panel_settings, get_pricing, require_admin, set_site_setting +import uuid + +__all__ = ['admin_ban_user', 'admin_delete_server', 'admin_delete_user', 'admin_disable_user_2fa', 'admin_get_all_servers', 'admin_get_cluster_nodes', 'admin_get_stats', 'admin_get_user_billing_history', 'admin_get_user_credit_history', 'admin_get_user_servers', 'admin_get_users', 'admin_homepage_showcase', 'admin_panel_settings', 'admin_pricing', 'admin_reset_user_password', 'admin_restart_server', 'admin_start_server', 'admin_stop_server', 'admin_unban_user', 'admin_update_credits', 'create_announcement', 'debug_domains', 'debug_k8s', 'debug_servers', 'debug_users', 'delete_announcement', 'list_namespaces', 'run_manual_billing', 'update_announcement'] + + + +bp = Blueprint("admin", __name__) + + +@bp.route('/api/admin/billing/run-manual', methods=['POST']) +def run_manual_billing(): + """Run manual billing collection (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + result = charge_monthly_fees() + + return jsonify(result), 200 + + except Exception as e: + logger.error(f"Error running manual billing: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users', methods=['GET']) +def admin_get_users(): + """Get all users (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + with database_connection() as conn: + cursor = conn.cursor() + + # Get all users + cursor.execute(''' + SELECT id, username, email, role, credits, created_at, email_verified, last_login, login_count + FROM users + ORDER BY created_at DESC + ''') + + users = [] + for row in cursor.fetchall(): + user_dict = dict_from_row(row) + users.append({ + 'id': user_dict['id'], + 'username': user_dict['username'], + 'email': user_dict['email'], + 'role': user_dict['role'], + 'credits': user_dict['credits'], + 'created_at': user_dict['created_at'], + 'email_verified': bool(user_dict['email_verified']), + 'last_login': user_dict['last_login'], + 'login_count': user_dict['login_count'] + }) + + return jsonify({'success': True, 'users': users}), 200 + + except Exception as e: + logger.error(f"Error getting users: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users//ban', methods=['POST']) +def admin_ban_user(user_id): + """Ban a user (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + if user['id'] == user_id: + return jsonify({'success': False, 'error': 'Cannot ban yourself'}), 400 + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('UPDATE users SET role = ? WHERE id = ?', ('banned', user_id)) + conn.commit() + + logger.info(f"User {user_id} banned by admin {user['username']}") + return jsonify({'success': True, 'message': 'User banned'}), 200 + + except Exception as e: + logger.error(f"Error banning user: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users//unban', methods=['POST']) +def admin_unban_user(user_id): + """Unban a user (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('UPDATE users SET role = ? WHERE id = ?', ('user', user_id)) + conn.commit() + + logger.info(f"User {user_id} unbanned by admin {user['username']}") + return jsonify({'success': True, 'message': 'User unbanned'}), 200 + + except Exception as e: + logger.error(f"Error unbanning user: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users//credits', methods=['POST']) +def admin_update_credits(user_id): + """Update user credits (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + data = request.get_json() + amount = data.get('amount') + operation = data.get('operation', 'add') # add, set, subtract + + if amount is None: + return jsonify({'success': False, 'error': 'Amount required'}), 400 + + try: + amount = int(amount) + except ValueError: + return jsonify({'success': False, 'error': 'Amount must be an integer'}), 400 + + with database_connection() as conn: + cursor = conn.cursor() + + cursor.execute('SELECT credits, username FROM users WHERE id = ?', (user_id,)) + current_user = dict_from_row(cursor.fetchone()) + + if not current_user: + return jsonify({'success': False, 'error': 'User not found'}), 404 + + current_credits = current_user['credits'] + new_credits = current_credits + + if operation == 'add': + new_credits += amount + elif operation == 'subtract': + new_credits -= amount + elif operation == 'set': + new_credits = amount + + cursor.execute('UPDATE users SET credits = ? WHERE id = ?', (new_credits, user_id)) + conn.commit() + + logger.info(f"Credits updated for user {current_user['username']} by admin {user['username']}: {current_credits} -> {new_credits}") + return jsonify({'success': True, 'message': 'Credits updated', 'new_credits': new_credits}), 200 + + except Exception as e: + logger.error(f"Error updating credits: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users//servers', methods=['GET']) +def admin_get_user_servers(user_id): + """Get servers for a specific user (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + servers = get_user_servers(user_id) + return jsonify({'success': True, 'servers': servers}), 200 + + except Exception as e: + logger.error(f"Error getting user servers: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users//credits/history', methods=['GET']) +def admin_get_user_credit_history(user_id): + """Get credit transaction history for a specific user (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + history = get_user_credit_history(user_id, limit=50) # Use existing function + return jsonify({'success': True, 'history': history}), 200 + + except Exception as e: + logger.error(f"Error getting user credit history: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users//billing/history', methods=['GET']) +def admin_get_user_billing_history(user_id): + """Get billing history for a specific user (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + history = get_user_monthly_fees(user_id, limit=50) # Use existing function + return jsonify({'success': True, 'history': history}), 200 + + except Exception as e: + logger.error(f"Error getting user billing history: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/servers//start', methods=['POST']) +def admin_start_server(server_id): + """Start a server (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + # Get server to find owner + server = get_server_by_id(server_id) + if not server: + return jsonify({'success': False, 'error': 'Server not found'}), 404 + + if start_server(server_id, server['owner_id']): + return jsonify({'success': True, 'message': 'Server started'}), 200 + return jsonify({'success': False, 'error': 'Failed to start server'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/servers//stop', methods=['POST']) +def admin_stop_server(server_id): + """Stop a server (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + # Get server to find owner + server = get_server_by_id(server_id) + if not server: + return jsonify({'success': False, 'error': 'Server not found'}), 404 + + if stop_server(server_id, server['owner_id']): + return jsonify({'success': True, 'message': 'Server stopped'}), 200 + return jsonify({'success': False, 'error': 'Failed to stop server'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/servers//restart', methods=['POST']) +def admin_restart_server(server_id): + """Restart a server (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + # Get server to find owner + server = get_server_by_id(server_id) + if not server: + return jsonify({'success': False, 'error': 'Server not found'}), 404 + + if restart_server(server_id, server['owner_id']): + return jsonify({'success': True, 'message': 'Server restarted'}), 200 + return jsonify({'success': False, 'error': 'Failed to restart server'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/servers/', methods=['DELETE']) +def admin_delete_server(server_id): + """Delete a server (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + # Get server to find owner + server = get_server_by_id(server_id) + if not server: + return jsonify({'success': False, 'error': 'Server not found'}), 404 + + if delete_server(server_id, server['owner_id']): + return jsonify({'success': True, 'message': 'Server deleted'}), 200 + return jsonify({'success': False, 'error': 'Failed to delete server'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/servers', methods=['GET']) +def admin_get_all_servers(): + """Get all servers with owner details (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT s.id, s.name, s.cpu, s.ram, s.storage, s.image, s.type, s.version, s.loader, s.java_version, + s.status, s.ports, s.created_at, u.username as owner, u.id as owner_id, + s.sftp_username, s.sftp_password, s.sftp_port + FROM servers s + JOIN users u ON s.owner_id = u.id + ORDER BY u.username, s.name + ''') + servers = [dict_from_row(row) for row in cursor.fetchall()] + + return jsonify({'success': True, 'servers': servers}), 200 + + except Exception as e: + logger.error(f"Error getting all servers: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/stats', methods=['GET']) +def admin_get_stats(): + """Get platform stats (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + with database_connection() as conn: + cursor = conn.cursor() + + cursor.execute('SELECT COUNT(*) as count FROM users') + res = dict_from_row(cursor.fetchone()) + total_users = res['count'] if res else 0 + + cursor.execute('SELECT COUNT(*) as count FROM servers') + res = dict_from_row(cursor.fetchone()) + total_servers = res['count'] if res else 0 + + cursor.execute("SELECT COUNT(*) as count FROM servers WHERE status = 'Running'") + res = dict_from_row(cursor.fetchone()) + active_servers = res['count'] if res else 0 + + cursor.execute('SELECT SUM(credits) as total FROM users') + res = dict_from_row(cursor.fetchone()) + total_credits = res['total'] if res and res['total'] is not None else 0 + + stats = { + 'total_users': total_users, + 'total_servers': total_servers, + 'active_servers': active_servers, + 'total_credits': total_credits + } + + return jsonify({'success': True, 'stats': stats}), 200 + + except Exception as e: + logger.error(f"Error getting stats: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users/', methods=['DELETE']) +def admin_delete_user(user_id): + """Delete a user (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + with database_connection() as conn: + cursor = conn.cursor() + + # Check if user exists + cursor.execute('SELECT username FROM users WHERE id = ?', (user_id,)) + target_user = cursor.fetchone() + + if not target_user: + return jsonify({'success': False, 'error': 'User not found'}), 404 + + # Delete user + cursor.execute('DELETE FROM users WHERE id = ?', (user_id,)) + conn.commit() + + logger.info(f"User {user_id} deleted by admin {user['username']}") + return jsonify({'success': True}), 200 + + except Exception as e: + logger.error(f"Error deleting user: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users//password', methods=['POST']) +def admin_reset_user_password(user_id): + """Reset user password (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + data = request.json + new_password = data.get('password') + + if not new_password or len(new_password) < 6: + return jsonify({'success': False, 'error': 'Password must be at least 6 characters'}), 400 + + hashed_password = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('UPDATE users SET password_hash = ?, password_changed_at = CURRENT_TIMESTAMP WHERE id = ?', (hashed_password, user_id)) + conn.commit() + + logger.info(f"Password reset for user {user_id} by admin {user['username']}") + return jsonify({'success': True}), 200 + + except Exception as e: + logger.error(f"Error resetting password: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/users//2fa/disable', methods=['POST']) +def admin_disable_user_2fa(user_id): + """Disable user 2FA (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('UPDATE users SET two_factor_enabled = FALSE, two_factor_secret = NULL WHERE id = ?', (user_id,)) + conn.commit() + + logger.info(f"2FA disabled for user {user_id} by admin {user['username']}") + return jsonify({'success': True}), 200 + + except Exception as e: + logger.error(f"Error disabling 2FA: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/cluster/nodes', methods=['GET']) +def admin_get_cluster_nodes(): + """Get detailed cluster node stats (admin only)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + if not user or user.get('role') != 'admin': + return jsonify({'success': False, 'error': 'Admin access required'}), 403 + + if not k8s_connected or not v1: + return jsonify({'success': False, 'error': 'Kubernetes not connected'}), 503 + + nodes = v1.list_node() + node_stats = [] + + # Try to get metrics if available + metrics = {} + try: + # Use global custom_objects_api if available, else try to instantiate + api_instance = custom_objects_api if custom_objects_api else client.CustomObjectsApi() + metrics_data = api_instance.list_cluster_custom_object( + group="metrics.k8s.io", + version="v1beta1", + plural="nodes" + ) + for item in metrics_data.get('items', []): + metrics[item['metadata']['name']] = item['usage'] + except Exception as e: + logger.warning(f"Could not fetch node metrics: {e}") + + # Get pod allocations for allocated stats + all_pods = v1.list_pod_for_all_namespaces() + pod_allocations = {} + for pod in all_pods.items: + if pod.spec.node_name and pod.status.phase in ['Running', 'Pending']: + node_name = pod.spec.node_name + if node_name not in pod_allocations: + pod_allocations[node_name] = {'cpu': 0, 'memory': 0, 'storage': 0.0} + + for container in pod.spec.containers: + if container.resources and container.resources.requests: + if 'cpu' in container.resources.requests: + pod_allocations[node_name]['cpu'] += parse_resource_to_millicores(container.resources.requests['cpu']) + if 'memory' in container.resources.requests: + pod_allocations[node_name]['memory'] += parse_resource_to_mebibytes(container.resources.requests['memory']) + if 'ephemeral-storage' in container.resources.requests: + pod_allocations[node_name]['storage'] += parse_storage_to_gb(container.resources.requests['ephemeral-storage']) + + for node in nodes.items: + name = node.metadata.name + + # Specs + allocatable = node.status.allocatable or {} + capacity = node.status.capacity or {} + node_info = node.status.node_info + + cpu_allocatable = parse_resource_to_millicores(allocatable.get('cpu', '0')) + memory_allocatable = parse_resource_to_mebibytes(allocatable.get('memory', '0')) + storage_capacity = parse_storage_to_gb(capacity.get('ephemeral-storage', '0Gi')) + storage_allocatable = parse_storage_to_gb(allocatable.get('ephemeral-storage', capacity.get('ephemeral-storage', '0Gi'))) + + # Roles + labels = node.metadata.labels or {} + roles = [] + if 'node-role.kubernetes.io/control-plane' in labels or 'node-role.kubernetes.io/master' in labels: + roles.append('Control Plane') + else: + roles.append('Worker') + + # OS Info + os_image = node_info.os_image + kernel_version = node_info.kernel_version + + # Extract CPU Model (if available from labels) + cpu_model = "Unknown CPU" + if 'cpu-model.node.kubevirt.io/Haswell-noTSX' in labels: + cpu_model = "Intel Haswell" + elif 'cpu-model.node.kubevirt.io/Nehalem' in labels: + cpu_model = "Intel Nehalem" + elif any(k.startswith('cpu-model.node.kubevirt.io/') for k in labels): + cpu_model = [k.split('/')[-1] for k in labels if k.startswith('cpu-model.node.kubevirt.io/')][0] + + # Fallback if vendor is known + if cpu_model == "Unknown CPU": + if 'cpu-vendor.node.kubevirt.io/Intel' in labels: + cpu_model = "Intel CPU" + elif 'cpu-vendor.node.kubevirt.io/AMD' in labels: + cpu_model = "AMD CPU" + + # Allocated + allocated = pod_allocations.get(name, {'cpu': 0, 'memory': 0, 'storage': 0.0}) + storage_allocated = allocated.get('storage', 0.0) + disk_alloc_percent = round((storage_allocated / storage_allocatable * 100), 1) if storage_allocatable > 0 else 0.0 + + # Usage (from metrics if available) + usage = {'cpu': 0, 'memory': 0} + if name in metrics: + usage['cpu'] = parse_resource_to_millicores(metrics[name].get('cpu', '0')) + usage['memory'] = parse_resource_to_mebibytes(metrics[name].get('memory', '0')) + + node_stats.append({ + 'name': name, + 'status': 'Ready' if any(c.type == 'Ready' and c.status == 'True' for c in node.status.conditions) else 'NotReady', + 'role': roles[0], + 'os': { + 'name': node_info.operating_system, + 'image': os_image, + 'kernel': kernel_version, + 'arch': node_info.architecture, + 'icon': 'linux' if node_info.operating_system.lower() == 'linux' else 'windows' + }, + 'specs': { + 'cpu': f"{int(cpu_allocatable/1000)} Cores", + 'cpu_model': cpu_model, + 'ram': f"{memory_allocatable/1024:.1f} GB", + 'disk': f"{storage_capacity:.1f} GB" + }, + 'usage': { + 'cpu': f"{usage['cpu']/1000:.2f} Cores", + 'cpu_display': f"{usage['cpu']/1000:.2f} Cores" if usage['cpu'] > 0 else "0.00 Cores", + 'cpu_percent': round(usage['cpu'] / cpu_allocatable * 100, 1) if cpu_allocatable > 0 else 0, + 'ram': f"{usage['memory']/1024:.2f} GB", + 'ram_display': f"{usage['memory']/1024:.2f} GB" if usage['memory'] > 0 else "0.00 GB", + 'ram_percent': round(usage['memory'] / memory_allocatable * 100, 1) if memory_allocatable > 0 else 0 + }, + 'allocated': { + 'cpu': f"{allocated['cpu']/1000:.2f} Cores", + 'cpu_display': f"{allocated['cpu']/1000:.2f} Cores", + 'cpu_percent': round(allocated['cpu'] / cpu_allocatable * 100, 1) if cpu_allocatable > 0 else 0, + 'ram': f"{allocated['memory']/1024:.2f} GB", + 'ram_display': f"{allocated['memory']/1024:.2f} GB", + 'ram_percent': round(allocated['memory'] / memory_allocatable * 100, 1) if memory_allocatable > 0 else 0, + 'disk': f"{storage_allocated:.2f} GB", + 'disk_display': f"{storage_allocated:.2f} GB / {storage_capacity:.1f} GB", + 'disk_percent': disk_alloc_percent + } + }) + + return jsonify({'success': True, 'nodes': node_stats}), 200 + + except Exception as e: + logger.error(f"Error getting cluster nodes: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/debug/users', methods=['GET']) +def debug_users(): + if not require_admin(): + return jsonify({'error': 'Unauthorized'}), 403 + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id, username, email, role, credits, created_at FROM users') + users_list = [dict_from_row(row) for row in cursor.fetchall()] + + return jsonify({ + 'users': users_list, + 'count': len(users_list), + 'kubernetes': { + 'connected': k8s_manager.connected, + 'lastError': str(k8s_manager.last_error) if k8s_manager.last_error else None + } + }), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/debug/servers', methods=['GET']) +def debug_servers(): + if not require_admin(): + return jsonify({'error': 'Unauthorized'}), 403 + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id, name, type, status, cpu, ram, storage, ip, ports, + owner_id, namespace, k8s_name, is_demo, demo_expires_at, + created_at + FROM servers + ''') + servers_list = [dict_from_row(row) for row in cursor.fetchall()] + + for server in servers_list: + if server and 'ports' in server: + server['ports'] = json.loads(server['ports']) + + return jsonify({ + 'servers': servers_list, + 'count': len(servers_list) + }), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/debug/domains', methods=['GET']) +def debug_domains(): + if not require_admin(): + return jsonify({'error': 'Unauthorized'}), 403 + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM user_domains') + domains_list = [dict_from_row(row) for row in cursor.fetchall()] + + return jsonify({ + 'domains': domains_list, + 'count': len(domains_list), + 'domain_manager': 'available' if domain_manager else 'unavailable' + }), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/debug/k8s', methods=['GET']) +def debug_k8s(): + if not require_admin(): + return jsonify({'error': 'Unauthorized'}), 403 + return jsonify({ + 'connected': k8s_manager.connected, + 'lastError': str(k8s_manager.last_error) if k8s_manager.last_error else None + }), 200 + +@bp.route('/namespaces', methods=['GET']) +def list_namespaces(): + if not require_admin(): + return jsonify({'error': 'Unauthorized'}), 403 + try: + if k8s_manager.connected: + namespaces = v1.list_namespace() + namespace_list = [ns.metadata.name for ns in namespaces.items] + return jsonify({ + 'success': True, + 'namespaces': namespace_list, + 'count': len(namespace_list) + }), 200 + else: + return jsonify({ + 'success': True, + 'namespaces': ['user-testuser'], + 'count': 1, + 'message': 'Kubernetes not connected' + }), 200 + except ApiException as e: + return jsonify({ + 'success': False, + 'error': f'Failed to list namespaces: {e.reason}', + 'status': e.status + }), e.status + +@bp.route('/api/admin/announcements', methods=['POST']) +def create_announcement(): + """Create a new announcement (Admin only)""" + try: + user = require_admin() + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + + data = request.json + if not data or not data.get('title') or not data.get('content'): + return jsonify({'success': False, 'error': 'Title and content are required'}), 400 + + title = data['title'] + content = data['content'] + tags = data.get('tags', []) + + # Ensure tags is a list + if isinstance(tags, str): + tags = [tags] + + # Serialize tags to JSON for storage + tags_json = json.dumps(tags) + + announcement_id = f"ann-{str(uuid.uuid4())[:8]}" + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO announcements (id, title, content, tags, author_id) + VALUES (?, ?, ?, ?, ?) + ''', (announcement_id, title, content, tags_json, user['id'])) + conn.commit() + + # Fetch the created announcement to return it + cursor.execute(''' + SELECT a.*, u.username as author_name + FROM announcements a + JOIN users u ON a.author_id = u.id + WHERE a.id = ? + ''', (announcement_id,)) + + new_announcement = dict_from_row(cursor.fetchone()) + if new_announcement and new_announcement.get('tags'): + try: + new_announcement['tags'] = json.loads(new_announcement['tags']) + except: + new_announcement['tags'] = [] + + return jsonify({'success': True, 'announcement': new_announcement}) + + except Exception as e: + logger.error(f"Error creating announcement: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/announcements/', methods=['PUT']) +def update_announcement(announcement_id): + """Update an announcement (Admin only)""" + try: + user = require_admin() + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + + data = request.json + + updates = [] + params = [] + + if 'title' in data: + updates.append("title = ?") + params.append(data['title']) + + if 'content' in data: + updates.append("content = ?") + params.append(data['content']) + + if 'tags' in data: + tags = data['tags'] + if isinstance(tags, str): + tags = [tags] + updates.append("tags = ?") + params.append(json.dumps(tags)) + + if not updates: + return jsonify({'success': False, 'error': 'No fields to update'}), 400 + + updates.append("updated_at = CURRENT_TIMESTAMP") + params.append(announcement_id) + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(f''' + UPDATE announcements + SET {', '.join(updates)} + WHERE id = ? + ''', params) + conn.commit() + + if cursor.rowcount == 0: + return jsonify({'success': False, 'error': 'Announcement not found'}), 404 + + return jsonify({'success': True, 'message': 'Announcement updated successfully'}) + + except Exception as e: + logger.error(f"Error updating announcement: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/announcements/', methods=['DELETE']) +def delete_announcement(announcement_id): + """Delete an announcement (Admin only)""" + try: + user = require_admin() + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM announcements WHERE id = ?', (announcement_id,)) + conn.commit() + + if cursor.rowcount == 0: + return jsonify({'success': False, 'error': 'Announcement not found'}), 404 + + return jsonify({'success': True, 'message': 'Announcement deleted successfully'}) + + except Exception as e: + logger.error(f"Error deleting announcement: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/homepage-showcase', methods=['GET', 'PUT']) +def admin_homepage_showcase(): + """Read or update homepage showcase links (admin only).""" + try: + user = require_admin() + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + + if request.method == 'GET': + return jsonify({'success': True, 'links': get_homepage_showcase_links()}) + + data = request.json or {} + raw_links = data.get('links') + if not isinstance(raw_links, list): + return jsonify({'success': False, 'error': 'links must be an array'}), 400 + + normalized = [] + for i in range(3): + entry = raw_links[i] if i < len(raw_links) and isinstance(raw_links[i], dict) else {} + label = (entry.get('label') or f'Site {i + 1}').strip()[:160] + url = _normalize_showcase_url(entry.get('url', '')) + if not _is_valid_http_url(url): + return jsonify({'success': False, 'error': f'Invalid URL for slot {i + 1}'}), 400 + normalized.append({"label": label, "url": url}) + + save_homepage_showcase_links(normalized) + return jsonify({'success': True, 'links': normalized}) + except Exception as e: + logger.error(f"admin_homepage_showcase: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/pricing', methods=['GET', 'POST']) +def admin_pricing(): + """Read or update service pricing (admin only).""" + try: + user = require_admin() + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + + if request.method == 'GET': + return jsonify({'success': True, 'pricing': get_pricing()}) + + data = request.json or {} + with database_connection() as conn: + cursor = conn.cursor() + for key, value in data.items(): + if key.startswith('price_') or key.startswith('multiplier_'): + # Skip None values (JSON NaN becomes None) + if value is None: + continue + + if 'postgres' in DATABASE_URL: + cursor.execute( + "INSERT INTO site_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP", + (key, str(value)) + ) + else: + # Check if key exists + cursor.execute("SELECT 1 FROM site_settings WHERE key = ?", (key,)) + if cursor.fetchone(): + cursor.execute( + "UPDATE site_settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", + (str(value), key) + ) + else: + cursor.execute( + "INSERT INTO site_settings (key, value) VALUES (?, ?)", + (key, str(value)) + ) + conn.commit() + return jsonify({'success': True, 'pricing': get_pricing()}) + except Exception as e: + logger.error(f"admin_pricing: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/admin/settings', methods=['GET', 'POST', 'PUT']) +@bp.route('/api/admin/panel-settings', methods=['GET', 'POST', 'PUT']) +def admin_panel_settings(): + """Read or update panel settings (admin only).""" + try: + user = require_admin() + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + + if request.method == 'GET': + return jsonify({'success': True, 'settings': get_panel_settings()}) + + data = request.json or {} + if 'demo_servers_enabled' in data: + demo_val = 'true' if data['demo_servers_enabled'] in (True, 'true', '1', 1) else 'false' + set_site_setting('demo_servers_enabled', demo_val) + + if 'new_user_credits' in data: + try: + credits_val = max(0, int(float(data['new_user_credits']))) + set_site_setting('new_user_credits', str(credits_val)) + except (ValueError, TypeError): + pass + + return jsonify({'success': True, 'settings': get_panel_settings()}) + except Exception as e: + logger.error(f"admin_panel_settings: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..0d2c241 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,602 @@ +"""Auth API routes for AnchorHost.""" + + +import asyncio +from database_service import database_connection, dict_from_row +from datetime import datetime, timezone +from email_service import get_email_service +from flask import Blueprint, jsonify, redirect, request +import json +import jwt +import os +import requests +from runtime_config import ( + APPLE_CLIENT_ID, + APPLE_KEY_ID, + APPLE_PRIVATE_KEY, + APPLE_REDIRECT_URI, + APPLE_TEAM_ID, + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + GOOGLE_REDIRECT_URI, + connected_clients, + logger, +) +import secrets +import urllib.parse +from user_service import create_user, generate_oauth_state, generate_token, get_user_by_apple_id, get_user_by_email, get_user_by_google_id, verify_oauth_state +from websocket_service import send_message + +__all__ = ['apple_callback', 'apple_login', 'get_auth_config', 'google_callback', 'google_login', 'verify_email', 'verify_email_api'] + + + +bp = Blueprint("auth", __name__) + + +@bp.route('/api/auth/google/login', methods=['GET']) +def google_login(): + """Initiate Google OAuth login""" + if not GOOGLE_CLIENT_ID: + return jsonify({'error': 'Google OAuth not configured'}), 500 + + # Generate state token for CSRF protection + state = generate_oauth_state('google') + + # Store state in session or return it to frontend + # For simplicity, we'll include it in the redirect URL + auth_url = ( + f"https://accounts.google.com/o/oauth2/v2/auth?" + f"client_id={GOOGLE_CLIENT_ID}&" + f"redirect_uri={urllib.parse.quote(GOOGLE_REDIRECT_URI)}&" + f"response_type=code&" + f"scope=openid email profile&" + f"state={state}&" + f"access_type=offline&" + f"prompt=consent" + ) + + return jsonify({'auth_url': auth_url, 'state': state}) + +@bp.route('/api/auth/google/callback', methods=['GET', 'POST']) +def google_callback(): + """Handle Google OAuth callback""" + try: + code = request.args.get('code') + state = request.args.get('state') + + if not verify_oauth_state(state, 'google'): + return jsonify({'error': 'Invalid OAuth state'}), 400 + + if not code: + return jsonify({'error': 'Authorization code not provided'}), 400 + + # Exchange authorization code for access token + token_url = 'https://oauth2.googleapis.com/token' + token_data = { + 'code': code, + 'client_id': GOOGLE_CLIENT_ID, + 'client_secret': GOOGLE_CLIENT_SECRET, + 'redirect_uri': GOOGLE_REDIRECT_URI, + 'grant_type': 'authorization_code' + } + + token_response = requests.post(token_url, data=token_data) + token_response.raise_for_status() + tokens = token_response.json() + + access_token = tokens.get('access_token') + if not access_token: + return jsonify({'error': 'Failed to obtain access token'}), 400 + + # Get user info from Google + user_info_url = 'https://www.googleapis.com/oauth2/v2/userinfo' + headers = {'Authorization': f'Bearer {access_token}'} + user_info_response = requests.get(user_info_url, headers=headers) + user_info_response.raise_for_status() + google_user = user_info_response.json() + + google_id = google_user.get('id') + email = google_user.get('email') + name = google_user.get('name', '') + given_name = google_user.get('given_name', '') + family_name = google_user.get('family_name', '') + picture = google_user.get('picture', '') + verified_email = google_user.get('verified_email', False) + + if not google_id or not email: + return jsonify({'error': 'Failed to retrieve user information from Google'}), 400 + + # Check if user exists by Google ID + user = get_user_by_google_id(google_id) + + if not user: + # Check if user exists by email (for linking accounts) + existing_user = get_user_by_email(email) + if existing_user: + # Link Google account to existing user + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE users SET google_id = ?, email_verified = ? WHERE id = ? + ''', (google_id, True, existing_user['id'])) + conn.commit() + user = get_user_by_google_id(google_id) + else: + # Create new user with Google account + # Generate username from email + username_base = email.split('@')[0] + username = username_base + counter = 1 + + # Ensure username is unique + with database_connection() as conn: + cursor = conn.cursor() + while True: + cursor.execute('SELECT id FROM users WHERE username = ?', (username,)) + if not cursor.fetchone(): + break + username = f"{username_base}{counter}" + counter += 1 + + user = create_user( + username=username, + email=email, + password=None, # No password for OAuth users + google_id=google_id, + display_name=name, + first_name=given_name, + last_name=family_name, + email_verified=verified_email + ) + + if not user: + return jsonify({'error': 'Failed to create or retrieve user'}), 500 + + # Update last login + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE users SET last_login = CURRENT_TIMESTAMP, + login_count = login_count + 1, + email_verified = ? + WHERE id = ? + ''', (True, user['id'])) + conn.commit() + + # Generate JWT token + token = generate_token(user['id']) + + # Redirect to frontend with token + frontend_url = os.environ.get('FRONTEND_URL', 'https://anchorhost.eu') + redirect_url = f"{frontend_url}/?token={token}&user={urllib.parse.quote(json.dumps(user))}" + + # Redirect to frontend + return redirect(redirect_url) + + except requests.exceptions.RequestException as e: + logger.error(f"Google OAuth error: {e}") + return jsonify({'error': 'Failed to authenticate with Google'}), 500 + except Exception as e: + logger.error(f"Error in Google OAuth callback: {e}", exc_info=True) + return jsonify({'error': 'An error occurred during authentication'}), 500 + +@bp.route('/api/auth/apple/login', methods=['GET']) +def apple_login(): + """Initiate Apple OAuth login""" + if not APPLE_CLIENT_ID: + return jsonify({'error': 'Apple OAuth not configured'}), 500 + + # Generate state token for CSRF protection + nonce = secrets.token_urlsafe(32) + state = generate_oauth_state('apple', nonce) + + # Store state in session or return it to frontend + # Apple authorization URL + auth_url = ( + f"https://appleid.apple.com/auth/authorize?" + f"client_id={APPLE_CLIENT_ID}&" + f"redirect_uri={urllib.parse.quote(APPLE_REDIRECT_URI)}&" + f"response_type=code id_token&" + f"scope=name email&" + f"response_mode=form_post&" + f"nonce={urllib.parse.quote(nonce)}&" + f"state={state}" + ) + + return jsonify({'auth_url': auth_url, 'state': state}) + +@bp.route('/api/auth/apple/callback', methods=['POST']) +def apple_callback(): + """Handle Apple OAuth callback""" + try: + # Apple sends data as form_post (application/x-www-form-urlencoded) + code = request.form.get('code') + id_token = request.form.get('id_token') + state = request.form.get('state') + user_json = request.form.get('user') # Only sent on first login + + state_payload = verify_oauth_state(state, 'apple') + if not state_payload or not state_payload.get('nonce'): + return jsonify({'error': 'Invalid OAuth state'}), 400 + + if not id_token: + return jsonify({'error': 'ID token not provided'}), 400 + + # Validate Apple's signature, issuer, audience, expiry, and state-bound nonce. + try: + signing_key = jwt.PyJWKClient( + 'https://appleid.apple.com/auth/keys', + cache_jwk_set=True, + ).get_signing_key_from_jwt(id_token).key + decoded_id_token = jwt.decode( + id_token, + signing_key, + algorithms=['RS256'], + audience=APPLE_CLIENT_ID, + issuer='https://appleid.apple.com', + options={'require': ['exp', 'iat', 'sub', 'nonce']}, + ) + if not secrets.compare_digest(decoded_id_token['nonce'], state_payload['nonce']): + return jsonify({'error': 'Invalid Apple identity token nonce'}), 400 + except Exception as e: + logger.warning(f"Apple identity token validation failed: {e}") + return jsonify({'error': 'Invalid ID token'}), 400 + + apple_id = decoded_id_token.get('sub') + email = decoded_id_token.get('email') + + if not apple_id or not email: + return jsonify({'error': 'Failed to retrieve user information from Apple'}), 400 + + # Parse user info if provided (name is only sent on first login) + first_name = "" + last_name = "" + display_name = "" + + if user_json: + try: + user_data = json.loads(user_json) + name_data = user_data.get('name', {}) + first_name = name_data.get('firstName', '') + last_name = name_data.get('lastName', '') + if first_name or last_name: + display_name = f"{first_name} {last_name}".strip() + except: + pass + + # Check if user exists by Apple ID + user = get_user_by_apple_id(apple_id) + + if not user: + # Check if user exists by email (for linking accounts) + existing_user = get_user_by_email(email) + if existing_user: + # Link Apple account to existing user + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE users SET apple_id = ?, email_verified = ? WHERE id = ? + ''', (apple_id, True, existing_user['id'])) + conn.commit() + user = get_user_by_apple_id(apple_id) + else: + # Create new user with Apple account + # Generate username from email + username_base = email.split('@')[0] + username = username_base + counter = 1 + + # Ensure username is unique + with database_connection() as conn: + cursor = conn.cursor() + while True: + cursor.execute('SELECT id FROM users WHERE username = ?', (username,)) + if not cursor.fetchone(): + break + username = f"{username_base}{counter}" + counter += 1 + + user = create_user( + username=username, + email=email, + password=None, # No password for OAuth users + apple_id=apple_id, + display_name=display_name, + first_name=first_name, + last_name=last_name, + email_verified=True # Apple verified email + ) + + if not user: + return jsonify({'error': 'Failed to create or retrieve user'}), 500 + + # Update last login + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE users SET last_login = CURRENT_TIMESTAMP, + login_count = login_count + 1, + email_verified = ? + WHERE id = ? + ''', (True, user['id'])) + conn.commit() + + # Generate JWT token + token = generate_token(user['id']) + + # Redirect to frontend with token + frontend_url = os.environ.get('FRONTEND_URL', 'https://anchorhost.eu') + redirect_url = f"{frontend_url}/?token={token}&user={urllib.parse.quote(json.dumps(user))}" + + # Redirect to frontend + return redirect(redirect_url) + + except Exception as e: + logger.error(f"Error in Apple OAuth callback: {e}", exc_info=True) + return jsonify({'error': 'An error occurred during authentication'}), 500 + +@bp.route('/verify-email', methods=['GET']) +def verify_email(): + """Email verification page""" + token = request.args.get('token') + + if not token: + return ''' + + + + + Verification Failed + + + +
+

โŒ Verification Failed

+

The verification link is invalid or missing.

+ Go to Home +
+ + + ''', 400 + + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Find verification token + cursor.execute(''' + SELECT vt.*, u.username, u.email + FROM email_verification_tokens vt + JOIN users u ON vt.user_id = u.id + WHERE vt.token = ? + ''', (token,)) + + token_data = dict_from_row(cursor.fetchone()) + + if not token_data: + return ''' + + + + + Invalid Token + + + +
+

โŒ Invalid Token

+

This verification link is invalid or has already been used.

+ Go to Home +
+ + + ''', 404 + + # Check if token expired + expires_at = datetime.fromisoformat(token_data['expires_at']) + # Fix for offset-naive vs offset-aware comparison + now = datetime.now(timezone.utc) if expires_at.tzinfo else datetime.now() + if now > expires_at: + return ''' + + + + + Token Expired + + + +
+

โฐ Token Expired

+

This verification link has expired. Please request a new verification email.

+ Go to Home +
+ + + ''', 410 + + # Verify the email + cursor.execute(''' + UPDATE users + SET email_verified = TRUE, email_verification_token = NULL + WHERE id = ? + ''', (token_data['user_id'],)) + user_updated = cursor.rowcount > 0 + + # Delete the token + cursor.execute('DELETE FROM email_verification_tokens WHERE token = ?', (token,)) + + conn.commit() + + if user_updated: + # After successful verification, notify the user via WebSocket if they're connected + for client_id, client_info in connected_clients.items(): + if (client_info.get('authenticated') and + client_info.get('user_id') == token_data['user_id']): + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def send_notification(): + await send_message(client_id, { + 'type': 'email_verified', + 'message': 'Your email has been verified successfully!', + 'user_id': token_data['user_id'] + }) + + loop.run_until_complete(send_notification()) + loop.close() + except Exception as e: + logger.error(f"Error sending verification notification: {e}") + + # Send welcome email + email_svc = get_email_service() + if email_svc: + email_svc.send_welcome_email(token_data['email'], token_data['username']) + + logger.info(f"โœ… Email verified for user: {token_data['username']}") + + return ''' + + + + + Email Verified + + + +
+
โœ…
+

Email Verified!

+

Your email has been successfully verified. You can now log in and start using AnchorHost!

+ Go to Dashboard +
+ + + ''', 200 + + except Exception as e: + logger.error(f"Error verifying email: {e}", exc_info=True) + return ''' + + + + + Verification Error + + + +
+

โŒ Error

+

An error occurred during verification. Please try again or contact support.

+ Go to Home +
+ + + ''', 500 + +@bp.route('/api/verify-email', methods=['GET', 'POST']) +def verify_email_api(): + """Verify email with token""" + # Handle GET requests by redirecting to the HTML page + if request.method == 'GET': + token = request.args.get('token') + if token: + return redirect(f"/verify-email?token={token}") + return redirect("/verify-email") + + try: + data = request.get_json() + token = data.get('token') + + if not token: + return jsonify({'success': False, 'error': 'Token required'}), 400 + + with database_connection() as conn: + cursor = conn.cursor() + + # Find token + cursor.execute('SELECT user_id, expires_at FROM email_verification_tokens WHERE token = ?', (token,)) + token_data = dict_from_row(cursor.fetchone()) + + if not token_data: + return jsonify({'success': False, 'error': 'Invalid or expired token'}), 400 + + expires_at = datetime.fromisoformat(token_data['expires_at']) + # Fix for offset-naive vs offset-aware comparison + now = datetime.now(timezone.utc) if expires_at.tzinfo else datetime.now() + if now > expires_at: + return jsonify({'success': False, 'error': 'Token expired'}), 400 + + user_id = token_data['user_id'] + + # Verify user + cursor.execute('UPDATE users SET email_verified = TRUE, email_verification_token = NULL WHERE id = ?', (user_id,)) + + # Delete token + cursor.execute('DELETE FROM email_verification_tokens WHERE token = ?', (token,)) + + conn.commit() + + # Get user info for welcome email + cursor.execute('SELECT email, username FROM users WHERE id = ?', (user_id,)) + user = dict_from_row(cursor.fetchone()) + + # Send welcome email + if user: + try: + email_svc = get_email_service() + if email_svc: + email_svc.send_welcome_email(user['email'], user['username']) + except Exception as e: + logger.error(f"Failed to send welcome email: {e}") + + return jsonify({'success': True, 'message': 'Email verified successfully'}), 200 + + except Exception as e: + logger.error(f"Error verifying email: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/auth/config', methods=['GET']) +def get_auth_config(): + """Get authentication configuration (which OAuth providers are available)""" + def is_valid(val): + return bool(val and str(val).strip() and str(val) not in ['', 'None', 'undefined', 'your.service.identifier', 'your_team_id', 'your_key_id']) + + return jsonify({ + 'google_enabled': is_valid(GOOGLE_CLIENT_ID) and is_valid(GOOGLE_CLIENT_SECRET), + 'apple_enabled': is_valid(APPLE_CLIENT_ID) and is_valid(APPLE_TEAM_ID) and is_valid(APPLE_KEY_ID) and is_valid(APPLE_PRIVATE_KEY) + }), 200 diff --git a/routes/billing.py b/routes/billing.py new file mode 100644 index 0000000..bf46352 --- /dev/null +++ b/routes/billing.py @@ -0,0 +1,484 @@ +"""Billing API routes for AnchorHost.""" + + +from currency_service import calculate_stripe_amount, get_cached_exchange_rates +from database_service import database_connection, dict_from_row +from email_service import get_email_service +from flask import Blueprint, jsonify, request +from runtime_config import ( + CACHE_DURATION, + EXCHANGE_RATE_CACHE, + STRIPE_CURRENCY_MAP, + STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET, + logger, +) +from server_billing import get_user_monthly_fees +from server_capacity import calculate_monthly_server_cost +from server_records import get_user_servers +import stripe +from user_service import add_credit_transaction, authenticate_user, get_bearer_token, get_user_by_id, update_user_credits + +__all__ = ['create_checkout_session', 'estimate_monthly_cost', 'get_billing_history', 'get_exchange_rates_api', 'get_session_status', 'payment_cancelled', 'payment_success', 'stripe_webhook'] + + + +stripe.api_key = STRIPE_SECRET_KEY + +bp = Blueprint("billing", __name__) + + +@bp.route('/api/user/billing/history', methods=['GET']) +def get_billing_history(): + """Get user's billing history""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + limit = request.args.get('limit', 10, type=int) + + # Pobierz opล‚aty miesiฤ™czne + monthly_fees = get_user_monthly_fees(user['id'], limit) + + # Pobierz informacje o serwerach i datach rozliczeniowych + servers = get_user_servers(user['id']) + server_billing_info = [] + + for server in servers: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM server_billing WHERE server_id = ? + ''', (server['id'],)) + billing = dict_from_row(cursor.fetchone()) + + if billing: + server['billing_info'] = billing + server_billing_info.append(server) + + return jsonify({ + 'success': True, + 'monthly_fees': monthly_fees, + 'active_servers': server_billing_info, + 'total_active_servers': len([s for s in servers if s['status'] in ['Running', 'Starting']]) + }), 200 + + except Exception as e: + logger.error(f"Error getting billing history: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/user/billing/estimate', methods=['POST']) +def estimate_monthly_cost(): + """Estimate monthly cost for a server configuration""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + + if not data: + return jsonify({'success': False, 'error': 'No configuration provided'}), 400 + + # Utwรณrz mock serwer do kalkulacji + mock_server = { + 'cpu': data.get('cpu', '1 vCPU'), + 'ram': data.get('ram', '1GB'), + 'storage': data.get('storage', '10GB'), + 'type': data.get('serverType', 'vps'), + 'name': 'Estimation' + } + + monthly_cost = calculate_monthly_server_cost(mock_server) + + return jsonify({ + 'success': True, + 'monthly_cost_credits': monthly_cost, + 'monthly_cost_euro': monthly_cost / 100, # 100 kredytรณw = 1 euro + 'estimated_daily_cost': monthly_cost / 30, # Dzienny koszt + 'currency': 'EUR', + 'pricing_model': { + 'cpu_per_vcpu': '0.25 EUR', + 'ram_per_gb': '0.25 EUR', + 'storage_per_gb': '0.05 EUR', + 'server_type_multipliers': { + 'vps': '1.0x', + 'game': '1.5x', + 'website': '1.2x', + 'database': '1.3x' + } + } + }), 200 + + except Exception as e: + logger.error(f"Error estimating monthly cost: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/stripe/create-checkout-session', methods=['POST']) +def create_checkout_session(): + """Create a Stripe checkout session""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() + credits = data.get('credits', 1000) + currency = data.get('currency', 'USD').upper() + + if credits < 100: + return jsonify({'error': 'Minimum purchase is 100 credits'}), 400 + + if currency not in STRIPE_CURRENCY_MAP: + return jsonify({'error': f'Currency {currency} not supported'}), 400 + + # Calculate amount in smallest currency unit + amount = calculate_stripe_amount(credits, currency) + stripe_currency = STRIPE_CURRENCY_MAP[currency] + + # Get currency symbol for display + currency_data = next((c for c in [ + {'code': 'USD', 'symbol': '$'}, + {'code': 'EUR', 'symbol': 'โ‚ฌ'}, + {'code': 'GBP', 'symbol': 'ยฃ'}, + {'code': 'PLN', 'symbol': 'zล‚'}, + {'code': 'PHP', 'symbol': 'โ‚ฑ'}, + {'code': 'CZK', 'symbol': 'Kฤ'} + ] if c['code'] == currency), {'code': currency, 'symbol': currency}) + + # Create Stripe checkout session + session = stripe.checkout.Session.create( + payment_method_types=[ + 'card', + 'paypal', + 'blik', + 'revolut_pay', + 'ideal', + 'bancontact', + 'eps', + 'twint', + 'link', + 'multibanco', + 'klarna' + ], + line_items=[{ + 'price_data': { + 'currency': stripe_currency, + 'product_data': { + 'name': f'{credits} AnchorHost Credits', + 'description': f'Purchase {credits} credits for your AnchorHost account', + 'images': ['https://your-logo-url.com/logo.png'], # Optional: Add your logo + }, + 'unit_amount': amount, + }, + 'quantity': 1, + }], + mode='payment', + success_url=request.host_url + f'payment-success?session_id={{CHECKOUT_SESSION_ID}}', + cancel_url=request.host_url + 'payment-cancelled', + metadata={ + 'user_id': user['id'], + 'username': user['username'], + 'credits': str(credits), + 'currency': currency + }, + customer_email=user['email'], + ) + + logger.info(f"Created Stripe checkout session for user {user['username']}: {credits} credits, {currency}") + + return jsonify({ + 'success': True, + 'sessionId': session.id, + 'url': session.url + }), 200 + + except stripe.error.StripeError as e: + logger.error(f"Stripe error: {e}") + return jsonify({'error': f'Payment service error: {str(e)}'}), 500 + except Exception as e: + logger.error(f"Error creating checkout session: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/stripe/webhook', methods=['POST']) +def stripe_webhook(): + """Handle Stripe webhook events""" + payload = request.get_data() + sig_header = request.headers.get('Stripe-Signature') + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, STRIPE_WEBHOOK_SECRET + ) + except ValueError as e: + logger.error(f"Invalid payload: {e}") + return jsonify({'error': 'Invalid payload'}), 400 + except stripe.error.SignatureVerificationError as e: + logger.error(f"Invalid signature: {e}") + return jsonify({'error': 'Invalid signature'}), 400 + + # Handle the event + if event['type'] == 'checkout.session.completed': + session = event['data']['object'] + + # Get metadata + user_id = session['metadata'].get('user_id') + username = session['metadata'].get('username') + credits = int(session['metadata'].get('credits', 0)) + currency = session['metadata'].get('currency', 'USD') + + logger.info(f"Payment completed: {username} purchased {credits} credits for {session['amount_total']/100} {currency}") + + # Add credits to user account + try: + user = get_user_by_id(user_id) + if user: + new_credits = user.get('credits', 0) + credits + if update_user_credits(user_id, new_credits): + add_credit_transaction( + user_id, + credits, + 'purchase', + f'Stripe payment: {credits} credits purchased for {session["amount_total"]/100} {currency}' + ) + logger.info(f"โœ… Credits added to {username}: +{credits} (new balance: {new_credits})") + + # Send confirmation email + email_svc = get_email_service() + if email_svc: + try: + email_svc.send_payment_confirmation( + user['email'], + username, + credits, + session['amount_total'] / 100, + currency + ) + except Exception as e: + logger.error(f"Failed to send confirmation email: {e}") + else: + logger.error(f"Failed to update credits for {username}") + else: + logger.error(f"User not found: {user_id}") + except Exception as e: + logger.error(f"Error processing payment for {username}: {e}") + + elif event['type'] == 'checkout.session.async_payment_succeeded': + # Handle async payment success (e.g., bank transfers) + session = event['data']['object'] + logger.info(f"Async payment succeeded: {session['id']}") + + elif event['type'] == 'checkout.session.async_payment_failed': + # Handle async payment failure + session = event['data']['object'] + logger.warning(f"Async payment failed: {session['id']}") + + return jsonify({'success': True}), 200 + +@bp.route('/api/stripe/session-status/', methods=['GET']) +def get_session_status(session_id): + """Get the status of a Stripe checkout session""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + session = stripe.checkout.Session.retrieve(session_id) + + return jsonify({ + 'success': True, + 'status': session.status, + 'payment_status': session.payment_status, + 'amount_total': session.amount_total, + 'currency': session.currency, + 'customer_email': session.customer_email + }), 200 + + except stripe.error.StripeError as e: + logger.error(f"Stripe error: {e}") + return jsonify({'error': str(e)}), 500 + except Exception as e: + logger.error(f"Error getting session status: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/payment-success') +def payment_success(): + """Payment success page""" + session_id = request.args.get('session_id') + + return f''' + + + + + Payment Successful + + + + +
+
โœ…
+

Payment Successful!

+

Your credits have been added to your account. Thank you for your purchase!

+

This window will close automatically in 5 seconds...

+ Return to Dashboard +
Session ID: {session_id}
+
+ + + ''' + +@bp.route('/payment-cancelled') +def payment_cancelled(): + """Payment cancelled page""" + return ''' + + + + + Payment Cancelled + + + + +
+
โŒ
+

Payment Cancelled

+

Your payment was cancelled. No charges were made to your account.

+

This window will close automatically in 3 seconds...

+ Return to Dashboard +
+ + + ''' + +@bp.route('/api/exchange-rates', methods=['GET']) +def get_exchange_rates_api(): + """Get current exchange rates""" + try: + rates = get_cached_exchange_rates() + + return jsonify({ + 'success': True, + 'rates': rates, + 'base_currency': 'USD', + 'last_updated': EXCHANGE_RATE_CACHE.get('last_updated'), + 'cache_duration_seconds': CACHE_DURATION + }), 200 + + except Exception as e: + logger.error(f"Error getting exchange rates: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 diff --git a/routes/domains.py b/routes/domains.py new file mode 100644 index 0000000..bea47da --- /dev/null +++ b/routes/domains.py @@ -0,0 +1,298 @@ +"""Domains API routes for AnchorHost.""" + + +from domain_runtime import domain_manager +from domain_service import create_domain_record, delete_domain_record, get_domain_by_id, get_user_domains, link_domain_to_server, unlink_domain_from_server +from flask import Blueprint, jsonify, request +from runtime_config import ( + logger, +) +from server_records import get_server_by_id +from user_service import authenticate_user, get_bearer_token, require_admin +from websocket_service import sync_user_domains_with_dns + +__all__ = ['create_domain_api', 'delete_domain_api', 'fix_all_dnssec_api', 'get_domains_api', 'link_domain_api', 'sync_domains_api', 'test_domain_api', 'unlink_domain_api', 'verify_dnssec_api'] + + + +bp = Blueprint("domains", __name__) + + +@bp.route('/api/dnssec/fix-all', methods=['POST']) +def fix_all_dnssec_api(): + """Fix DNSSEC delegation for all user domains""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + # Only allow admin users + if not user or user.get('role') != 'admin': + return jsonify({'error': 'Admin access required'}), 403 + + if not domain_manager: + return jsonify({'error': 'Domain manager not available'}), 503 + + # Run the fix + domain_manager.fix_all_dnssec_delegations() + + return jsonify({ + 'success': True, + 'message': 'DNSSEC fix process initiated. Check logs for details.' + }), 200 + + except Exception as e: + logger.error(f"Error fixing DNSSEC: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/dnssec/verify/', methods=['GET']) +def verify_dnssec_api(username): + """Verify DNSSEC delegation for a user""" + try: + token = get_bearer_token() + user = authenticate_user(token) + + # Users can only check their own DNSSEC, admins can check any + if not user or (user.get('role') != 'admin' and user.get('username') != username): + return jsonify({'error': 'Unauthorized'}), 403 + + if not domain_manager: + return jsonify({'error': 'Domain manager not available'}), 503 + + result = domain_manager.setup_dnssec_delegation(username) + + return jsonify(result), 200 if result.get('success') else 500 + + except Exception as e: + logger.error(f"Error verifying DNSSEC: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/domains', methods=['GET']) +def get_domains_api(): + """Get domains API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + include_serverless = request.args.get('include_serverless', 'true').lower() == 'true' + domains = get_user_domains(user['id'], include_serverless) + + return jsonify({ + 'success': True, + 'domains': domains, + 'count': len(domains) + }), 200 + + except Exception as e: + logger.error(f"Error getting domains: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/domains/sync', methods=['POST']) +def sync_domains_api(): + """Sync domains between database and DNS""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + success = sync_user_domains_with_dns(user['id']) + + if success: + domains = get_user_domains(user['id'], True) + return jsonify({ + 'success': True, + 'message': 'Domains synced successfully', + 'domains': domains, + 'count': len(domains) + }), 200 + else: + return jsonify({'error': 'Failed to sync domains'}), 500 + + except Exception as e: + logger.error(f"Error syncing domains: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/domains', methods=['POST']) +def create_domain_api(): + """Create domain API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + if not domain_manager: + return jsonify({'error': 'Domain service not available'}), 503 + + data = request.get_json() + subdomain = data.get('subdomain') + server_id = data.get('serverId') + + if not subdomain: + return jsonify({'error': 'Subdomain name required'}), 400 + + # Check if subdomain is available + availability_result = domain_manager.check_domain_availability(subdomain, user['username']) + if not availability_result.get('available', False): + return jsonify({'error': availability_result.get('message', 'Domain not available')}), 400 + + # Create the subdomain + result = domain_manager.create_subdomain(user['username'], subdomain) + + if result['success'] and result.get('domain_data'): + # Save to database + domain_record = { + 'user_id': user['id'], + 'subdomain': result['domain_data']['subdomain'], + 'full_domain': result['domain_data']['domain'], + 'record_type': result['domain_data']['record_type'], + 'ip_address': result['domain_data']['ip_address'], + 'ttl': result['domain_data']['ttl'], + 'server_id': server_id, + 'created_at': result['domain_data']['created_at'] + } + + db_domain = create_domain_record(domain_record) + + if db_domain: + return jsonify({ + 'success': True, + 'domain': db_domain, + 'message': result['message'] + }), 201 + else: + return jsonify({'error': 'Domain created in DNS but failed to save to database'}), 500 + else: + return jsonify({'error': result.get('message', 'Failed to create domain')}), 500 + + except Exception as e: + logger.error(f"Error creating domain: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/domains/', methods=['DELETE']) +def delete_domain_api(domain_id): + """Delete domain API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + if not domain_manager: + return jsonify({'error': 'Domain service not available'}), 503 + + # Get domain from database + domain = get_domain_by_id(domain_id) + if not domain or domain['user_id'] != user['id']: + return jsonify({'error': 'Domain not found or access denied'}), 404 + + # Delete from DNS + result = domain_manager.delete_subdomain(user['username'], domain['subdomain']) + + if result['success']: + # Delete from database + if delete_domain_record(domain_id): + return jsonify({ + 'success': True, + 'message': result['message'] + }), 200 + else: + return jsonify({'error': 'Domain deleted from DNS but failed to delete from database'}), 500 + else: + return jsonify({'error': result.get('message', 'Failed to delete domain')}), 500 + + except Exception as e: + logger.error(f"Error deleting domain: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/domains//link', methods=['POST']) +def link_domain_api(domain_id): + """Link domain to server API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() + server_id = data.get('serverId') + + if not server_id: + return jsonify({'error': 'Server ID required'}), 400 + + # Verify domain belongs to user + domain = get_domain_by_id(domain_id) + if not domain or domain['user_id'] != user['id']: + return jsonify({'error': 'Domain not found or access denied'}), 404 + + # Verify server belongs to user + server = get_server_by_id(server_id) + if not server or server['owner_id'] != user['id']: + return jsonify({'error': 'Server not found or access denied'}), 404 + + # Check if server is a website or VPS + if server['type'] not in ['website', 'vps']: + return jsonify({'error': 'Domain can only be linked to Website or VPS servers'}), 400 + + # Link domain to server (this will create IngressRoute) + if link_domain_to_server(domain_id, server_id): + return jsonify({ + 'success': True, + 'message': f'Domain linked to server {server["name"]} with IngressRoute created' + }), 200 + else: + return jsonify({'error': 'Failed to link domain'}), 500 + + except Exception as e: + logger.error(f"Error linking domain: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/domains//unlink', methods=['POST']) +def unlink_domain_api(domain_id): + """Unlink domain from server API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + # Verify domain belongs to user + domain = get_domain_by_id(domain_id) + if not domain or domain['user_id'] != user['id']: + return jsonify({'error': 'Domain not found or access denied'}), 404 + + # Unlink domain from server (this will delete IngressRoute) + if unlink_domain_from_server(domain_id): + return jsonify({ + 'success': True, + 'message': 'Domain unlinked from server and IngressRoute removed' + }), 200 + else: + return jsonify({'error': 'Failed to unlink domain'}), 500 + + except Exception as e: + logger.error(f"Error unlinking domain: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/domains/test', methods=['GET']) +def test_domain_api(): + """Test domain API connection""" + if not require_admin(): + return jsonify({'error': 'Unauthorized'}), 403 + try: + if not domain_manager: + return jsonify({'success': False, 'message': 'Domain manager not initialized'}), 503 + + success = domain_manager.test_connection() + + return jsonify({ + 'success': success, + 'message': 'Domain API connection test completed', + 'connected': success + }), 200 + + except Exception as e: + logger.error(f"Error testing domain API: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/routes/public.py b/routes/public.py new file mode 100644 index 0000000..dc6cbdb --- /dev/null +++ b/routes/public.py @@ -0,0 +1,126 @@ +"""Public API routes for AnchorHost.""" + + +from database_service import database_connection, dict_from_row +from datetime import datetime +from domain_runtime import domain_manager +from flask import Blueprint, jsonify, request +import json +from kubernetes_service import k8s_manager +from runtime_config import ( + logger, +) +from showcase_service import get_homepage_showcase_links +from user_service import get_pricing, is_demo_enabled + +__all__ = ['get_announcements', 'get_public_pricing', 'get_public_settings', 'health_check', 'public_homepage_showcase', 'public_stats'] + + + +bp = Blueprint("public", __name__) + + +@bp.route('/health', methods=['GET']) +def health_check(): + database_status = 'connected' + try: + with database_connection() as conn: + conn.cursor().execute('SELECT 1') + except Exception: + logger.exception("Health check could not reach the database") + database_status = 'disconnected' + + return jsonify({ + 'status': 'ok' if database_status == 'connected' else 'unavailable', + 'kubernetes': 'connected' if k8s_manager.connected else 'disconnected', + 'database': database_status, + 'domain_manager': 'available' if domain_manager else 'unavailable', + 'timestamp': datetime.now().isoformat() + }), 200 if database_status == 'connected' else 503 + +@bp.route('/api/announcements', methods=['GET']) +def get_announcements(): + """Get public announcements""" + try: + limit = request.args.get('limit', default=10, type=int) + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT a.*, u.username as author_name + FROM announcements a + JOIN users u ON a.author_id = u.id + ORDER BY a.created_at DESC + LIMIT ? + ''', (limit,)) + + announcements = [dict_from_row(row) for row in cursor.fetchall()] + + # Parse tags if they are stored as JSON string or comma-separated + for ann in announcements: + if ann.get('tags'): + try: + # Try parsing as JSON first + ann['tags'] = json.loads(ann['tags']) + except: + # Fallback to comma-separated if simple string + if ',' in ann['tags']: + ann['tags'] = [tag.strip() for tag in ann['tags'].split(',')] + else: + ann['tags'] = [ann['tags']] + else: + ann['tags'] = [] + + return jsonify({'success': True, 'announcements': announcements}) + + except Exception as e: + logger.error(f"Error fetching announcements: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/public/homepage-showcase', methods=['GET']) +def public_homepage_showcase(): + """Public: three configurable customer-site URLs for the marketing homepage.""" + try: + links = get_homepage_showcase_links() + return jsonify({'success': True, 'links': links}) + except Exception as e: + logger.error(f"public_homepage_showcase: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/public/stats', methods=['GET']) +def public_stats(): + """Return public platform statistics.""" + try: + with database_connection() as conn: + cursor = conn.cursor() + # Use a named column 'count' to ensure compatibility with DictCursor + cursor.execute("SELECT COUNT(*) as count FROM users") + row = cursor.fetchone() + user_count = row['count'] if row else 0 + + # For a more impressive number, add a base number if it's a new installation + # or just return the real count. User asked for "how much users there are". + return jsonify({ + 'success': True, + 'user_count': user_count, + # Add other stats if helpful + 'total_customers': user_count + }) + except Exception as e: + logger.error(f"public_stats: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/pricing', methods=['GET']) +def get_public_pricing(): + """Get current pricing (publicly accessible).""" + return jsonify(get_pricing()), 200 + +@bp.route('/api/settings', methods=['GET']) +def get_public_settings(): + """Get public panel settings.""" + return jsonify({ + 'success': True, + 'settings': { + 'demo_servers_enabled': is_demo_enabled() + } + }), 200 diff --git a/routes/servers.py b/routes/servers.py new file mode 100644 index 0000000..2c269a6 --- /dev/null +++ b/routes/servers.py @@ -0,0 +1,468 @@ +"""Servers API routes for AnchorHost.""" + + +from datetime import datetime +from flask import Blueprint, jsonify, request +import json +from kubernetes_service import k8s_manager +import requests +from runtime_config import ( + MODRINTH_API_BASE, + logger, +) +from server_access import expose_port, remove_port +from server_capacity import get_cluster_capacity, validate_server_capacity +from server_lifecycle import adjust_server_parameters +from server_provisioning import reinstall_server, sync_server_ports_from_k8s, sync_server_status_from_k8s +from server_records import get_server_by_id, get_user_servers +from user_service import add_credits, authenticate_user, get_bearer_token, get_user_credits, require_admin + +__all__ = ['add_credits_api', 'adjust_server_parameters_api', 'check_capacity_api', 'download_plugin_to_server', 'expose_port_api', 'get_cluster_capacity_api', 'get_modrinth_project', 'get_modrinth_tags', 'get_modrinth_version', 'get_modrinth_versions', 'reinstall_server_api', 'remove_port_api', 'search_modrinth_plugins', 'sync_server_status_api'] + + + +bp = Blueprint("servers", __name__) + + +@bp.route('/api/capacity/check', methods=['POST']) +def check_capacity_api(): + """API endpoint to check capacity for a server configuration""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'No configuration provided'}), 400 + + # Validate required fields + required_fields = ['cpu', 'ram', 'storage', 'serverType'] + for field in required_fields: + if field not in data: + return jsonify({'success': False, 'error': f'Missing field: {field}'}), 400 + + # Run capacity validation + capacity_result = validate_server_capacity(data) + + # Also get cluster overview + cluster_capacity = get_cluster_capacity() + + # Extract useful information for frontend + response_data = { + 'success': True, + 'capacity_check': { + 'success': capacity_result.get('success', False), + 'message': capacity_result.get('message', ''), + 'suitable_nodes': capacity_result.get('suitable_nodes', []), + 'node_details': capacity_result.get('node_details', []), + 'details': capacity_result.get('details', []), + 'error_details': capacity_result.get('error_details', []), + 'summary': capacity_result.get('summary', {}) + }, + 'cluster_capacity': cluster_capacity, + 'request': { + 'cpu': data['cpu'], + 'ram': data['ram'], + 'storage': data['storage'], + 'server_type': data['serverType'] + } + } + + return jsonify(response_data), 200 + + except Exception as e: + logger.error(f"Error in capacity check API: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@bp.route('/api/capacity/cluster', methods=['GET']) +def get_cluster_capacity_api(): + """API endpoint to get cluster capacity overview""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + capacity = get_cluster_capacity() + + return jsonify(capacity), 200 if capacity['success'] else 500 + + except Exception as e: + logger.error(f"Error getting cluster capacity: {e}") + return jsonify({ + 'success': False, + 'error': str(e), + 'timestamp': datetime.now().isoformat() + }), 500 + +@bp.route('/api/servers//sync', methods=['POST']) +def sync_server_status_api(server_id): + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + sync_server_status_from_k8s(server_id, user['id']) + sync_server_ports_from_k8s(server_id, user['id']) + + server = get_server_by_id(server_id) + if not server: + return jsonify({'error': 'Server not found'}), 404 + + return jsonify({ + 'success': True, + 'server': server + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/servers//reinstall', methods=['POST']) +def reinstall_server_api(server_id): + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + success = reinstall_server(server_id, user['id']) + + if success: + return jsonify({ + 'success': True, + 'message': 'Server reinstall started' + }), 200 + else: + return jsonify({'error': 'Failed to reinstall server'}), 500 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/users//credits', methods=['POST']) +def add_credits_api(user_id): + try: + if not require_admin(): + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json(silent=True) or {} + amount = data.get('amount') + if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0: + return jsonify({'error': 'Invalid amount'}), 400 + + success = add_credits(user_id, amount) + + if success: + return jsonify({ + 'success': True, + 'message': f'Added {amount} credits', + 'new_balance': get_user_credits(user_id) + }), 200 + else: + return jsonify({'error': 'Failed to add credits'}), 500 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/modrinth/search', methods=['GET']) +def search_modrinth_plugins(): + """Search for plugins on Modrinth""" + try: + query = request.args.get('query', '') + limit = int(request.args.get('limit', 20)) + offset = int(request.args.get('offset', 0)) + project_type = request.args.get('project_type', 'plugin') # plugin, mod, modpack, etc. + loaders = request.args.get('loaders', '') # paper, spigot, bukkit, etc. + game_versions = request.args.get('game_versions', '') + + # Build facets array for Modrinth API + # Facets format: [['category:value1', 'category:value2'], ['category2:value3']] + # Each inner array represents OR conditions, outer array represents AND conditions + facets = [['project_type:' + project_type]] + + if loaders: + loader_list = [loader.strip() for loader in loaders.split(',') if loader.strip()] + if loader_list: + # Create a single array with all loader filters (OR condition) + loader_facets = ['loaders:' + loader for loader in loader_list] + facets.append(loader_facets) + + if game_versions: + version_list = [version.strip() for version in game_versions.split(',') if version.strip()] + if version_list: + # Create a single array with all version filters (OR condition) + version_facets = ['versions:' + version for version in version_list] + facets.append(version_facets) + + # Build query parameters + params = { + 'query': query, + 'limit': limit, + 'offset': offset, + 'facets': json.dumps(facets) + } + + response = requests.get(f'{MODRINTH_API_BASE}/search', params=params, headers={ + 'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)' + }) + + if response.status_code == 200: + return jsonify(response.json()), 200 + else: + return jsonify({'error': 'Failed to search Modrinth', 'status': response.status_code}), response.status_code + + except Exception as e: + logger.error(f"Error searching Modrinth: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/modrinth/project/', methods=['GET']) +def get_modrinth_project(project_id): + """Get project details from Modrinth""" + try: + response = requests.get( + f'{MODRINTH_API_BASE}/project/{project_id}', + headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'} + ) + + if response.status_code == 200: + return jsonify(response.json()), 200 + else: + return jsonify({'error': 'Project not found', 'status': response.status_code}), response.status_code + + except Exception as e: + logger.error(f"Error fetching Modrinth project: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/modrinth/project//versions', methods=['GET']) +def get_modrinth_versions(project_id): + """Get versions for a Modrinth project""" + try: + game_versions = request.args.get('game_versions', '') + loaders = request.args.get('loaders', '') + + params = {} + if game_versions: + params['game_versions'] = game_versions.split(',') + if loaders: + params['loaders'] = loaders.split(',') + + response = requests.get( + f'{MODRINTH_API_BASE}/project/{project_id}/version', + params=params, + headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'} + ) + + if response.status_code == 200: + return jsonify(response.json()), 200 + else: + return jsonify({'error': 'Failed to fetch versions', 'status': response.status_code}), response.status_code + + except Exception as e: + logger.error(f"Error fetching Modrinth versions: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/modrinth/project//version/', methods=['GET']) +def get_modrinth_version(project_id, version_id): + """Get specific version details""" + try: + response = requests.get( + f'{MODRINTH_API_BASE}/version/{version_id}', + headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'} + ) + + if response.status_code == 200: + return jsonify(response.json()), 200 + else: + return jsonify({'error': 'Version not found', 'status': response.status_code}), response.status_code + + except Exception as e: + logger.error(f"Error fetching Modrinth version: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/modrinth/tags', methods=['GET']) +def get_modrinth_tags(): + """Get available tags/categories from Modrinth""" + try: + response = requests.get( + f'{MODRINTH_API_BASE}/tag', + headers={'User-Agent': 'AnchorHost/1.0 (contact@anchorhost.eu)'} + ) + + if response.status_code == 200: + return jsonify(response.json()), 200 + else: + return jsonify({'error': 'Failed to fetch tags', 'status': response.status_code}), response.status_code + + except Exception as e: + logger.error(f"Error fetching Modrinth tags: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/plugins/download-to-server', methods=['POST']) +def download_plugin_to_server(): + """Download a plugin from URL and upload it to a server's plugins directory""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Not authenticated'}), 401 + + data = request.get_json() + server_id = data.get('server_id') + plugin_url = data.get('plugin_url') + plugin_name = data.get('plugin_name', 'plugin') + file_name = data.get('file_name', 'plugin.jar') + + if not server_id or not plugin_url: + return jsonify({'error': 'Server ID and plugin URL are required'}), 400 + + # Verify server belongs to user + user_servers = get_user_servers(user['id']) + server = next((s for s in user_servers if s['id'] == server_id), None) + + if not server: + return jsonify({'error': 'Server not found or access denied'}), 404 + + # Check if server is Minecraft server + if server.get('image') != 'minecraft' or server.get('type') != 'game': + return jsonify({'error': 'Plugin downloads are only available for Minecraft game servers'}), 400 + + # Download plugin file + logger.info(f"Downloading plugin from {plugin_url} for server {server_id}") + plugin_response = requests.get(plugin_url, stream=True, timeout=30) + + if plugin_response.status_code != 200: + return jsonify({'error': f'Failed to download plugin: HTTP {plugin_response.status_code}'}), 500 + + # Determine plugins directory based on server type + # For Minecraft servers, plugins go in /data/plugins directory + plugins_dir = '/data/plugins' + file_path = f'{plugins_dir}/{file_name}' + + # Save plugin to temporary file first + import tempfile + import os + with tempfile.NamedTemporaryFile(delete=False, suffix='.jar') as temp_file: + temp_file_path = temp_file.name + for chunk in plugin_response.iter_content(chunk_size=8192): + if chunk: + temp_file.write(chunk) + + try: + # Upload to server + namespace = f"user-{user['username']}".lower() + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return jsonify({'error': 'Server pod not found'}), 404 + + # Ensure plugins directory exists + k8s_manager.exec_command(namespace, pods[0], ['mkdir', '-p', plugins_dir]) + + # Upload file using write_file_binary_from_disk + success = k8s_manager.write_file_binary_from_disk(namespace, pods[0], file_path, temp_file_path) + + if success: + logger.info(f"Plugin {file_name} successfully uploaded to server {server_id}") + return jsonify({ + 'success': True, + 'message': f'Plugin {plugin_name} downloaded and installed successfully', + 'file_path': file_path + }), 200 + else: + return jsonify({'error': 'Failed to upload plugin to server'}), 500 + + finally: + # Clean up temporary file + try: + if os.path.exists(temp_file_path): + os.remove(temp_file_path) + except Exception as e: + logger.warning(f"Failed to remove temporary file: {e}") + + except requests.exceptions.RequestException as e: + logger.error(f"Error downloading plugin: {e}") + return jsonify({'error': f'Failed to download plugin: {str(e)}'}), 500 + except Exception as e: + logger.error(f"Error downloading plugin to server: {e}") + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/servers//ports', methods=['POST']) +def expose_port_api(server_id): + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() + internal_port = data.get('internalPort') + protocol = data.get('protocol', 'TCP') + + if not internal_port: + return jsonify({'error': 'Internal port required'}), 400 + + success = expose_port(server_id, user['id'], internal_port, protocol) + + if success: + return jsonify({ + 'success': True, + 'message': f'Port {internal_port} exposed successfully' + }), 200 + else: + return jsonify({'error': 'Failed to expose port'}), 500 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/servers//ports/', methods=['DELETE']) +def remove_port_api(server_id, internal_port): + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() or {} + protocol = data.get('protocol', 'TCP') + + success = remove_port(server_id, user['id'], internal_port, protocol) + + if success: + return jsonify({ + 'success': True, + 'message': f'Port {internal_port}/{protocol} removed successfully' + }), 200 + else: + return jsonify({'error': 'Failed to remove port'}), 500 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/servers//adjust-parameters', methods=['POST']) +def adjust_server_parameters_api(server_id): + """API endpoint to adjust server parameters (upgrade/downgrade)""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() + if not data: + return jsonify({'error': 'Request body required'}), 400 + + new_cpu = data.get('cpu') + new_ram = data.get('ram') + new_storage = data.get('storage') + + if not new_cpu or not new_ram or not new_storage: + return jsonify({'error': 'cpu, ram, and storage are required'}), 400 + + result = adjust_server_parameters(server_id, user['id'], new_cpu, new_ram, new_storage) + + return jsonify(result), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/routes/tickets.py b/routes/tickets.py new file mode 100644 index 0000000..2511e5b --- /dev/null +++ b/routes/tickets.py @@ -0,0 +1,179 @@ +"""Tickets API routes for AnchorHost.""" + + +from flask import Blueprint, jsonify, request +from ticket_service import add_ticket_message, create_ticket, get_all_tickets, get_ticket, get_ticket_stats, get_user_tickets, update_ticket_status +from user_service import authenticate_user, get_bearer_token + +__all__ = ['add_ticket_message_api', 'create_ticket_api', 'get_ticket_api', 'get_ticket_stats_api', 'get_tickets_api', 'update_ticket_status_api'] + + + +bp = Blueprint("tickets", __name__) + + +@bp.route('/api/tickets', methods=['GET']) +def get_tickets_api(): + """Get tickets API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + status_filter = request.args.get('status') + priority_filter = request.args.get('priority') + + if user['role'] == 'admin': + tickets = get_all_tickets(status_filter, priority_filter) + else: + include_closed = request.args.get('include_closed', 'false').lower() == 'true' + tickets = get_user_tickets(user['id'], include_closed) + + return jsonify({ + 'success': True, + 'tickets': tickets + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/tickets', methods=['POST']) +def create_ticket_api(): + """Create ticket API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() + subject = data.get('subject') + description = data.get('description') + priority = data.get('priority', 'medium') + category = data.get('category', 'general') + + if not subject or not description: + return jsonify({'error': 'Subject and description required'}), 400 + + ticket = create_ticket(user['id'], subject, description, priority, category) + + if ticket: + return jsonify({ + 'success': True, + 'ticket': ticket + }), 201 + else: + return jsonify({'error': 'Failed to create ticket'}), 500 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/tickets/', methods=['GET']) +def get_ticket_api(ticket_id): + """Get ticket details API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + ticket = get_ticket(ticket_id) + if not ticket: + return jsonify({'error': 'Ticket not found'}), 404 + + # Check access + if user['role'] != 'admin' and ticket['user_id'] != user['id']: + return jsonify({'error': 'Access denied'}), 403 + + return jsonify({ + 'success': True, + 'ticket': ticket + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/tickets//messages', methods=['POST']) +def add_ticket_message_api(ticket_id): + """Add message to ticket API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json() + message = data.get('message') + + if not message: + return jsonify({'error': 'Message required'}), 400 + + # Verify ticket access + ticket = get_ticket(ticket_id) + if not ticket: + return jsonify({'error': 'Ticket not found'}), 404 + + if user['role'] != 'admin' and ticket['user_id'] != user['id']: + return jsonify({'error': 'Access denied'}), 403 + + is_admin = (user['role'] == 'admin') + message_data = add_ticket_message(ticket_id, user['id'], message, is_admin) + + if message_data: + return jsonify({ + 'success': True, + 'message': message_data + }), 201 + else: + return jsonify({'error': 'Failed to send message'}), 500 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/tickets//status', methods=['PUT']) +def update_ticket_status_api(ticket_id): + """Update ticket status API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user or user['role'] != 'admin': + return jsonify({'error': 'Admin access required'}), 403 + + data = request.get_json() + status = data.get('status') + + if not status: + return jsonify({'error': 'Status required'}), 400 + + success = update_ticket_status(ticket_id, status, user['id']) + + if success: + return jsonify({ + 'success': True, + 'message': 'Ticket status updated' + }), 200 + else: + return jsonify({'error': 'Failed to update ticket status'}), 500 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@bp.route('/api/tickets/stats', methods=['GET']) +def get_ticket_stats_api(): + """Get ticket statistics API endpoint""" + try: + token = get_bearer_token() + user = authenticate_user(token) + if not user: + return jsonify({'error': 'Unauthorized'}), 401 + + stats = get_ticket_stats() + + return jsonify({ + 'success': True, + 'stats': stats + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/runtime_config.py b/runtime_config.py new file mode 100644 index 0000000..f2b11b1 --- /dev/null +++ b/runtime_config.py @@ -0,0 +1,87 @@ +"""Application settings and process-local state shared by services.""" + +import logging +import os +import secrets +import sys +import unicodedata +from threading import Lock + +from dotenv import load_dotenv + + +class TerminalFormatter(logging.Formatter): + """Use readable, color-coded terminal logs without decorative symbols.""" + + _COLORS = { + logging.DEBUG: "\033[36m", + logging.INFO: "\033[32m", + logging.WARNING: "\033[33m", + logging.ERROR: "\033[31m", + logging.CRITICAL: "\033[1;31m", + } + _RESET = "\033[0m" + + def __init__(self) -> None: + super().__init__("%(asctime)s %(levelname)s %(name)s: %(message)s", "%H:%M:%S") + self._use_color = sys.stderr.isatty() and "NO_COLOR" not in os.environ + + def format(self, record: logging.LogRecord) -> str: + line = super().format(record) + line = "".join(char for char in line if unicodedata.category(char) != "So") + if self._use_color: + return f"{self._COLORS.get(record.levelno, '')}{line}{self._RESET}" + return line + + +def _configure_logging() -> None: + handler = logging.StreamHandler() + handler.setFormatter(TerminalFormatter()) + logging.basicConfig(level=logging.INFO, handlers=[handler], force=True) + + +load_dotenv() +_configure_logging() +logger = logging.getLogger("anchorhost") + +JWT_SECRET = os.environ.get("JWT_SECRET") +if not JWT_SECRET: + JWT_SECRET = secrets.token_urlsafe(48) + logger.warning("JWT_SECRET is not configured; issued tokens will be invalidated on restart") +elif JWT_SECRET == "change-me-in-production" or len(JWT_SECRET) < 32: + raise RuntimeError("JWT_SECRET must be a non-placeholder value of at least 32 characters") + +ALLOWED_ORIGINS = ( + "http://localhost:3000", + "https://anchorhost.eu", + "https://www.anchorhost.eu", +) +SALT_ROUNDS = 12 +PORT = int(os.environ.get("PORT", 8080)) +WS_PORT = int(os.environ.get("WS_PORT", 8081)) +DATABASE_URL = os.environ.get("DATABASE_URL", "anchorhost.db") +STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "") +STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "") +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") +GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") +GOOGLE_REDIRECT_URI = os.environ.get("GOOGLE_REDIRECT_URI", "https://anchorhost.eu/api/auth/google/callback") +APPLE_CLIENT_ID = os.environ.get("APPLE_CLIENT_ID", "") +APPLE_TEAM_ID = os.environ.get("APPLE_TEAM_ID", "") +APPLE_KEY_ID = os.environ.get("APPLE_KEY_ID", "") +APPLE_PRIVATE_KEY = os.environ.get("APPLE_PRIVATE_KEY", "").replace("\\n", "\n") +APPLE_REDIRECT_URI = os.environ.get("APPLE_REDIRECT_URI", "https://anchorhost.eu/api/auth/apple/callback") +NODE_IP = os.environ.get("NODE_IP", "IP") +MODRINTH_API_BASE = "https://api.modrinth.com/v2" +EXCHANGE_RATE_CACHE = { + "rates": {"EUR": 0.92, "GBP": 0.79, "PLN": 4.15, "PHP": 56.8, "CZK": 23.2}, + "last_updated": None, +} +EXCHANGE_RATE_LOCK = Lock() +CACHE_DURATION = 3600 +STRIPE_CURRENCY_MAP = {"USD": "usd", "EUR": "eur", "GBP": "gbp", "PLN": "pln", "PHP": "php", "CZK": "czk"} +connected_clients = {} +ssh_sessions = {} +node_sessions = {} +chunked_uploads = {} +CHUNK_UPLOAD_DIR = "/tmp/chunk_uploads" +os.makedirs(CHUNK_UPLOAD_DIR, exist_ok=True) diff --git a/server_access.py b/server_access.py new file mode 100644 index 0000000..e204d7d --- /dev/null +++ b/server_access.py @@ -0,0 +1,674 @@ +"""Server access operations.""" + + +from database_service import database_connection +from datetime import datetime +import json +from kubernetes import client +from kubernetes.client.rest import ApiException +from kubernetes_service import apps_v1, k8s_manager, v1 +import os +import random +import re +from runtime_config import ( + logger, +) +from server_provisioning import generate_sftp_credentials, get_sftp_sidecar_container +from server_records import get_server_access_and_namespace, get_server_by_id +from typing import Dict, List + +__all__ = ['create_server_directory', 'delete_server_path', 'download_server_file', 'exec_server_command', 'expose_port', 'get_server_logs', 'get_server_metrics', 'list_server_files', 'read_server_file', 'read_server_file_base64', 'reconcile_sftp', 'remove_port', 'upload_server_file', 'validate_path_for_server', 'write_server_file'] + + + + +def expose_port(server_id: str, user_id: str, internal_port: int, protocol: str = 'TCP') -> bool: + """Expose a new port for a server""" + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + logger.info("Kubernetes not connected, simulating port exposure") + # Update database with new port + if server: + ports = server['ports'] + external_port = random.randint(30000, 32768) + ports.append({ + 'internal': internal_port, + 'external': external_port, + 'protocol': protocol, + 'name': f'port-{internal_port}-{protocol}'.lower() + }) + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (json.dumps(ports), server_id) + ) + conn.commit() + return True + + service_name = f"{server_id}-service" + + # Get existing service + try: + service = v1.read_namespaced_service(name=service_name, namespace=namespace) + except ApiException as e: + if e.status == 404: + # Service doesn't exist, create it + external_port = random.randint(30000, 32768) + service_ports = [ + client.V1ServicePort( + name=f"port-{internal_port}-{protocol}".lower(), + port=internal_port, + target_port=internal_port, + node_port=external_port, + protocol=protocol + ) + ] + + service = client.V1Service( + metadata=client.V1ObjectMeta( + name=service_name, + namespace=namespace, + labels={'app': server_id} + ), + spec=client.V1ServiceSpec( + selector={'app': server_id}, + type='NodePort', + ports=service_ports + ) + ) + created_service = v1.create_namespaced_service(namespace=namespace, body=service) + + # Get the actual assigned node port + actual_external_port = created_service.spec.ports[0].node_port + + # Update database + server = get_server_by_id(server_id) + if server: + ports = [{ + 'internal': internal_port, + 'external': actual_external_port, + 'protocol': protocol, + 'name': f'port-{internal_port}-{protocol}'.lower() + }] + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (json.dumps(ports), server_id) + ) + conn.commit() + + return True + else: + raise e + + # Check if port already exists + for port in service.spec.ports: + if port.port == internal_port and port.protocol == protocol: + return True # Port already exposed + + # Add new port + external_port = random.randint(30000, 32768) + new_port = client.V1ServicePort( + name=f"port-{internal_port}-{protocol}".lower(), + port=internal_port, + target_port=internal_port, + node_port=external_port, + protocol=protocol + ) + + service.spec.ports.append(new_port) + + updated_service = v1.patch_namespaced_service(name=service_name, namespace=namespace, body=service) + + # Get the actual assigned node port + actual_external_port = None + for port in updated_service.spec.ports: + if port.port == internal_port and port.protocol == protocol: + actual_external_port = port.node_port + break + + # Update server ports in database + server = get_server_by_id(server_id) + if server: + ports = server['ports'] + ports.append({ + 'internal': internal_port, + 'external': actual_external_port or external_port, + 'protocol': protocol, + 'name': f'port-{internal_port}-{protocol}'.lower() + }) + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (json.dumps(ports), server_id) + ) + conn.commit() + + return True + except Exception as e: + logger.error(f"Error exposing port: {e}") + raise Exception(f'Failed to expose port: {str(e)}') + +def remove_port(server_id: str, user_id: str, internal_port: int, protocol: str = 'TCP') -> bool: + """Remove an exposed port from a server""" + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + logger.info("Kubernetes not connected, simulating port removal") + if server: + ports = server['ports'] + new_ports = [p for p in ports if not (p['internal'] == internal_port and p['protocol'] == protocol)] + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (json.dumps(new_ports), server_id) + ) + conn.commit() + return True + + service_name = f"{server_id}-service" + + # Get existing service + try: + service = v1.read_namespaced_service(name=service_name, namespace=namespace) + except ApiException as e: + if e.status == 404: + return True # Service doesn't exist, nothing to remove + else: + raise e + + # Remove port from service - FIXED: Check both port number and protocol + service.spec.ports = [p for p in service.spec.ports if not (p.port == internal_port and p.protocol == protocol)] + + # If no ports left, delete the service + if not service.spec.ports: + v1.delete_namespaced_service(name=service_name, namespace=namespace) + else: + # Update the service with remaining ports - Use replace instead of patch for reliable array updates + # Clear managed_fields to avoid conflicts with server-side apply + if hasattr(service.metadata, 'managed_fields') and service.metadata.managed_fields: + service.metadata.managed_fields = None + v1.replace_namespaced_service(name=service_name, namespace=namespace, body=service) + + # Update server ports in database + server = get_server_by_id(server_id) + if server: + ports = [p for p in server['ports'] if not (p['internal'] == internal_port and p['protocol'] == protocol)] + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (json.dumps(ports), server_id) + ) + conn.commit() + + logger.info(f"โœ… Successfully removed port {internal_port}/{protocol} from server {server_id}") + return True + except Exception as e: + logger.error(f"Error removing port: {e}") + raise Exception(f'Failed to remove port: {str(e)}') + +def get_server_logs(server_id: str, user_id: str) -> str: + try: + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return f"Mock logs for {server_id}" + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return f"No pods found" + + logs = k8s_manager.get_pod_logs(namespace, pods[0], container_name=server_id) + return logs + + except Exception as e: + return f"Error: {str(e)}" + +def reconcile_sftp(server_id: str, user_id: str) -> bool: + """Ensure SFTP sidecar and port are correctly configured for a server""" + try: + logger.info(f"Checking SFTP reconciliation for server {server_id}") + namespace, server = get_server_access_and_namespace(server_id, user_id) + + if server['type'] == 'vps' or not k8s_manager.connected: + logger.info(f"Skipping SFTP for {server_id}: type={server['type']}, k8s_connected={k8s_manager.connected}") + return True + + # 1. Ensure credentials exist + u = server.get('sftp_username') + p = server.get('sftp_password') + port = server.get('sftp_port') + + if not u or not p or not port: + logger.info(f"Generating missing SFTP credentials for {server_id}") + u, p, port = generate_sftp_credentials() + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET sftp_username = ?, sftp_password = ?, sftp_port = ? WHERE id = ?', + (u, p, port, server_id) + ) + conn.commit() + server['sftp_username'] = u + server['sftp_password'] = p + server['sftp_port'] = port + + # 2. Ensure sidecar in Deployment + try: + deployment = apps_v1.read_namespaced_deployment(name=server_id, namespace=namespace) + containers = deployment.spec.template.spec.containers + sftp_container_index = next((i for i, c in enumerate(containers) if c.name == 'sftp'), -1) + + # Determine mount path based on image + mount_path = '/data' + if server['image'] == 'nginx': mount_path = '/usr/share/nginx/html' + elif server['image'] in ['wordpress', 'wordpress-sqlite', 'node']: mount_path = '/var/www/html' + elif server['image'] == 'unturned': mount_path = '/home/steam/Unturned' + elif server['image'] == 'factorio': mount_path = '/factorio' + elif server['image'] == 'hytale': mount_path = '/home/container' + elif server['image'] == 'zomboid': mount_path = '/home/steam/Zomboid' + + sftp_container = get_sftp_sidecar_container(server_id, u, p, mount_path) + container_spec = client.ApiClient().sanitize_for_serialization(sftp_container) + + if sftp_container_index == -1: + logger.info(f"Adding SFTP sidecar to deployment {server_id}") + patch = [ + { + "op": "add", + "path": "/spec/template/spec/containers/-", + "value": container_spec + } + ] + + apps_v1.patch_namespaced_deployment( + name=server_id, + namespace=namespace, + body=patch, + _content_type='application/json-patch+json' + ) + logger.info(f"โœ“ Successfully added SFTP sidecar to deployment {server_id}") + else: + # Update existing sidecar if needed (check if env, image or security_context differ) + existing_container = containers[sftp_container_index] + needs_update = False + + # Check SFTP_USERS env + existing_sftp_users = next((env.value for env in existing_container.env if env.name == 'SFTP_USERS'), None) + expected_sftp_users = f"{u}:{p}:1000:1000:data" + + # Check image + existing_image = existing_container.image + + if existing_sftp_users != expected_sftp_users or existing_image != "atmoz/sftp:alpine": + logger.info(f"Updating SFTP configuration for {server_id} (image or credentials changed)") + needs_update = True + + if needs_update: + patch = [ + { + "op": "replace", + "path": f"/spec/template/spec/containers/{sftp_container_index}", + "value": container_spec + } + ] + apps_v1.patch_namespaced_deployment( + name=server_id, + namespace=namespace, + body=patch, + _content_type='application/json-patch+json' + ) + logger.info(f"โœ“ Successfully updated SFTP sidecar for deployment {server_id}") + else: + logger.info(f"SFTP sidecar already exists and is up to date for {server_id}") + except Exception as e: + logger.error(f"Error reconciling SFTP sidecar for {server_id}: {e}") + + # 3. Ensure port in Service + try: + service_name = f"{server_id}-service" + service = v1.read_namespaced_service(name=service_name, namespace=namespace) + ports = service.spec.ports + sftp_port_exists = any(p.name == 'sftp' for p in ports) + + if not sftp_port_exists: + logger.info(f"Adding SFTP port {port} to service {service_name}") + sftp_service_port = client.V1ServicePort( + name="sftp", + port=22, + target_port=22, + node_port=int(port), + protocol="TCP" + ) + + port_spec = client.ApiClient().sanitize_for_serialization(sftp_service_port) + patch = [ + { + "op": "add", + "path": "/spec/ports/-", + "value": port_spec + } + ] + + v1.patch_namespaced_service( + name=service_name, + namespace=namespace, + body=patch, + _content_type='application/json-patch+json' + ) + logger.info(f"โœ“ Successfully added SFTP port to service {service_name}") + else: + logger.info(f"SFTP port already exists for {service_name}") + except Exception as e: + logger.error(f"Error reconciling SFTP port for {server_id}: {e}") + + return True + except Exception as e: + logger.error(f"Critical error in reconcile_sftp for {server_id}: {e}") + return False + +def get_server_metrics(server_id: str, user_id: str) -> Dict: + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + server_ram_gb = 1 + server_cpu_cores = 1 + + server_ram_gb = int(server['ram'].replace('GB', '')) if 'GB' in server['ram'] else 1 + # Parse CPU cores from server config + cpu_str = server.get('cpu', '1') + if 'vCPU' in cpu_str: + server_cpu_cores = int(cpu_str.split()[0]) if cpu_str.split()[0].isdigit() else 1 + else: + match = re.search(r'(\d+)', cpu_str) + if match: + server_cpu_cores = int(match.group(1)) + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + # Use fallback metrics when no pods found + cpu_millicores = random.randint(50, 300) + memory_mebibytes = random.randint(128, 1024) + max_cpu_millicores = server_cpu_cores * 1000 + cpu_percentage = min((cpu_millicores / max_cpu_millicores) * 100, 100) if max_cpu_millicores > 0 else 0 + memory_percentage = min((memory_mebibytes / (server_ram_gb * 1024)) * 100, 100) + + return { + 'cpu': f'{cpu_millicores}m', + 'memory': f'{memory_mebibytes}Mi', + 'cpu_percentage': round(cpu_percentage, 1), + 'memory_percentage': round(memory_percentage, 1), + 'timestamp': datetime.now().isoformat() + } + + metrics = k8s_manager.get_pod_metrics(namespace, pods[0], server_ram_gb, server_cpu_cores) + return metrics + + except Exception as e: + # Use fallback metrics on error + cpu_millicores = random.randint(50, 300) + memory_mebibytes = random.randint(128, 1024) + server_cpu_cores = 1 # Default fallback + server_ram_gb = 1 # Default fallback + max_cpu_millicores = server_cpu_cores * 1000 + cpu_percentage = min((cpu_millicores / max_cpu_millicores) * 100, 100) if max_cpu_millicores > 0 else 0 + memory_percentage = min((memory_mebibytes / (server_ram_gb * 1024)) * 100, 100) + + return { + 'cpu': f'{cpu_millicores}m', + 'memory': f'{memory_mebibytes}Mi', + 'cpu_percentage': round(cpu_percentage, 1), + 'memory_percentage': round(memory_percentage, 1), + 'error': str(e), + 'timestamp': datetime.now().isoformat() + } + +def validate_path_for_server(server_id: str, path: str) -> str: + """Validate and normalize path for server, restricting servers to their data directories""" + server = get_server_by_id(server_id) + if not server: + return path + + server_type = server.get('type') + image = server.get('image') + + # Image to base path mapping + base_paths = { + # Game servers + 'minecraft': '/data', + 'rust': '/data', + 'unturned': '/home/steam/Unturned', + 'factorio': '/factorio', + 'hytale': '/home/container', + 'zomboid': '/home/steam/Zomboid', + + # Website servers + 'nginx': '/usr/share/nginx/html', + 'wordpress': '/var/www/html', + 'node': '/var/www/html' + } + + # Determine base path + if server_type == 'game': + base_path = base_paths.get(image, '/data') + elif server_type == 'website': + base_path = base_paths.get(image, '/var/www/html') + else: + # For VPS or other types, allow full path or define specific root if needed + return path + + # If path is relative, make it absolute starting from base_path + if not path.startswith('/'): + path = os.path.join(base_path, path) + elif not path.startswith(base_path): + # If it's absolute but doesn't start with base_path, prefix it with base_path + # e.g. /etc/config -> /data/etc/config + path = os.path.join(base_path, path.lstrip('/')) + + # Ensure path doesn't go above base path + normalized = os.path.normpath(path) + if not normalized.startswith(base_path): + return base_path + return normalized + +def list_server_files(server_id: str, user_id: str, path: str = "/") -> List[Dict]: + """List files in server with improved error handling""" + try: + # Validate path for Minecraft servers + path = validate_path_for_server(server_id, path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + # Return mock data for development + return [ + {"name": "eula.txt", "type": "file", "size": 1024, "modified": "2024-01-01T00:00:00Z"}, + {"name": "server.properties", "type": "file", "size": 2048, "modified": "2024-01-01T00:00:00Z"}, + {"name": "logs", "type": "directory", "size": 4096, "modified": "2024-01-01T00:00:00Z"}, + {"name": "world", "type": "directory", "size": 4096, "modified": "2024-01-01T00:00:00Z"} + ] + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return [] + + return k8s_manager.list_files(namespace, pods[0], path, container_name=server_id) + except Exception as e: + logger.error(f"Error listing server files: {e}") + return [] + +def read_server_file(server_id: str, user_id: str, file_path: str) -> str: + try: + # Validate path for Minecraft servers + file_path = validate_path_for_server(server_id, file_path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return "" + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return "" + + return k8s_manager.read_file(namespace, pods[0], file_path, container_name=server_id) + except Exception: + return "" + +def read_server_file_base64(server_id: str, user_id: str, file_path: str) -> str: + """Read server file as base64 encoded string""" + try: + # Validate path for Minecraft servers + file_path = validate_path_for_server(server_id, file_path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return "" + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return "" + + return k8s_manager.read_file_base64(namespace, pods[0], file_path, container_name=server_id) + except Exception: + return "" + +def write_server_file(server_id: str, user_id: str, file_path: str, content: str) -> bool: + try: + # Validate path for Minecraft servers + file_path = validate_path_for_server(server_id, file_path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return True + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return False + + return k8s_manager.write_file(namespace, pods[0], file_path, content, container_name=server_id) + except Exception: + return False + +def create_server_directory(server_id: str, user_id: str, dir_path: str) -> bool: + """Create directory in server container""" + try: + # Validate path for Minecraft servers + dir_path = validate_path_for_server(server_id, dir_path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return True + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return False + + return k8s_manager.create_directory(namespace, pods[0], dir_path, container_name=server_id) + except Exception: + return False + +def delete_server_path(server_id: str, user_id: str, path: str) -> bool: + """Delete file or directory in server container""" + try: + # Validate path for Minecraft servers + path = validate_path_for_server(server_id, path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return True + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return False + + return k8s_manager.delete_path(namespace, pods[0], path, container_name=server_id) + except Exception: + return False + +def upload_server_file(server_id: str, user_id: str, file_path: str, file_content: str, is_base64: bool = True) -> bool: + """Upload file to server container (now uses binary upload for all files)""" + try: + # Validate path for Minecraft servers + file_path = validate_path_for_server(server_id, file_path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return True + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return False + + # Use write_file_binary for all files to support binary files + return k8s_manager.write_file_binary(namespace, pods[0], file_path, file_content, container_name=server_id) + except Exception as e: + logger.error(f"Error in upload_server_file: {e}") + return False + +def download_server_file(server_id: str, user_id: str, file_path: str) -> str: + """Download file from server container""" + try: + # Validate path for Minecraft servers + file_path = validate_path_for_server(server_id, file_path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + logger.warning("K8s manager not connected, cannot download file") + return "" + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + logger.error(f"No pods found for server {server_id}") + return "" + + return k8s_manager.download_file(namespace, pods[0], file_path, container_name=server_id) + except Exception as e: + logger.error(f"Error downloading file {file_path} from server {server_id}: {e}", exc_info=True) + return "" + +def exec_server_command(server_id: str, user_id: str, command: str) -> str: + """Execute command in server container""" + try: + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return f"$ {command}\nMock command execution completed" + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return "" + + return k8s_manager.exec_command(namespace, pods[0], ['sh', '-c', command], container_name=server_id) + except Exception: + return "" diff --git a/server_billing.py b/server_billing.py new file mode 100644 index 0000000..91b9e31 --- /dev/null +++ b/server_billing.py @@ -0,0 +1,383 @@ +"""Server billing operations.""" + + +from database_service import database_connection, dict_from_row +from datetime import datetime +from email_service import get_email_service +import json +from runtime_config import ( + logger, +) +import schedule +from server_capacity import calculate_monthly_server_cost +from server_lifecycle import stop_server +import threading +import time +from typing import Dict, List, Optional +from user_service import add_credit_transaction, update_user_credits +import uuid + +__all__ = ['calculate_already_paid_this_month', 'charge_monthly_fees', 'check_and_charge_fees', 'get_active_servers', 'get_server_billing_info', 'get_user_credit_history', 'get_user_monthly_fees', 'save_fee_collection_log', 'save_monthly_fee_record', 'start_monthly_fee_scheduler'] + + + + +def get_server_billing_info(server_id: str) -> Optional[Dict]: + """Get billing information for a server""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM server_billing WHERE server_id = ? + ''', (server_id,)) + return dict_from_row(cursor.fetchone()) + except Exception as e: + logger.error(f"Error getting server billing info: {e}") + return None + +def calculate_already_paid_this_month(server_id: str, current_monthly_cost: int) -> int: + """Calculate what the user has already paid this month based on days since last billing""" + try: + billing_info = get_server_billing_info(server_id) + if not billing_info: + return 0 + + # Use last_billing_date if available, otherwise use creation_date + billing_date_str = billing_info.get('last_billing_date') or billing_info.get('creation_date') + if not billing_date_str: + return 0 + + billing_date = datetime.strptime(billing_date_str, '%Y-%m-%d').date() + today = datetime.now().date() + + # Calculate days since last billing + days_since_billing = (today - billing_date).days + + # If negative or zero, user hasn't been billed yet this cycle + if days_since_billing <= 0: + return 0 + + # Calculate days until next billing + next_billing_date_str = billing_info.get('next_billing_date') + if next_billing_date_str: + next_billing_date = datetime.strptime(next_billing_date_str, '%Y-%m-%d').date() + days_in_billing_cycle = (next_billing_date - billing_date).days + else: + # Default to 30 days if next_billing_date is not set + days_in_billing_cycle = 30 + + if days_in_billing_cycle <= 0: + return 0 + + # Calculate proportional cost already paid + # (days_since_billing / days_in_billing_cycle) * monthly_cost + already_paid = int((days_since_billing / days_in_billing_cycle) * current_monthly_cost) + + logger.info(f"Already paid calculation for server {server_id}:") + logger.info(f" Days since billing: {days_since_billing}") + logger.info(f" Days in billing cycle: {days_in_billing_cycle}") + logger.info(f" Current monthly cost: {current_monthly_cost}") + logger.info(f" Already paid: {already_paid}") + + return already_paid + except Exception as e: + logger.error(f"Error calculating already paid amount: {e}") + return 0 + +def get_active_servers(): + """Pobierz wszystkie aktywne serwery (w stanie Running lub Starting)""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT s.*, u.username, u.email, u.credits + FROM servers s + JOIN users u ON s.owner_id = u.id + WHERE s.status IN ('Running', 'Starting') + ''') + servers = [dict_from_row(row) for row in cursor.fetchall()] + + for server in servers: + if server and 'ports' in server: + server['ports'] = json.loads(server['ports']) + + return servers + except Exception as e: + logger.error(f"Error getting active servers: {e}") + return [] + + +def charge_monthly_fees(): + """ลšciฤ…gnij opล‚aty miesiฤ™czne za wszystkie aktywne serwery""" + try: + logger.info("๐Ÿ” Starting monthly server fee collection...") + + active_servers = get_active_servers() + total_servers = len(active_servers) + successful_charges = 0 + failed_charges = 0 + insufficient_credits = 0 + + logger.info(f"Found {total_servers} active servers") + + for server in active_servers: + try: + user_id = server['owner_id'] + server_id = server['id'] + server_name = server['name'] + user_credits = server['credits'] + + # Oblicz miesiฤ™czny koszt + monthly_cost = calculate_monthly_server_cost(server) + + logger.info(f"Processing server: {server_name} (ID: {server_id})") + logger.info(f" User credits: {user_credits}") + logger.info(f" Monthly cost: {monthly_cost} credits") + + # Always deduct credits, regardless of balance + new_credits = user_credits - monthly_cost + + if update_user_credits(user_id, new_credits): + # Add transaction record + add_credit_transaction( + user_id, + -monthly_cost, + 'monthly_fee', + f'Monthly server fee: {server_name} ({server["type"]}) - {monthly_cost} credits' + ) + + # Save fee record + save_monthly_fee_record(user_id, server_id, monthly_cost) + + # Check if balance is negative + if new_credits < 0: + insufficient_credits += 1 + logger.warning(f"โš  Insufficient credits for user {server['username']}: {new_credits} < 0") + + # Stop server due to negative balance + try: + stop_server(server_id, user_id) + logger.info(f"๐Ÿ›‘ Stopped server {server_name} due to negative balance") + + # Send suspension email + try: + email_svc = get_email_service() + if email_svc: + email_svc.send_server_suspended_email( + server['email'], + server['username'], + server_name, + monthly_cost, + new_credits + ) + except Exception as email_error: + logger.error(f"Error sending suspension email: {email_error}") + + except Exception as stop_error: + logger.error(f"Error stopping server: {stop_error}") + else: + # Balance is positive (or zero), server keeps running + successful_charges += 1 + logger.info(f"โœ… Successfully charged {monthly_cost} credits for server {server_name}") + + # Send confirmation email + try: + email_svc = get_email_service() + if email_svc: + email_svc.send_monthly_fee_email( + server['email'], + server['username'], + server_name, + monthly_cost, + new_credits + ) + except Exception as email_error: + logger.error(f"Error sending fee email: {email_error}") + else: + failed_charges += 1 + logger.error(f"โŒ Failed to update credits for user {user_id}") + + except Exception as server_error: + failed_charges += 1 + logger.error(f"Error processing server {server.get('id', 'unknown')}: {server_error}") + + # Podsumowanie + logger.info(f"๐Ÿ’ฐ Monthly fee collection completed:") + logger.info(f" โ€ข Total servers: {total_servers}") + logger.info(f" โ€ข Successfully charged: {successful_charges}") + logger.info(f" โ€ข Failed charges: {failed_charges}") + logger.info(f" โ€ข Insufficient credits: {insufficient_credits}") + + return { + 'success': True, + 'total_servers': total_servers, + 'successful_charges': successful_charges, + 'failed_charges': failed_charges, + 'insufficient_credits': insufficient_credits, + 'timestamp': datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"Error in monthly fee collection: {e}") + return { + 'success': False, + 'error': str(e), + 'timestamp': datetime.now().isoformat() + } + +def save_monthly_fee_record(user_id: str, server_id: str, amount: int) -> bool: + """Zapisz rekord opล‚aty miesiฤ™cznej w bazie danych""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Sprawdลบ czy tabela monthly_fees istnieje, jeล›li nie - utwรณrz + cursor.execute(''' + CREATE TABLE IF NOT EXISTS monthly_fees ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + server_id TEXT NOT NULL, + amount INTEGER NOT NULL, + payment_date DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE CASCADE + ) + ''') + + # Utwรณrz indeksy + cursor.execute('CREATE INDEX IF NOT EXISTS idx_monthly_fees_user_id ON monthly_fees(user_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_monthly_fees_server_id ON monthly_fees(server_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_monthly_fees_payment_date ON monthly_fees(payment_date)') + + # Wstaw rekord + fee_id = f"fee-{str(uuid.uuid4())[:8]}" + payment_date = datetime.now().date().isoformat() + + cursor.execute(''' + INSERT INTO monthly_fees (id, user_id, server_id, amount, payment_date) + VALUES (?, ?, ?, ?, ?) + ''', (fee_id, user_id, server_id, amount, payment_date)) + + conn.commit() + return True + + except Exception as e: + logger.error(f"Error saving monthly fee record: {e}") + return False + +def get_user_monthly_fees(user_id: str, limit: int = 10) -> List[Dict]: + """Pobierz historiฤ™ opล‚at miesiฤ™cznych uลผytkownika""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT mf.*, s.name as server_name, s.type as server_type + FROM monthly_fees mf + JOIN servers s ON mf.server_id = s.id + WHERE mf.user_id = ? + ORDER BY mf.payment_date DESC + LIMIT ? + ''', (user_id, limit)) + + fees = [dict_from_row(row) for row in cursor.fetchall()] + return fees + except Exception as e: + logger.error(f"Error getting user monthly fees: {e}") + return [] + +def get_user_credit_history(user_id: str, limit: int = 10) -> List[Dict]: + """Get credit transaction history for a user""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM credit_transactions + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? + ''', (user_id, limit)) + + transactions = [dict_from_row(row) for row in cursor.fetchall()] + return transactions + except Exception as e: + logger.error(f"Error getting user credit history: {e}") + return [] + +def start_monthly_fee_scheduler(): + """Uruchom scheduler do automatycznego ล›ciฤ…gania opล‚at""" + def run_scheduler(): + # Ustaw harmonogram - kaลผdego 1-go dnia miesiฤ…ca o pรณล‚nocy + schedule.every().day.at("00:00").do(check_and_charge_fees) + + logger.info("๐Ÿ’ฐ Monthly fee scheduler started") + logger.info(" Will check for fees on the 1st day of each month at midnight") + + while True: + try: + schedule.run_pending() + time.sleep(60) # Sprawdzaj co minutฤ™ + except Exception as e: + logger.error(f"Scheduler error: {e}") + time.sleep(300) # Czekaj 5 minut w przypadku bล‚ฤ™du + + thread = threading.Thread(target=run_scheduler, daemon=True) + thread.start() + +def check_and_charge_fees(): + """Sprawdลบ czy dzisiaj jest 1-szy dzieล„ miesiฤ…ca i pobierz opล‚aty""" + today = datetime.now() + + # Sprawdลบ czy dzisiaj jest 1-szy dzieล„ miesiฤ…ca + if today.day == 1: + logger.info(f"๐Ÿ“… Today is the 1st day of month {today.month}, charging monthly fees...") + result = charge_monthly_fees() + logger.info(f"Monthly fee result: {result}") + + # Zapisz log opล‚at + save_fee_collection_log(result) + else: + logger.debug(f"Not the 1st day of month (today: {today.day}), skipping fee collection") + +def save_fee_collection_log(result: Dict) -> bool: + """Zapisz log kolekcji opล‚at""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Utwรณrz tabelฤ™ jeล›li nie istnieje + cursor.execute(''' + CREATE TABLE IF NOT EXISTS fee_collection_logs ( + id TEXT PRIMARY KEY, + total_servers INTEGER NOT NULL, + successful_charges INTEGER NOT NULL, + failed_charges INTEGER NOT NULL, + insufficient_credits INTEGER NOT NULL, + collection_date DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Wstaw rekord + log_id = f"log-{str(uuid.uuid4())[:8]}" + collection_date = datetime.now().date().isoformat() + + cursor.execute(''' + INSERT INTO fee_collection_logs + (id, total_servers, successful_charges, failed_charges, insufficient_credits, collection_date) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + log_id, + result.get('total_servers', 0), + result.get('successful_charges', 0), + result.get('failed_charges', 0), + result.get('insufficient_credits', 0), + collection_date + )) + + conn.commit() + return True + + except Exception as e: + logger.error(f"Error saving fee collection log: {e}") + return False diff --git a/server_capacity.py b/server_capacity.py new file mode 100644 index 0000000..71e1cf4 --- /dev/null +++ b/server_capacity.py @@ -0,0 +1,795 @@ +"""Server capacity operations.""" + + +from datetime import datetime +from kubernetes_service import custom_objects_api, k8s_manager, v1 +from runtime_config import ( + logger, +) +from typing import Dict, Tuple +from user_service import get_pricing + +__all__ = ['calculate_monthly_server_cost', 'calculate_server_cost', 'check_longhorn_storage_capacity', 'get_cluster_capacity', 'parse_kubernetes_resource', 'parse_resource_to_mebibytes', 'parse_resource_to_millicores', 'parse_storage_to_gb', 'validate_server_capacity'] + + + +def check_longhorn_storage_capacity(required_storage_bytes: int, replicas: int = 3) -> Tuple[bool, str]: + """Check if Longhorn cluster has enough capacity for a new volume""" + try: + # Get Longhorn nodes + lh_nodes = custom_objects_api.list_namespaced_custom_object( + group="longhorn.io", + version="v1beta2", + namespace="longhorn-system", + plural="nodes" + ) + + schedulable_nodes = 0 + nodes_with_space = [] + + for item in lh_nodes.get('items', []): + node_name = item['metadata']['name'] + spec = item.get('spec', {}) + status = item.get('status', {}) + disk_status = status.get('diskStatus', {}) + + # Check if node allows scheduling + if not spec.get('allowScheduling', True): + continue + + # Check available storage on disks + node_has_space = False + for disk_uuid, disk_info in disk_status.items(): + # Check individual disk schedulable status if available in conditions + conditions = disk_info.get('conditions', []) + + # Check if disk is schedulable + schedulable_status = "True" + if isinstance(conditions, list): + schedulable_cond = next((c for c in conditions if c.get('type') == 'Schedulable'), {}) + schedulable_status = schedulable_cond.get('status', 'True') + elif isinstance(conditions, dict): + schedulable_status = conditions.get('Schedulable', {}).get('status', 'True') + + if schedulable_status == 'False': + continue + + # storageScheduled is the space available for scheduling new replicas (after over-provisioning) + storage_schedulable = int(disk_info.get('storageScheduled', 0)) + + if storage_schedulable >= required_storage_bytes: + node_has_space = True + # We only need one suitable disk per node + break + + if node_has_space: + schedulable_nodes += 1 + nodes_with_space.append(node_name) + + logger.info(f"Longhorn storage check: {schedulable_nodes} nodes have >{required_storage_bytes/1024/1024/1024:.1f}GB schedulable. Nodes: {', '.join(nodes_with_space)}") + + if schedulable_nodes >= replicas: + return True, f"Storage available on {schedulable_nodes} nodes" + else: + return False, f"Longhorn needs {replicas} nodes with {required_storage_bytes/1024/1024/1024:.1f}GB space, but only found {schedulable_nodes}: {', '.join(nodes_with_space)}" + + except Exception as e: + logger.warning(f"Could not check Longhorn storage capacity: {e}") + # Fallback: assume True if we can't check (e.g. permission error or API down), + # but log warning. + return True, f"Skipped storage check: {e}" + +def validate_server_capacity(server_config: Dict) -> Dict: + """ + Validate if there's sufficient capacity for the requested server. + Uses Kubernetes allocatable resources which already account for system reservations. + """ + try: + # Extract resource requirements + cpu_cores = int(server_config['cpu'].split()[0]) + ram_gb = int(server_config['ram'].replace('GB', '')) + storage_gb = int(server_config['storage'].replace('GB', '')) + + # Convert to Kubernetes units + # Use 50m (5%) per core for requests and 50% of RAM for requests + # to allow smarter overcommitment while keeping limits at 100% + cpu_request_millicores = cpu_cores * 50 + memory_request_mebibytes = (ram_gb * 1024) * 0.5 + storage_request_gb = storage_gb + + if not k8s_manager.connected: + # Development mode - assume unlimited capacity + return { + 'success': True, + 'message': 'Development mode: Capacity validation skipped', + 'summary': { + 'requested': { + 'cpu': f"{cpu_cores} vCPU", + 'ram': f"{ram_gb}GB", + 'storage': f"{storage_gb}GB" + } + } + } + + # Check Longhorn storage capacity + storage_request_bytes = storage_gb * 1024 * 1024 * 1024 + storage_ok, storage_msg = check_longhorn_storage_capacity(storage_request_bytes) + + if not storage_ok: + logger.warning(f"โŒ Storage capacity check failed: {storage_msg}") + return { + 'success': False, + 'message': f'Insufficient storage capacity: {storage_msg}', + 'summary': { + 'requested': { + 'cpu': f"{cpu_cores} vCPU", + 'ram': f"{ram_gb}GB", + 'storage': f"{storage_gb}GB" + } + } + } + + try: + # Get ALL nodes including control-plane + nodes = v1.list_node() + all_nodes = list(nodes.items) + + if not all_nodes: + return { + 'success': False, + 'message': 'No nodes available in the cluster', + 'summary': { + 'requested': { + 'cpu': f"{cpu_cores} vCPU", + 'ram': f"{ram_gb}GB", + 'storage': f"{storage_gb}GB" + } + } + } + + # Check each node for capacity + suitable_nodes = [] + all_details = [] + + for node in all_nodes: + node_name = node.metadata.name + node_details = {'node': node_name, 'suitable': True, 'reasons': []} + + # Check node type + labels = node.metadata.labels or {} + is_control_plane = 'node-role.kubernetes.io/control-plane' in labels or 'node-role.kubernetes.io/master' in labels + node_type = 'control-plane' if is_control_plane else 'worker' + node_details['type'] = node_type + + # Get node allocatable resources - THIS ALREADY EXCLUDES SYSTEM RESERVATIONS + allocatable = node.status.allocatable + + # Parse available resources (allocatable = total - system reservations) + allocatable_cpu_millicores = parse_resource_to_millicores(allocatable.get('cpu', '0')) + allocatable_memory_mebibytes = parse_resource_to_mebibytes(allocatable.get('memory', '0')) + + # For storage, use capacity since ephemeral-storage might not be in allocatable + capacity = node.status.capacity + available_storage_gb = parse_storage_to_gb(capacity.get('ephemeral-storage', '0Gi')) + + # Get currently allocated resources from running pods + pods = v1.list_pod_for_all_namespaces(field_selector=f"spec.nodeName={node_name}") + used_cpu_millicores = 0 + used_memory_mebibytes = 0 + + for pod in pods.items: + if pod.status.phase in ['Running', 'Pending']: + for container in pod.spec.containers: + resources = container.resources + if resources and resources.requests: + if 'cpu' in resources.requests: + used_cpu_millicores += parse_resource_to_millicores(resources.requests['cpu']) + if 'memory' in resources.requests: + used_memory_mebibytes += parse_resource_to_mebibytes(resources.requests['memory']) + + # Calculate free resources + # NO BUFFER NEEDED - allocatable already accounts for system reservations! + free_cpu_millicores = allocatable_cpu_millicores - used_cpu_millicores + free_memory_mebibytes = allocatable_memory_mebibytes - used_memory_mebibytes + + # For storage, assume 20% is used by system/overhead + free_storage_gb = available_storage_gb * 0.8 + + # Debug logging + logger.info(f"Node {node_name} ({node_type}):") + logger.info(f" Allocatable: CPU={allocatable_cpu_millicores/1000:.1f}v, RAM={allocatable_memory_mebibytes/1024:.1f}GB") + logger.info(f" Used by pods: CPU={used_cpu_millicores/1000:.1f}v, RAM={used_memory_mebibytes/1024:.1f}GB") + logger.info(f" Free for new pods: CPU={free_cpu_millicores/1000:.1f}v, RAM={free_memory_mebibytes/1024:.1f}GB") + logger.info(f" Storage: {free_storage_gb:.1f}GB free of {available_storage_gb:.1f}GB") + logger.info(f" Requested: CPU={cpu_cores}v, RAM={ram_gb}GB, Storage={storage_gb}GB") + + # Check if node has enough resources + cpu_passed = free_cpu_millicores >= cpu_request_millicores + memory_passed = free_memory_mebibytes >= memory_request_mebibytes + storage_passed = free_storage_gb >= storage_request_gb + + if not cpu_passed: + node_details['suitable'] = False + node_details['reasons'].append( + f"CPU: {free_cpu_millicores/1000:.1f}v free < {cpu_cores}v needed" + ) + + if not memory_passed: + node_details['suitable'] = False + node_details['reasons'].append( + f"RAM: {free_memory_mebibytes/1024:.1f}GB free < {ram_gb}GB needed" + ) + + if not storage_passed: + node_details['suitable'] = False + node_details['reasons'].append( + f"Storage: {free_storage_gb:.1f}GB free < {storage_gb}GB needed" + ) + + # Add resource details + node_details.update({ + 'free_cpu': free_cpu_millicores, + 'free_memory': free_memory_mebibytes, + 'free_storage': free_storage_gb, + 'requested_cpu': cpu_request_millicores, + 'requested_memory': memory_request_mebibytes, + 'requested_storage': storage_request_gb, + 'is_control_plane': is_control_plane, + 'allocatable_cpu': allocatable_cpu_millicores, + 'allocatable_memory': allocatable_memory_mebibytes, + 'available_storage': available_storage_gb, + 'used_cpu': used_cpu_millicores, + 'used_memory': used_memory_mebibytes + }) + + all_details.append(node_details) + + if node_details['suitable']: + suitable_nodes.append({ + 'name': node_name, + 'type': node_type, + 'free_cpu': free_cpu_millicores, + 'free_memory': free_memory_mebibytes, + 'free_storage': free_storage_gb + }) + logger.info(f"โœ… Node {node_name} ({node_type}) has sufficient capacity") + else: + logger.info(f"โŒ Node {node_name} ({node_type}) insufficient: {node_details['reasons']}") + + # Check if we found any suitable nodes + if suitable_nodes: + suitable_node_names = [n['name'] for n in suitable_nodes] + + return { + 'success': True, + 'message': f'Sufficient capacity available on {len(suitable_nodes)} node(s)', + 'suitable_nodes': suitable_node_names, + 'node_details': all_details, + 'summary': { + 'requested': { + 'cpu': f"{cpu_cores} vCPU", + 'ram': f"{ram_gb}GB", + 'storage': f"{storage_gb}GB" + }, + 'suitable_nodes_count': len(suitable_nodes), + 'total_nodes': len(all_nodes) + } + } + else: + error_details = [] + for detail in all_details: + if detail['reasons']: + error_details.append(f"{detail['node']} ({detail['type']}): {', '.join(detail['reasons'])}") + + return { + 'success': False, + 'message': f'No nodes have sufficient capacity', + 'details': all_details, + 'error_details': error_details, + 'summary': { + 'requested': { + 'cpu': f"{cpu_cores} vCPU", + 'ram': f"{ram_gb}GB", + 'storage': f"{storage_gb}GB" + }, + 'total_nodes': len(all_nodes), + 'suitable_nodes': 0 + } + } + + except Exception as k8s_error: + logger.error(f"Error checking Kubernetes capacity: {k8s_error}") + return { + 'success': False, + 'message': f'Error checking cluster capacity: {str(k8s_error)}', + 'summary': { + 'requested': { + 'cpu': f"{cpu_cores} vCPU", + 'ram': f"{ram_gb}GB", + 'storage': f"{storage_gb}GB" + } + } + } + + except Exception as e: + logger.error(f"Error in capacity validation: {e}") + return { + 'success': False, + 'message': f'Capacity validation error: {str(e)}' + } + +def parse_resource_to_millicores(resource_str: str) -> float: + """Parse CPU resource string to millicores""" + if not resource_str: + return 0.0 + + resource_str = str(resource_str).strip() + + try: + if resource_str.endswith('m'): + # Millicores + return float(resource_str[:-1]) + elif resource_str.replace('.', '', 1).isdigit(): + # Cores (e.g., "1", "0.5", "2.5") + return float(resource_str) * 1000 + elif resource_str.endswith('n'): + # Nanocores + return float(resource_str[:-1]) / 1000000 + else: + # Try to parse as plain number + return float(resource_str) * 1000 + except Exception: + return 0.0 + +def parse_resource_to_mebibytes(resource_str: str) -> float: + """Parse memory resource string to MiB (mebibytes)""" + if not resource_str: + return 0.0 + + resource_str = str(resource_str).strip() + + try: + # Remove any spaces + resource_str = resource_str.replace(' ', '') + + if resource_str.endswith('Ki'): + # Kibibytes + return float(resource_str[:-2]) / 1024 + elif resource_str.endswith('Mi'): + # Mebibytes + return float(resource_str[:-2]) + elif resource_str.endswith('Gi'): + # Gibibytes + return float(resource_str[:-2]) * 1024 + elif resource_str.endswith('Ti'): + # Tebibytes + return float(resource_str[:-2]) * 1024 * 1024 + elif resource_str.endswith('K'): + # Kilobytes (SI) + return float(resource_str[:-1]) / 1000 / 1024 * 1000 # Convert KiB to MiB + elif resource_str.endswith('M'): + # Megabytes (SI) + return float(resource_str[:-1]) / 1024 * 1000 # Convert MB to MiB + elif resource_str.endswith('G'): + # Gigabytes (SI) + return float(resource_str[:-1]) * 1000 # Convert GB to MiB + else: + # Assume it's already in bytes + try: + bytes_val = float(resource_str) + return bytes_val / (1024 * 1024) # Convert bytes to MiB + except: + return 0.0 + except Exception as e: + logger.warning(f"Error parsing memory '{resource_str}': {e}") + return 0.0 + +def parse_storage_to_gb(resource_str: str) -> float: + """Parse storage resource string to GB""" + if not resource_str: + return 0.0 + + resource_str = str(resource_str).strip() + + try: + # Remove any spaces + resource_str = resource_str.replace(' ', '') + + if resource_str.endswith('Ki'): + # Kibibytes + return float(resource_str[:-2]) / (1024 * 1024) + elif resource_str.endswith('Mi'): + # Mebibytes + return float(resource_str[:-2]) / 1024 + elif resource_str.endswith('Gi'): + # Gibibytes (1 GiB = 1.07374 GB) + return float(resource_str[:-2]) * 1.07374 + elif resource_str.endswith('Ti'): + # Tebibytes + return float(resource_str[:-2]) * 1024 * 1.07374 + elif resource_str.endswith('K'): + # Kilobytes (SI) + return float(resource_str[:-1]) / (1000 * 1000) + elif resource_str.endswith('M'): + # Megabytes (SI) + return float(resource_str[:-1]) / 1000 + elif resource_str.endswith('G'): + # Gigabytes (SI) + return float(resource_str[:-1]) + elif resource_str.endswith('T'): + # Terabytes (SI) + return float(resource_str[:-1]) * 1000 + else: + # Assume it's already in bytes + try: + bytes_val = float(resource_str) + return bytes_val / (1000 * 1000 * 1000) # Convert bytes to GB + except: + return 0.0 + except Exception as e: + logger.warning(f"Error parsing storage '{resource_str}': {e}") + return 0.0 + +def parse_kubernetes_resource(resource_str: str) -> float: + """ + Parse Kubernetes resource strings to numeric values. + + Supports: + - CPU: "100m" = 100 millicores, "1" = 1 core, "0.5" = 500m + - Memory: "1Gi" = 1024Mi, "1G" = 1000Mi, "100Mi" = 100Mi, "1M" = 1Mi + - Storage: "10Gi" = 10GiB, "100G" = 100GB + + Returns: + float representing the resource in base units: + - CPU: millicores + - Memory: MiB + - Storage: GB + """ + if not resource_str: + return 0.0 + + resource_str = str(resource_str).strip() + + try: + # Handle CPU resources + if resource_str.endswith('m'): + # Millicores + return float(resource_str[:-1]) + elif resource_str.endswith('n'): + # Nanocores + return float(resource_str[:-1]) / 1000000 + elif resource_str.replace('.', '', 1).isdigit(): + # Cores (e.g., "1", "0.5", "2.5") + return float(resource_str) * 1000 + + # Handle memory and storage resources + # Get numeric value + import re + match = re.match(r'^([0-9.]+)([a-zA-Z]*)$', resource_str) + if not match: + # Try to parse as plain number + try: + return float(resource_str) + except: + return 0.0 + + value = float(match.group(1)) + unit = match.group(2).upper() + + # Memory conversions to MiB + if unit == 'KI' or unit == 'K': + return value / 1024 # KiB to MiB + elif unit == 'MI' or unit == 'M': + return value # MiB + elif unit == 'GI' or unit == 'G': + return value * 1024 # GiB to MiB + elif unit == 'TI' or unit == 'T': + return value * 1024 * 1024 # TiB to MiB + + # If no unit, assume it's already in appropriate units + return value + + except Exception as e: + logger.warning(f"Error parsing resource string '{resource_str}': {e}") + return 0.0 + +def get_cluster_capacity() -> Dict: + """Get cluster capacity using allocatable resources.""" + try: + if not k8s_manager.connected: + return { + 'success': False, + 'message': 'Kubernetes not connected' + } + + nodes = v1.list_node() + all_nodes = nodes.items + + total_allocatable_cpu = 0 + total_allocatable_memory = 0 + total_storage = 0 + + total_used_cpu = 0 + total_used_memory = 0 + + node_details = [] + + # Get pod allocations + all_pods = v1.list_pod_for_all_namespaces() + pod_allocations = {} + + for pod in all_pods.items: + if pod.spec.node_name and pod.status.phase in ['Running', 'Pending']: + node_name = pod.spec.node_name + pod_cpu = 0 + pod_memory = 0 + + for container in pod.spec.containers: + resources = container.resources + if resources and resources.requests: + if 'cpu' in resources.requests: + pod_cpu += parse_resource_to_millicores(resources.requests['cpu']) + if 'memory' in resources.requests: + pod_memory += parse_resource_to_mebibytes(resources.requests['memory']) + + if node_name not in pod_allocations: + pod_allocations[node_name] = {'cpu': 0, 'memory': 0} + pod_allocations[node_name]['cpu'] += pod_cpu + pod_allocations[node_name]['memory'] += pod_memory + + for node in all_nodes: + node_name = node.metadata.name + + # Node type + labels = node.metadata.labels or {} + is_control_plane = 'node-role.kubernetes.io/control-plane' in labels or 'node-role.kubernetes.io/master' in labels + node_type = 'control-plane' if is_control_plane else 'worker' + + # Allocatable resources + allocatable = node.status.allocatable + cpu = parse_resource_to_millicores(allocatable.get('cpu', '0')) + memory = parse_resource_to_mebibytes(allocatable.get('memory', '0')) + + # Storage capacity + capacity = node.status.capacity + storage = parse_storage_to_gb(capacity.get('ephemeral-storage', '0Gi')) + + total_allocatable_cpu += cpu + total_allocatable_memory += memory + total_storage += storage + + # Used resources + node_alloc = pod_allocations.get(node_name, {'cpu': 0, 'memory': 0}) + total_used_cpu += node_alloc['cpu'] + total_used_memory += node_alloc['memory'] + + # Free resources + free_cpu = cpu - node_alloc['cpu'] + free_memory = memory - node_alloc['memory'] + free_storage = storage * 0.8 # 80% available + + # Utilization + cpu_percent = (node_alloc['cpu'] / cpu * 100) if cpu > 0 else 0 + memory_percent = (node_alloc['memory'] / memory * 100) if memory > 0 else 0 + + node_details.append({ + 'name': node_name, + 'type': node_type, + 'allocatable': { + 'cpu': cpu, + 'cpu_cores': cpu / 1000, + 'memory': memory, + 'memory_gb': memory / 1024, + 'storage': storage, + 'storage_gb': storage + }, + 'used': { + 'cpu': node_alloc['cpu'], + 'memory': node_alloc['memory'] + }, + 'free': { + 'cpu': free_cpu, + 'memory': free_memory, + 'storage': free_storage + }, + 'utilization': { + 'cpu_percent': round(cpu_percent, 1), + 'memory_percent': round(memory_percent, 1) + } + }) + + # Calculate totals + free_cpu = total_allocatable_cpu - total_used_cpu + free_memory = total_allocatable_memory - total_used_memory + free_storage = total_storage * 0.8 + + overall_cpu_percent = (total_used_cpu / total_allocatable_cpu * 100) if total_allocatable_cpu > 0 else 0 + overall_memory_percent = (total_used_memory / total_allocatable_memory * 100) if total_allocatable_memory > 0 else 0 + + return { + 'success': True, + 'message': f'Cluster capacity: {len(all_nodes)} total nodes', + 'total_nodes': len(all_nodes), + 'total_capacity': { + 'cpu': total_allocatable_cpu, + 'cpu_cores': total_allocatable_cpu / 1000, + 'memory': total_allocatable_memory, + 'memory_gb': total_allocatable_memory / 1024, + 'storage': total_storage, + 'storage_gb': total_storage + }, + 'used': { + 'cpu': total_used_cpu, + 'memory': total_used_memory + }, + 'available': { + 'cpu': free_cpu, + 'memory': free_memory, + 'storage': free_storage + }, + 'utilization': { + 'cpu_percent': round(overall_cpu_percent, 1), + 'memory_percent': round(overall_memory_percent, 1) + }, + 'node_details': node_details, + 'timestamp': datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"Error getting cluster capacity: {e}") + return { + 'success': False, + 'message': f'Error getting cluster capacity: {str(e)}', + 'timestamp': datetime.now().isoformat() + } + +def calculate_server_cost(config: Dict) -> int: + """Calculate server creation cost - UPDATED with new pricing""" + try: + cpu_str = config['cpu'] + ram_str = config['ram'] + storage_str = config['storage'] + + # Parsowanie CPU + cpu_cores = 0 + if 'vCPU' in cpu_str: + cpu_cores = int(cpu_str.split()[0]) + elif 'GHz' in cpu_str: + cpu_cores = int(cpu_str.split()[0]) + else: + import re + match = re.search(r'\d+', cpu_str) + if match: + cpu_cores = int(match.group()) + + # Parsowanie RAM + ram_gb = 0 + import re + match = re.search(r'(\d+)\s*GB', ram_str, re.IGNORECASE) + if match: + ram_gb = int(match.group(1)) + else: + match = re.search(r'(\d+)\s*GiB', ram_str, re.IGNORECASE) + if match: + ram_gb = int(match.group(1)) + + # Parsowanie Storage + storage_gb = 0 + match = re.search(r'(\d+)\s*GB', storage_str, re.IGNORECASE) + if match: + storage_gb = int(match.group(1)) + else: + match = re.search(r'(\d+)\s*GiB', storage_str, re.IGNORECASE) + if match: + storage_gb = int(match.group(1)) + + # Ceny dopasowane do polskiego rynku (w EURO centach) + pricing = get_pricing() + + cpu_cost = cpu_cores * pricing['price_cpu'] + ram_cost = ram_gb * pricing['price_ram'] + storage_cost = storage_gb * pricing['price_storage'] + + server_type = config.get('serverType', 'vps') + type_multiplier = { + 'vps': pricing.get('multiplier_vps', 1.0), + 'game': pricing.get('multiplier_game', 0.8), + 'website': pricing.get('multiplier_website', 1.5), + 'database': pricing.get('multiplier_database', 1.3) + }.get(server_type, 1.0) + + total_cost_cents = int((cpu_cost + ram_cost + storage_cost) * type_multiplier) + + # Konwersja na kredyty (1 euro = 100 kredytรณw, 1 cent = 1 kredyt) + total_credits = total_cost_cents + + logger.info(f"Server cost calculation for {config['name']}:") + logger.info(f" CPU: {cpu_cores} vCPU * {pricing['price_cpu']} = {cpu_cost} cents") + logger.info(f" RAM: {ram_gb}GB * {pricing['price_ram']} = {ram_cost} cents") + logger.info(f" Storage: {storage_gb}GB * {pricing['price_storage']} = {storage_cost} cents") + logger.info(f" Type multiplier: {type_multiplier}") + logger.info(f" Total: {total_cost_cents} cents = {total_credits} credits") + + return total_credits + except Exception as e: + logger.error(f"Error calculating server cost: {e}") + return 1000 # Domyล›lny koszt + +def calculate_monthly_server_cost(server: Dict) -> int: + """Oblicz miesiฤ™czny koszt serwera""" + try: + # Ekstrakcja zasobรณw z konfiguracji serwera + cpu_str = server['cpu'] + ram_str = server['ram'] + storage_str = server['storage'] + + # Parsowanie CPU - format moลผe byฤ‡ "2 vCPU" lub podobny + cpu_cores = 0 + if 'vCPU' in cpu_str: + cpu_cores = int(cpu_str.split()[0]) + elif 'GHz' in cpu_str: + cpu_cores = int(cpu_str.split()[0]) + else: + # Sprรณbuj wyodrฤ™bniฤ‡ liczbฤ™ + import re + match = re.search(r'\d+', cpu_str) + if match: + cpu_cores = int(match.group()) + + # Parsowanie RAM - format moลผe byฤ‡ "4GB" lub "4 GB" + ram_gb = 0 + import re + match = re.search(r'(\d+)\s*GB', ram_str, re.IGNORECASE) + if match: + ram_gb = int(match.group(1)) + else: + # Sprรณbuj inne formaty + match = re.search(r'(\d+)\s*GiB', ram_str, re.IGNORECASE) + if match: + ram_gb = int(match.group(1)) + + # Parsowanie Storage - format moลผe byฤ‡ "50GB" lub "50 GB" + storage_gb = 0 + match = re.search(r'(\d+)\s*GB', storage_str, re.IGNORECASE) + if match: + storage_gb = int(match.group(1)) + else: + match = re.search(r'(\d+)\s*GiB', storage_str, re.IGNORECASE) + if match: + storage_gb = int(match.group(1)) + + # Kalkulacja kosztรณw wedล‚ug stawek dopasowanych do polskiego rynku (w EURO centach) + pricing = get_pricing() + + cpu_cost = cpu_cores * pricing['price_cpu'] + ram_cost = ram_gb * pricing['price_ram'] + storage_cost = storage_gb * pricing['price_storage'] + + # Koszt bazowy zaleลผny od typu serwera + base_cost = 0 + server_type = server.get('type', 'vps') + type_multiplier = { + 'vps': pricing.get('multiplier_vps', 1.0), + 'game': pricing.get('multiplier_game', 0.8), + 'website': pricing.get('multiplier_website', 1.5), + 'database': pricing.get('multiplier_database', 1.3) + }.get(server_type, 1.0) + + total_cost = int((base_cost + cpu_cost + ram_cost + storage_cost) * type_multiplier) + + # Konwersja na kredyty (1 euro = 100 kredytรณw) + # Poniewaลผ koszt jest w centach, dzielimy przez 100 aby dostaฤ‡ euro, a potem *100 aby dostaฤ‡ kredyty + total_credits = int(total_cost) # poniewaลผ 1 euro = 100 kredytรณw, a 1 cent = 1 kredyt + + logger.info(f"Monthly cost calculation for server {server.get('name', 'unknown')}:") + logger.info(f" CPU: {cpu_cores} vCPU * {pricing['price_cpu']} = {cpu_cost} cents") + logger.info(f" RAM: {ram_gb}GB * {pricing['price_ram']} = {ram_cost} cents") + logger.info(f" Storage: {storage_gb}GB * {pricing['price_storage']} = {storage_cost} cents") + logger.info(f" Type multiplier: {type_multiplier}") + logger.info(f" Total: {total_cost} cents = {total_credits} credits") + + return total_credits + + except Exception as e: + logger.error(f"Error calculating monthly cost: {e}") + return 1000 # Domyล›lny koszt w przypadku bล‚ฤ™du diff --git a/server_demo.py b/server_demo.py new file mode 100644 index 0000000..cdb9e12 --- /dev/null +++ b/server_demo.py @@ -0,0 +1,215 @@ +"""Server demo operations.""" + + +from database_service import database_connection, dict_from_row +from kubernetes import client +from kubernetes_service import k8s_manager +from runtime_config import ( + DATABASE_URL, + logger, +) +from server_capacity import calculate_server_cost +from typing import Dict +from user_service import add_credit_transaction, get_user_by_id, is_demo_enabled, update_user_credits +import uuid + +__all__ = ['check_demo_eligibility', 'cleanup_expired_demo_servers', 'convert_demo_to_paid', 'mark_demo_used'] + + + + +def check_demo_eligibility(user_id: str) -> Dict: + """Check if user is eligible for demo server""" + try: + if not is_demo_enabled(): + return { + 'eligible': False, + 'has_used_demo': False, + 'has_purchased_credits': False, + 'reason': 'DEMO servers are currently disabled' + } + + with database_connection() as conn: + cursor = conn.cursor() + + # Check if user has used demo before + cursor.execute('SELECT id FROM demo_servers_used WHERE user_id = ?', (user_id,)) + has_used_demo = cursor.fetchone() is not None + + # Check if user has purchased credits (type='purchase') + cursor.execute(''' + SELECT COUNT(*) FROM credit_transactions + WHERE user_id = ? AND type = 'purchase' + ''', (user_id,)) + purchase_count = cursor.fetchone()['count'] + has_purchased_credits = purchase_count > 0 + + eligible = not has_used_demo and not has_purchased_credits + + return { + 'eligible': eligible, + 'has_used_demo': has_used_demo, + 'has_purchased_credits': has_purchased_credits, + 'reason': None if eligible else ( + 'You have already used the demo server' if has_used_demo + else 'You have purchased credits before' + ) + } + except Exception as e: + logger.error(f"Error checking demo eligibility: {e}", exc_info=True) + return {'eligible': False, 'has_used_demo': False, 'has_purchased_credits': False, 'reason': 'Error checking eligibility'} + +def mark_demo_used(user_id: str) -> bool: + """Mark that user has used demo server""" + try: + with database_connection() as conn: + cursor = conn.cursor() + demo_id = f"demo-{str(uuid.uuid4())[:8]}" + + # PostgreSQL-compatible syntax + if 'postgres' in DATABASE_URL: + cursor.execute(''' + INSERT INTO demo_servers_used (id, user_id) + VALUES (%s, %s) + ON CONFLICT (user_id) DO NOTHING + ''', (demo_id, user_id)) + else: + # SQLite syntax + cursor.execute(''' + INSERT OR IGNORE INTO demo_servers_used (id, user_id) + VALUES (?, ?) + ''', (demo_id, user_id)) + + conn.commit() + return True + except Exception as e: + logger.error(f"Error marking demo as used: {e}") + return False + +def convert_demo_to_paid(server_id: str, user_id: str) -> Dict: + """Convert demo server to paid server""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Get server details + cursor.execute(''' + SELECT * FROM servers WHERE id = ? AND owner_id = ? + ''', (server_id, user_id)) + server = dict_from_row(cursor.fetchone()) + + if not server: + raise Exception('Server not found') + + if not server.get('is_demo'): + raise Exception('Server is not a demo server') + + # Calculate server cost + config = { + 'serverType': server['type'], + 'cpu': server['cpu'], + 'ram': server['ram'], + 'storage': server['storage'] + } + server_cost = calculate_server_cost(config) + + # Check user credits + user = get_user_by_id(user_id) + if user.get('credits', 0) < server_cost: + raise Exception(f'Insufficient credits. Need {server_cost}, have {user.get("credits", 0)}') + + # Deduct credits + new_credits = user.get('credits', 0) - server_cost + if not update_user_credits(user_id, new_credits): + raise Exception('Failed to deduct credits') + + add_credit_transaction(user_id, -server_cost, 'deduct', f'Convert demo server to paid: {server["name"]}') + + # Update server to remove demo status + cursor.execute(''' + UPDATE servers + SET is_demo = FALSE, demo_expires_at = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (server_id,)) + conn.commit() + + logger.info(f"โœ“ Converted demo server {server_id} to paid server") + + return { + 'success': True, + 'server_id': server_id, + 'credits_charged': server_cost, + 'new_balance': new_credits + } + except Exception as e: + logger.error(f"Error converting demo to paid: {e}") + raise Exception(f'Failed to convert demo server: {str(e)}') + +def cleanup_expired_demo_servers(): + """Delete expired demo servers""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Find expired demo servers + cursor.execute(''' + SELECT id, name, owner_id, namespace, type, k8s_name FROM servers + WHERE is_demo = TRUE + AND demo_expires_at IS NOT NULL + AND demo_expires_at < CURRENT_TIMESTAMP + ''') + expired_servers = cursor.fetchall() + + deleted_count = 0 + for row in expired_servers: + server_id = row[0] + server_name = row[1] + owner_id = row[2] + namespace = row[3] if len(row) > 3 else None + server_type = row[4] if len(row) > 4 else 'vps' + k8s_name = row[5] if len(row) > 5 else server_id # Use k8s_name if available, fallback to server_id + + try: + # Delete from Kubernetes first if connected + if k8s_manager.connected and namespace: + try: + logger.info(f"Attempting to delete Kubernetes resources for expired demo server {server_id} (k8s_name: {k8s_name}) in namespace {namespace}") + # Import here to avoid issues if k8s not connected + from kubernetes.client.rest import ApiException + apps_v1 = client.AppsV1Api() + v1 = client.CoreV1Api() + + # Try to delete deployment using k8s_name + try: + apps_v1.delete_namespaced_deployment(name=k8s_name, namespace=namespace) + logger.info(f"โœ“ Deleted deployment for demo server {server_id} (k8s_name: {k8s_name})") + except ApiException as e: + if e.status != 404: # 404 means it doesn't exist, which is fine + logger.warning(f"Could not delete deployment for {server_id} (k8s_name: {k8s_name}): {e}") + + # Try to delete service + try: + v1.delete_namespaced_service(name=f"{k8s_name}-service", namespace=namespace) + logger.info(f"โœ“ Deleted service for demo server {server_id} (k8s_name: {k8s_name})") + except ApiException as e: + if e.status != 404: + logger.warning(f"Could not delete service for {server_id} (k8s_name: {k8s_name}): {e}") + except Exception as k8s_error: + logger.error(f"Error deleting Kubernetes resources for {server_id}: {k8s_error}") + + # Delete server from database (this will cascade delete related records) + cursor.execute('DELETE FROM servers WHERE id = ?', (server_id,)) + conn.commit() + + deleted_count += 1 + logger.info(f"โœ“ Deleted expired demo server: {server_name} ({server_id})") + except Exception as e: + logger.error(f"Error deleting expired demo server {server_id}: {e}") + + if deleted_count > 0: + logger.info(f"โœ“ Cleaned up {deleted_count} expired demo server(s)") + + return deleted_count + except Exception as e: + logger.error(f"Error cleaning up expired demo servers: {e}") + return 0 diff --git a/server_lifecycle.py b/server_lifecycle.py new file mode 100644 index 0000000..cd9d648 --- /dev/null +++ b/server_lifecycle.py @@ -0,0 +1,747 @@ +"""Server lifecycle operations.""" + + +from database_service import database_connection +from kubernetes import client +from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1 +from runtime_config import ( + logger, +) +from server_capacity import calculate_monthly_server_cost, calculate_server_cost, validate_server_capacity +from server_provisioning import wait_and_sync_pod_status, wait_and_sync_vm_status +from server_records import delete_server_record, get_server_by_id, update_server_status +import threading +import time +from typing import Dict +from user_service import add_credit_transaction, get_user_by_id, update_user_credits + +__all__ = ['adjust_server_parameters', 'delete_server', 'restart_server', 'start_server', 'stop_server', 'update_server_resources_in_k8s'] + + + + +def delete_server(server_id: str, user_id: str) -> bool: + try: + user = get_user_by_id(user_id) + if not user: + raise Exception('User not found') + + server = get_server_by_id(server_id) + if not server: + raise Exception('Server not found') + + # Verify ownership or admin + server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) + req_id = str(user_id) + + if server_owner_id != req_id and user.get('role') != 'admin': + raise Exception("Unauthorized: Access denied") + + # Get owner for namespace + if server_owner_id == req_id: + owner = user + else: + owner = get_user_by_id(server_owner_id) + if not owner: + raise Exception("Server owner not found") + + original_cost = calculate_server_cost({ + 'serverType': server['type'], + 'cpu': server['cpu'], + 'ram': server['ram'], + 'storage': server['storage'] + }) + refund_amount = int(original_cost * 0.5) + + if not k8s_manager.connected: + if delete_server_record(server_id): + # Removed refund logic + pass + return True + + namespace = f"user-{owner['username']}".lower() + + try: + custom_objects_api.delete_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + name=server_id + ) + except Exception: + pass + + try: + custom_objects_api.delete_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachineinstances", + name=server_id + ) + except Exception: + pass + + try: + apps_v1.delete_namespaced_deployment(name=server_id, namespace=namespace) + except Exception: + pass + + try: + v1.delete_namespaced_service(name=f"{server_id}-service", namespace=namespace) + except Exception: + pass + + try: + v1.delete_namespaced_persistent_volume_claim(name=f"{server_id}-data", namespace=namespace) + except Exception: + pass + + try: + v1.delete_namespaced_persistent_volume_claim(name=f"{server_id}-rootdisk", namespace=namespace) + except Exception: + pass + + if delete_server_record(server_id): + # Removed refund logic + pass + + return True + except Exception as e: + raise Exception(f'Failed to delete server: {str(e)}') + +def start_server(server_id: str, user_id: str) -> bool: + try: + # Step 1: Verify server exists + server = get_server_by_id(server_id) + if not server: + raise Exception('Server not found') + + # Step 2: Get user + user = get_user_by_id(user_id) + if not user: + raise Exception('User not found') + + # Verify server ownership or admin access + server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) + req_id = str(user_id) + + if server_owner_id != req_id and user.get('role') != 'admin': + raise Exception("Unauthorized: You don't own this server") + + # Get owner for namespace determination + if server_owner_id == req_id: + owner = user + else: + owner = get_user_by_id(server_owner_id) + if not owner: + raise Exception("Server owner not found") + + # Step 4: Check if user has enough credits (must be non-negative) + # Use owner's credits check if admin is starting, or just skip? + # Ideally check owner's credits. + owner_credits = owner.get('credits', 0) + if owner_credits < 0: + raise Exception(f'Insufficient credits to start server. Owner has {owner_credits} credits.') + + # Step 5: Check capacity if server is not already running + if server.get('status') != 'Running': + logger.info(f"๐Ÿ” Validating capacity before starting server: {server_id}") + server_config = { + 'type': server['type'], + 'cpu': server['cpu'], + 'ram': server['ram'], + 'storage': server['storage'] + } + capacity_check = validate_server_capacity(server_config) + if not capacity_check['success']: + error_msg = f"Insufficient capacity to start server: {capacity_check['message']}" + logger.error(f"โŒ Capacity validation failed: {error_msg}") + raise Exception(error_msg) + logger.info(f"โœ… Capacity validation passed: {capacity_check['message']}") + + if not k8s_manager.connected: + update_server_status(server_id, 'Running') + return True + + namespace = f"user-{owner['username']}".lower() + + try: + patch = {'spec': {'runStrategy': 'Always'}} + custom_objects_api.patch_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + name=server_id, + body=patch + ) + update_server_status(server_id, 'Starting') + + threading.Thread( + target=wait_and_sync_vm_status, + args=(server_id, owner['id'], namespace), + daemon=True + ).start() + + return True + except Exception: + pass + + try: + patch = {'spec': {'replicas': 1}} + apps_v1.patch_namespaced_deployment_scale(name=server_id, namespace=namespace, body=patch) + update_server_status(server_id, 'Starting') + return True + except Exception: + pass + + return False + except Exception as e: + raise Exception(f'Failed to start server: {str(e)}') + +def stop_server(server_id: str, user_id: str) -> bool: + try: + # Step 1: Verify server exists + server = get_server_by_id(server_id) + if not server: + raise Exception('Server not found') + + # Step 2: Get user + user = get_user_by_id(user_id) + if not user: + raise Exception('User not found') + + # Verify server ownership or admin access + server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) + req_id = str(user_id) + + if server_owner_id != req_id and user.get('role') != 'admin': + raise Exception("Unauthorized: You don't own this server") + + # Get owner for namespace determination + if server_owner_id == req_id: + owner = user + else: + owner = get_user_by_id(server_owner_id) + if not owner: + raise Exception("Server owner not found") + + if not k8s_manager.connected: + update_server_status(server_id, 'Stopped') + return True + + namespace = f"user-{owner['username']}".lower() + + try: + patch = {'spec': {'runStrategy': 'Halted'}} + custom_objects_api.patch_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + name=server_id, + body=patch + ) + update_server_status(server_id, 'Stopping') + return True + except Exception: + pass + + try: + patch = {'spec': {'replicas': 0}} + apps_v1.patch_namespaced_deployment_scale(name=server_id, namespace=namespace, body=patch) + update_server_status(server_id, 'Stopping') + return True + except Exception: + pass + + return False + except Exception as e: + raise Exception(f'Failed to stop server: {str(e)}') + +def restart_server(server_id: str, user_id: str, skip_checks: bool = False) -> bool: + try: + # Step 1: Verify server exists + server = get_server_by_id(server_id) + if not server: + raise Exception('Server not found') + + # Step 2: Get user + user = get_user_by_id(user_id) + if not user: + raise Exception('User not found') + + # Verify server ownership or admin access + server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) + req_id = str(user_id) + + if server_owner_id != req_id and user.get('role') != 'admin': + raise Exception("Unauthorized: You don't own this server") + + # Get owner for namespace determination + if server_owner_id == req_id: + owner = user + else: + owner = get_user_by_id(server_owner_id) + if not owner: + raise Exception("Server owner not found") + + # Step 4: Check if user has enough credits (skip for downgrades) + if not skip_checks: + # Check owner's credits + owner_credits = owner.get('credits', 0) + if owner_credits < 0: + raise Exception(f'Insufficient credits to restart server. Owner has {owner_credits} credits.') + + # Step 5: Check capacity before restarting (skip for downgrades) + if not skip_checks: + logger.info(f"๐Ÿ” Validating capacity before restarting server: {server_id}") + server_config = { + 'type': server['type'], + 'cpu': server['cpu'], + 'ram': server['ram'], + 'storage': server['storage'] + } + capacity_check = validate_server_capacity(server_config) + if not capacity_check['success']: + error_msg = f"Insufficient capacity to restart server: {capacity_check['message']}" + logger.error(f"โŒ Capacity validation failed: {error_msg}") + raise Exception(error_msg) + logger.info(f"โœ… Capacity validation passed: {capacity_check['message']}") + + if not k8s_manager.connected: + update_server_status(server_id, 'Restarting') + time.sleep(2) + update_server_status(server_id, 'Running') + return True + + namespace = f"user-{owner['username']}".lower() + update_server_status(server_id, 'Restarting') + + try: + stop_patch = {'spec': {'runStrategy': 'Halted'}} + custom_objects_api.patch_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + name=server_id, + body=stop_patch + ) + + time.sleep(5) + + start_patch = {'spec': {'runStrategy': 'Always'}} + custom_objects_api.patch_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + name=server_id, + body=start_patch + ) + + threading.Thread( + target=wait_and_sync_vm_status, + args=(server_id, owner['id'], namespace), + daemon=True + ).start() + + return True + except Exception: + pass + + try: + pods = k8s_manager.get_pods_for_server(namespace, server_id) + if pods: + for pod in pods: + v1.delete_namespaced_pod(name=pod, namespace=namespace) + update_server_status(server_id, 'Restarting') + + # Wait for pods to restart and update status + threading.Thread( + target=wait_and_sync_pod_status, + args=(server_id, user_id, namespace), + daemon=True + ).start() + + return True + except Exception: + pass + + return False + except Exception as e: + raise Exception(f'Failed to restart server: {str(e)}') + +def update_server_resources_in_k8s(server: Dict, new_cpu: str, new_ram: str, new_storage: str, namespace: str) -> bool: + """Update server resources (CPU, RAM, Storage) in Kubernetes""" + try: + server_type = server.get('type') + server_id = server['id'] + + if server_type == 'vps': + # Update VirtualMachine resources + cpu_cores = int(new_cpu.split()[0]) + memory_size = new_ram.replace('GB', 'Gi') + + # Parse storage size + import re + storage_match = re.search(r'(\d+)\s*GB', new_storage, re.IGNORECASE) + if storage_match: + storage_gb = int(storage_match.group(1)) + storage_size = f"{storage_gb}Gi" + else: + storage_match = re.search(r'(\d+)\s*GiB', new_storage, re.IGNORECASE) + if storage_match: + storage_size = f"{storage_match.group(1)}Gi" + else: + raise Exception(f"Invalid storage format: {new_storage}") + + # Get current VM spec to preserve network interfaces and other configurations + try: + current_vm = custom_objects_api.get_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + name=server_id + ) + + # Preserve existing domain configuration (especially devices/interfaces) + current_domain = current_vm.get('spec', {}).get('template', {}).get('spec', {}).get('domain', {}) + current_devices = current_domain.get('devices', {}) + current_machine = current_domain.get('machine', {}) + + # Build patch with CPU and resources, explicitly preserving devices (network interfaces) + domain_patch = { + 'cpu': { + 'cores': cpu_cores, + 'sockets': 1, + 'threads': 1 + }, + 'resources': { + 'requests': { + 'memory': memory_size, + 'cpu': f'{cpu_cores * 100}m' + }, + 'limits': { + 'memory': memory_size, + 'cpu': f'{cpu_cores * 1000}m' + } + } + } + + # Preserve devices (network interfaces) if they exist + if current_devices: + domain_patch['devices'] = current_devices + + # Preserve machine type if it exists + if current_machine: + domain_patch['machine'] = current_machine + + vm_patch = { + 'spec': { + 'template': { + 'spec': { + 'domain': domain_patch + } + } + } + } + + # Use JSON merge patch (default) which preserves fields not mentioned + # But we explicitly include devices to ensure they're preserved + custom_objects_api.patch_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + name=server_id, + body=vm_patch + ) + + logger.info(f"โœ“ Updated VM {server_id} resources while preserving network interfaces") + + except Exception as e: + logger.error(f"Error updating VM resources: {e}") + raise + + # Update storage (PVC resize) + # Find the PVC name - it's usually {server_id}-rootdisk + pvc_name = f"{server_id}-rootdisk" + try: + pvc = v1.read_namespaced_persistent_volume_claim(name=pvc_name, namespace=namespace) + if pvc: + # Resize PVC + pvc_patch = { + 'spec': { + 'resources': { + 'requests': { + 'storage': storage_size + } + } + } + } + v1.patch_namespaced_persistent_volume_claim( + name=pvc_name, + namespace=namespace, + body=pvc_patch + ) + logger.info(f"โœ“ Updated PVC {pvc_name} to {storage_size}") + except Exception as e: + logger.warning(f"Could not resize PVC {pvc_name}: {e}") + + logger.info(f"โœ“ Updated VM {server_id} resources: CPU={cpu_cores}, RAM={memory_size}") + return True + + elif server_type == 'game': + # Update Deployment resources + cpu_cores = int(new_cpu.split()[0]) + ram_gb = int(re.search(r'(\d+)\s*GB', new_ram, re.IGNORECASE).group(1)) + memory_size = f"{ram_gb}Gi" + + # Parse storage size + storage_match = re.search(r'(\d+)\s*GB', new_storage, re.IGNORECASE) + if storage_match: + storage_gb = int(storage_match.group(1)) + storage_size = f"{storage_gb}Gi" + else: + storage_match = re.search(r'(\d+)\s*GiB', new_storage, re.IGNORECASE) + if storage_match: + storage_size = f"{storage_match.group(1)}Gi" + else: + raise Exception(f"Invalid storage format: {new_storage}") + + # Get current deployment + deployment = apps_v1.read_namespaced_deployment(name=server_id, namespace=namespace) + + # Update container resources and environment variables + if deployment.spec.template.spec.containers: + container = deployment.spec.template.spec.containers[0] + container.resources = client.V1ResourceRequirements( + requests={ + 'memory': memory_size, + 'cpu': f'{cpu_cores * 100}m' + }, + limits={ + 'memory': memory_size, + 'cpu': f'{cpu_cores * 1000}m' + } + ) + + # Update MEMORY environment variable if it exists + if container.env: + for env_var in container.env: + if env_var.name == 'MEMORY': + env_var.value = f"{ram_gb}G" + logger.info(f"โœ“ Updated MEMORY env var to {ram_gb}G for server {server_id}") + + # Update deployment + apps_v1.patch_namespaced_deployment( + name=server_id, + namespace=namespace, + body=deployment + ) + + # Update storage (PVC resize) + pvc_name = f"{server_id}-data" + try: + pvc = v1.read_namespaced_persistent_volume_claim(name=pvc_name, namespace=namespace) + if pvc: + pvc_patch = { + 'spec': { + 'resources': { + 'requests': { + 'storage': storage_size + } + } + } + } + v1.patch_namespaced_persistent_volume_claim( + name=pvc_name, + namespace=namespace, + body=pvc_patch + ) + logger.info(f"โœ“ Updated PVC {pvc_name} to {storage_size}") + except Exception as e: + logger.warning(f"Could not resize PVC {pvc_name}: {e}") + + logger.info(f"โœ“ Updated Deployment {server_id} resources: CPU={cpu_cores}, RAM={memory_size}") + return True + + else: + # For other server types (website, database), update deployment similarly + cpu_cores = int(new_cpu.split()[0]) + ram_gb = int(re.search(r'(\d+)\s*GB', new_ram, re.IGNORECASE).group(1)) + memory_size = f"{ram_gb}Gi" + + try: + deployment = apps_v1.read_namespaced_deployment(name=server_id, namespace=namespace) + if deployment.spec.template.spec.containers: + container = deployment.spec.template.spec.containers[0] + container.resources = client.V1ResourceRequirements( + requests={ + 'memory': memory_size, + 'cpu': f'{cpu_cores * 100}m' + }, + limits={ + 'memory': memory_size, + 'cpu': f'{cpu_cores * 1000}m' + } + ) + + # Update MEMORY environment variable if it exists + if container.env: + for env_var in container.env: + if env_var.name == 'MEMORY': + env_var.value = f"{ram_gb}G" + logger.info(f"โœ“ Updated MEMORY env var to {ram_gb}G for server {server_id}") + + apps_v1.patch_namespaced_deployment( + name=server_id, + namespace=namespace, + body=deployment + ) + logger.info(f"โœ“ Updated Deployment {server_id} resources") + return True + except Exception as e: + logger.error(f"Error updating deployment resources: {e}") + return False + + except Exception as e: + logger.error(f"Error updating server resources in K8s: {e}") + return False + +def adjust_server_parameters(server_id: str, user_id: str, new_cpu: str, new_ram: str, new_storage: str) -> Dict: + """Adjust server parameters (upgrade/downgrade) with credit calculation""" + try: + # Get user first + user = get_user_by_id(user_id) + if not user: + raise Exception("User not found") + + # Get server + server = get_server_by_id(server_id) + if not server: + raise Exception("Server not found") + + # Verify server ownership or admin access + server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) + req_id = str(user_id) + + if server_owner_id != req_id and user.get('role') != 'admin': + raise Exception("Unauthorized: You don't own this server") + + # Step 1: Validate capacity for new resources BEFORE proceeding + new_server_config = { + 'name': server['name'], + 'type': server['type'], + 'cpu': new_cpu, + 'ram': new_ram, + 'storage': new_storage + } + + # Check if this is an upgrade (increased resources) + current_cpu = int(server['cpu'].split()[0]) + current_ram = int(server['ram'].replace('GB', '')) + current_storage = int(server['storage'].replace('GB', '')) + new_cpu_val = int(new_cpu.split()[0]) + new_ram_val = int(new_ram.replace('GB', '')) + new_storage_val = int(new_storage.replace('GB', '')) + + is_upgrade = (new_cpu_val > current_cpu or new_ram_val > current_ram or new_storage_val > current_storage) + + # Only check capacity if upgrading (increasing resources) + if is_upgrade: + logger.info(f"๐Ÿ” Validating capacity for server parameter upgrade: {server_id}") + capacity_check = validate_server_capacity(new_server_config) + if not capacity_check['success']: + error_msg = f"Insufficient capacity: {capacity_check['message']}" + logger.error(f"โŒ Capacity validation failed: {error_msg}") + raise Exception(error_msg) + logger.info(f"โœ… Capacity validation passed: {capacity_check['message']}") + + # Calculate current monthly cost + current_monthly_cost = calculate_monthly_server_cost(server) + + # Calculate new monthly cost + new_monthly_cost = calculate_monthly_server_cost(new_server_config) + + # Calculate credit charge based on upgrade/downgrade + if is_upgrade: + # For upgrades: charge the difference between new and current monthly costs + # Ensure we never charge negative credits (safeguard) + credits_to_charge = max(0, new_monthly_cost - current_monthly_cost) + else: + # For downgrades: no charge + credits_to_charge = 0 + + logger.info(f"Server parameter adjustment for {server_id}:") + logger.info(f" Current monthly cost: {current_monthly_cost} credits") + logger.info(f" New monthly cost: {new_monthly_cost} credits") + logger.info(f" Credits to charge: {credits_to_charge} credits") + logger.info(f" Is upgrade: {is_upgrade}") + + # Only check credits when UPGRADING (increasing resources) + # When downgrading, we should never check credits regardless of credits_to_charge value + if is_upgrade and credits_to_charge > 0: + user_credits = user.get('credits', 0) + if user_credits < credits_to_charge: + raise Exception(f'Insufficient credits. Need {credits_to_charge} credits, have {user_credits}') + + # Deduct credits + new_credits = user_credits - credits_to_charge + if not update_user_credits(user_id, new_credits): + raise Exception('Failed to deduct credits') + + add_credit_transaction( + user_id, + -credits_to_charge, + 'server_upgrade', + f'Server upgrade: {server["name"]} - {credits_to_charge} credits' + ) + logger.info(f"โœ“ Charged {credits_to_charge} credits for server upgrade") + elif not is_upgrade: + # For downgrades, never check credits - allow the downgrade regardless + logger.info(f"โœ“ Server downgrade: no credit check required (decreasing resources)") + elif credits_to_charge <= 0: + # credits_to_charge <= 0, no charge needed (edge case where upgrade doesn't require additional credits) + logger.info(f"โœ“ No credit adjustment needed") + + # Update server in database + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE servers + SET cpu = ?, ram = ?, storage = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (new_cpu, new_ram, new_storage, server_id)) + conn.commit() + + # Update server resources in Kubernetes + namespace = f"user-{user['username']}".lower() + if k8s_manager.connected: + if not update_server_resources_in_k8s(server, new_cpu, new_ram, new_storage, namespace): + logger.warning(f"Failed to update server resources in K8s, but database was updated") + + # Restart server to apply changes + # Skip credit/capacity checks for downgrades (when credits_to_charge <= 0) + skip_checks = credits_to_charge <= 0 + restart_server(server_id, user_id, skip_checks=skip_checks) + + logger.info(f"โœ“ Successfully adjusted server parameters for {server_id}") + + return { + 'success': True, + 'message': 'Server parameters adjusted successfully', + 'credits_charged': credits_to_charge if credits_to_charge > 0 else 0, + 'new_monthly_cost': new_monthly_cost, + 'current_monthly_cost': current_monthly_cost, + 'is_upgrade': is_upgrade + } + + except Exception as e: + logger.error(f"Error adjusting server parameters: {e}") + raise Exception(f'Failed to adjust server parameters: {str(e)}') diff --git a/server_provisioning.py b/server_provisioning.py new file mode 100644 index 0000000..4fceec2 --- /dev/null +++ b/server_provisioning.py @@ -0,0 +1,2031 @@ +"""Server provisioning operations.""" + + +from database_service import database_connection +from datetime import datetime, timedelta, timezone +import json +from kubernetes import client +from kubernetes_service import apps_v1, custom_objects_api, k8s_manager, v1 +import random +import re +from runtime_config import ( + NODE_IP, + logger, +) +from server_capacity import calculate_server_cost, validate_server_capacity +from server_demo import check_demo_eligibility, mark_demo_used +from server_records import create_server_record, get_server_access_and_namespace, get_server_by_id, update_server_status +import threading +import time +from typing import Dict, List, Optional, Tuple +import urllib.parse +from user_service import add_credit_transaction, get_user_by_id, update_user_credits +import uuid + +__all__ = ['change_server_version', 'convert_storage_to_k8s_format', 'create_database_server', 'create_game_server', 'create_server', 'create_vps_server', 'create_website_server', 'create_windows_vps', 'extract_server_from_url', 'extract_token_from_url', 'generate_sftp_credentials', 'get_db_mount_path', 'get_default_ports', 'get_minecraft_image', 'get_sftp_sidecar_container', 'get_storage_size', 'reinstall_server', 'save_server_creation_date', 'sync_server_ports_from_k8s', 'sync_server_status_from_k8s', 'validate_server_name', 'wait_and_sync_pod_status', 'wait_and_sync_vm_status'] + + + + +def change_server_version(server_id: str, user_id: str, new_version: str, java_version: str = None, loader: str = None) -> bool: + """Change server version by reinstalling the server with new version (free of charge)""" + try: + # Verify user exists + user = get_user_by_id(user_id) + if not user: + raise Exception("User not found") + + # Verify server ownership + server = get_server_by_id(server_id) + if not server: + raise Exception("Server not found") + + server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) + req_id = str(user_id) + + if server_owner_id != req_id and user.get('role') != 'admin': + raise Exception("Unauthorized: You don't own this server") + + # Get owner for namespace determination + if server_owner_id == req_id: + owner = user + else: + owner = get_user_by_id(server_owner_id) + if not owner: + raise Exception("Server owner not found") + + # Prohibit version changes for demo servers + if server.get('is_demo') == True or server.get('is_demo') == 1: + raise Exception("Version changes are not allowed for demo servers") + + # Use provided values or fallback to current ones, or defaults if not set + final_java_version = java_version if java_version else server.get('java_version') + final_loader = loader if loader else server.get('loader') + + # Only reinstall game servers (especially Minecraft) + if server['type'] != 'game' or server.get('image') != 'minecraft': + # For non-game servers or non-Minecraft servers, just update the database + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET version = ?, java_version = ?, loader = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (new_version, final_java_version, final_loader, server_id) + ) + conn.commit() + logger.info(f"โœ“ Changed version for server {server_id} to {new_version} (billing date unchanged)") + return cursor.rowcount > 0 + + namespace = f"user-{owner['username']}".lower() + + if not k8s_manager.connected: + # If K8s not connected, just update database + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET version = ?, java_version = ?, loader = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (new_version, final_java_version, final_loader, server_id) + ) + conn.commit() + logger.info(f"โœ“ Changed version for server {server_id} to {new_version} (K8s not connected, DB only)") + return cursor.rowcount > 0 + + # Get ports from server config (parse from JSON string if needed) + ports = server.get('ports', []) + if isinstance(ports, str): + try: + ports = json.loads(ports) + except: + ports = [] + if not ports: + ports = get_default_ports('game', server.get('image', 'minecraft')) + + # Delete old deployment + try: + apps_v1.delete_namespaced_deployment(name=server_id, namespace=namespace) + logger.info(f"โœ“ Deleted old deployment for server {server_id}") + # Wait a bit for deletion to complete + time.sleep(2) + except Exception as e: + logger.warning(f"Could not delete old deployment (may not exist): {e}") + + # Recreate deployment with new version + if server.get('image') == 'minecraft': + container_image = get_minecraft_image(new_version, final_java_version) + else: + image_map = { + 'fivem': 'fivem/fivem:latest', + 'cs2': 'cm2network/cs2:latest', + 'rust': 'cm2network/rust:latest', + 'ark': 'cm2network/arkserver:latest', + 'valheim': 'lloesche/valheim-server:latest' + } + container_image = image_map.get(server.get('image', 'minecraft'), 'itzg/minecraft-server:latest') + + # Game-specific environment variables with new version + env_vars = [] + if server.get('image') == 'minecraft': + # Use the allocated RAM for Minecraft JVM + memory_val = server.get('ram', '3GB').replace('GB', 'G') + + # Determine server type (loader) + server_type_env = 'PAPER' + if final_loader: + server_type_env = final_loader.upper() + + version_val = new_version + if server_type_env == 'PAPER' and version_val == '26.1': + version_val = '26.1.2' + + env_vars = [ + client.V1EnvVar(name='EULA', value='TRUE'), + client.V1EnvVar(name='TYPE', value=server_type_env), + client.V1EnvVar(name='VERSION', value=version_val), + client.V1EnvVar(name='MEMORY', value=memory_val) + ] + elif server.get('image') == 'fivem': + env_vars = [ + client.V1EnvVar(name='FIVEM_LICENSE_KEY', value='your-license-key-here') + ] + + # Get PVC name (should already exist) + pvc_name = f"{server_id}-data" + + # Calculate resources + try: + cpu_cores = int(server['cpu'].split()[0]) + ram_gb = int(server['ram'].replace('GB', '')) + memory_limit = f"{ram_gb}Gi" + memory_request = f"{ram_gb * 0.5}Gi" + except: + cpu_cores = 1 + memory_limit = "2Gi" + memory_request = "1Gi" + + # Create new deployment with updated version + deployment = client.V1Deployment( + metadata=client.V1ObjectMeta(name=server_id, namespace=namespace), + spec=client.V1DeploymentSpec( + replicas=1, + selector=client.V1LabelSelector(match_labels={'app': server_id}), + template=client.V1PodTemplateSpec( + metadata=client.V1ObjectMeta(labels={'app': server_id}), + spec=client.V1PodSpec( + containers=[ + client.V1Container( + name=server_id, + image=container_image, + ports=[client.V1ContainerPort(container_port=p['internal']) for p in ports], + env=env_vars, + volume_mounts=[client.V1VolumeMount(name='data', mount_path='/data')], + resources=client.V1ResourceRequirements( + requests={ + 'cpu': f'{cpu_cores * 50}m', + 'memory': memory_request + }, + limits={ + 'cpu': f'{cpu_cores * 1000}m', + 'memory': memory_limit + } + ) + ) + ], + volumes=[client.V1Volume(name='data', persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(claim_name=pvc_name))] + ) + ) + ) + ) + + apps_v1.create_namespaced_deployment(namespace=namespace, body=deployment) + logger.info(f"โœ“ Created new deployment for server {server_id} with version {new_version}, type {final_loader}, java {final_java_version}") + + # Update database version + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET version = ?, java_version = ?, loader = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (new_version, final_java_version, final_loader, server_id) + ) + conn.commit() + + logger.info(f"โœ“ Changed version for server {server_id} to {new_version} (server reinstalled, billing date unchanged)") + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error changing server version: {e}", exc_info=True) + raise + +def sync_server_status_from_k8s(server_id: str, user_id: str) -> bool: + """Sync server status from Kubernetes""" + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return True + + if server['type'] == 'vps': + vm_status = k8s_manager.get_vm_status(namespace, server['k8s_name']) + phase = vm_status.get('phase', 'Unknown') + ready = vm_status.get('ready', False) + run_strategy = vm_status.get('runStrategy', 'Always') + + # Check if VM is stopped (Halted) + if run_strategy == 'Halted' or phase == 'Stopped': + new_status = 'Stopped' + message = 'VM is stopped' + elif phase == 'Running' and ready: + new_status = 'Running' + message = 'VM is running and ready' + elif phase == 'Scheduling': + new_status = 'Starting' + message = 'VM is being scheduled' + elif phase == 'Pending': + new_status = 'Creating' + message = 'VM is pending creation' + else: + new_status = 'Error' + message = f'VM in unexpected state: {phase}' + + else: + pods = k8s_manager.get_pods_for_server(namespace, server_id) + if pods: + # Default to Running, but check if any pod is still creating + new_status = 'Running' + message = 'Pod is running' + + # If we have multiple pods (less common for game servers but possible), + # check if all of them are actually Running and Ready + for pod_name in pods: + try: + pod = v1.read_namespaced_pod(name=pod_name, namespace=namespace) + if pod.status.phase != 'Running': + new_status = 'Creating' + message = f'Pod is {pod.status.phase}' + break + + # Check container statuses for ContainerCreating + if pod.status.container_statuses: + for container_status in pod.status.container_statuses: + if not container_status.ready: + new_status = 'Creating' + if container_status.state.waiting: + message = f'Container: {container_status.state.waiting.reason}' + else: + message = 'Container is not ready' + break + if new_status == 'Creating': + break + except Exception as e: + logger.warning(f"Error checking pod status for {pod_name}: {e}") + new_status = 'Creating' + message = 'Checking pod status...' + break + else: + new_status = 'Creating' + message = 'Waiting for pod to start' + + if server['status'] != new_status: + update_server_status(server_id, new_status, message) + + return True + + except Exception as e: + return False + +def sync_server_ports_from_k8s(server_id: str, user_id: str) -> bool: + """Sync server ports from Kubernetes - FIXED VERSION""" + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + return True + + service_name = f"{server_id}-service" + + try: + service_ports = k8s_manager.get_service_ports(namespace, service_name) + logger.info(f"Retrieved service ports for {service_name}: {service_ports}") + + # Update ports with actual node_port values from Kubernetes + updated_ports = [] + for port_config in server['ports']: + # Find matching port from Kubernetes service + matching_port = next((p for p in service_ports if p['port'] == port_config['internal']), None) + + if matching_port and matching_port.get('node_port'): + # Use the actual node_port assigned by Kubernetes + port_config['external'] = matching_port['node_port'] + logger.info(f"Updated port {port_config['internal']} -> external: {port_config['external']}") + else: + # Keep existing external port if no match found + logger.warning(f"No matching port found for internal port {port_config['internal']}") + + updated_ports.append(port_config) + + # Update database with corrected ports + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET ports = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (json.dumps(updated_ports), server_id) + ) + conn.commit() + + logger.info(f"Successfully synced ports for server {server_id}: {updated_ports}") + return True + + except Exception as e: + logger.error(f"Error syncing ports for server {server_id}: {e}") + return False + + except Exception as e: + logger.error(f"Error in sync_server_ports_from_k8s: {e}") + return False + +def reinstall_server(server_id: str, user_id: str) -> bool: + """Reinstall server (redeploys container/VM)""" + try: + # Step 1: Verify server exists + server = get_server_by_id(server_id) + if not server: + raise Exception('Server not found') + + # Step 2: Get user + user = get_user_by_id(user_id) + if not user: + raise Exception('User not found') + + # Verify server ownership or admin access + server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) + req_id = str(user_id) + + if server_owner_id != req_id and user.get('role') != 'admin': + raise Exception("Unauthorized: You don't own this server") + + # Get owner for namespace determination + if server_owner_id == req_id: + owner = user + else: + owner = get_user_by_id(server_owner_id) + if not owner: + raise Exception("Server owner not found") + + namespace = f"user-{owner['username']}".lower() + + if not k8s_manager.connected: + update_server_status(server_id, 'Running') + logger.info(f"โœ“ Reinstalled server {server_id} (K8s not connected, just status update)") + return True + + # Use change_server_version logic if possible (it handles deployment recreation) + if server['type'] == 'game' and server.get('image'): + # Reuse current version + return change_server_version(server_id, user_id, server['version']) + + # For other types, force pod deletion (soft reinstall) + try: + # Delete pods + pods = k8s_manager.get_pods_for_server(namespace, server_id) + if pods: + for pod in pods: + v1.delete_namespaced_pod(name=pod, namespace=namespace) + + # Ensure it starts up again + update_server_status(server_id, 'Restarting') + + # Wait a bit then check + threading.Thread( + target=wait_and_sync_vm_status if server['type'] == 'vps' else wait_and_sync_pod_status, + args=(server_id, owner['id'], namespace), + daemon=True + ).start() + + return True + except Exception as e: + logger.error(f"Error during soft reinstall: {e}") + return False + + except Exception as e: + logger.error(f"Error reinstalling server: {e}") + raise Exception(f'Failed to reinstall server: {str(e)}') + +def extract_token_from_url(path: str) -> Optional[str]: + try: + if '?' in path: + query_string = path.split('?', 1)[1] + query_params = urllib.parse.parse_qs(query_string) + token_list = query_params.get('token') + if token_list and len(token_list) > 0: + return token_list[0] + return None + except Exception: + return None + +def extract_server_from_url(path: str) -> Optional[str]: + try: + if '?' in path: + query_string = path.split('?', 1)[1] + query_params = urllib.parse.parse_qs(query_string) + server_list = query_params.get('server') + if server_list and len(server_list) > 0: + return server_list[0] + return None + except Exception: + return None + +def convert_storage_to_k8s_format(storage: str) -> str: + """Convert storage format""" + if storage.endswith('GB'): + return storage.replace('GB', 'Gi') + elif storage.endswith('G'): + return storage.replace('G', 'Gi') + elif storage.endswith('Gi'): + return storage + return storage + +def get_default_ports(server_type: str, image: str) -> List[Dict]: + """Get default ports with actual external port numbers""" + external_port = random.randint(30000, 32768) + if server_type == 'website': + return [{'internal': 80, 'external': external_port, 'protocol': 'TCP', 'name': 'http'}] + elif server_type == 'game': + if image == 'minecraft': return [{'internal': 25565, 'external': external_port, 'protocol': 'TCP', 'name': 'minecraft'}] + if image == 'fivem': return [{'internal': 30120, 'external': external_port, 'protocol': 'TCP', 'name': 'fivem'}] + if image == 'cs2': return [{'internal': 27015, 'external': external_port, 'protocol': 'TCP', 'name': 'cs2'}] + if image == 'rust': return [ + {'internal': 28015, 'external': external_port, 'protocol': 'UDP', 'name': 'rust-game'}, + {'internal': 28016, 'external': external_port + 1, 'protocol': 'TCP', 'name': 'rust-rcon'} + ] + if image == 'hytale': return [{'internal': 5520, 'external': external_port, 'protocol': 'UDP', 'name': 'hytale'}] + if image == 'unturned': return [ + {'internal': 27015, 'external': external_port, 'protocol': 'UDP', 'name': 'unturned-game'}, + {'internal': 27016, 'external': external_port + 1, 'protocol': 'UDP', 'name': 'unturned-query'} + ] + if image == 'zomboid': return [ + {'internal': 16261, 'external': external_port, 'protocol': 'UDP', 'name': 'zomboid-game'}, + {'internal': 8766, 'external': external_port + 1, 'protocol': 'UDP', 'name': 'zomboid-steam1'}, + {'internal': 8767, 'external': external_port + 2, 'protocol': 'UDP', 'name': 'zomboid-steam2'} + ] + if image == 'factorio': return [ + {'internal': 34197, 'external': external_port, 'protocol': 'UDP', 'name': 'factorio-game'}, + {'internal': 27015, 'external': external_port + 1, 'protocol': 'TCP', 'name': 'factorio-rcon'} + ] + if image == 'ark': return [{'internal': 7777, 'external': external_port, 'protocol': 'TCP', 'name': 'ark'}] + if image == 'valheim': return [{'internal': 2456, 'external': external_port, 'protocol': 'TCP', 'name': 'valheim'}] + return [{'internal': 27015, 'external': external_port, 'protocol': 'TCP', 'name': 'game'}] + elif server_type == 'vps': + if image == 'windows': return [{'internal': 3389, 'external': external_port, 'protocol': 'TCP', 'name': 'rdp'}] + return [{'internal': 22, 'external': external_port, 'protocol': 'TCP', 'name': 'ssh'}] + elif server_type == 'database': + if image == 'mysql': return [{'internal': 3306, 'external': external_port, 'protocol': 'TCP', 'name': 'mysql'}] + if image == 'postgresql': return [{'internal': 5432, 'external': external_port, 'protocol': 'TCP', 'name': 'postgresql'}] + if image == 'mongodb': return [{'internal': 27017, 'external': external_port, 'protocol': 'TCP', 'name': 'mongodb'}] + if image == 'redis': return [{'internal': 6379, 'external': external_port, 'protocol': 'TCP', 'name': 'redis'}] + return [{'internal': 3306, 'external': external_port, 'protocol': 'TCP', 'name': 'database'}] + return [] + +def get_storage_size(server_type: str, image: str) -> str: + if server_type == 'vps': + if image == 'windows': return '60Gi' + if image == 'alpine': return '10Gi' + return '20Gi' + elif server_type == 'game': + if image == 'minecraft': return '10Gi' + if image == 'fivem': return '15Gi' + if image == 'cs2': return '20Gi' + if image == 'rust': return '25Gi' + if image == 'hytale': return '10Gi' + if image == 'unturned': return '15Gi' + if image == 'zomboid': return '20Gi' + if image == 'factorio': return '10Gi' + if image == 'ark': return '30Gi' + if image == 'valheim': return '15Gi' + return '10Gi' + elif server_type == 'website': + if image == 'wordpress-sqlite': return '10Gi' + return '5Gi' + return '10Gi' + +def generate_sftp_credentials() -> Tuple[str, str, int]: + """Generate random credentials and port for SFTP""" + username = "ftp-user" + # Generate random 12-character alphanumeric password to avoid shell/format issues + import string + chars = string.ascii_letters + string.digits + password = ''.join(random.choice(chars) for _ in range(12)) + # Generate random node port for SFTP (in standard range) + sftp_port = random.randint(30000, 32768) + return username, password, sftp_port + +def get_sftp_sidecar_container(server_id: str, username: str, password: str, mount_path: str) -> client.V1Container: + """Create SFTP sidecar container specification using Alpine and non-root UID""" + # atmoz/sftp format: user:pass:uid:gid:dir + # Using UID 1000 as it is the standard non-root user in most containers + # and allows SSH login (UID 0 is often blocked by sshd) + sftp_config = f"{username}:{password}:1000:1000:data" + + return client.V1Container( + name="sftp", + image="atmoz/sftp:alpine", + ports=[client.V1ContainerPort(container_port=22)], + env=[ + client.V1EnvVar(name='SFTP_USERS', value=sftp_config) + ], + volume_mounts=[ + client.V1VolumeMount( + name='data', + mount_path=f'/home/{username}/data' + ) + ], + security_context=client.V1SecurityContext( + capabilities=client.V1Capabilities( + add=["SYS_CHROOT", "CHOWN", "SETGID", "SETUID"] + ), + run_as_user=0, # Entrypoint must run as root to setup chroot + privileged=False + ), + resources=client.V1ResourceRequirements( + requests={'cpu': '10m', 'memory': '32Mi'}, + limits={'cpu': '100m', 'memory': '128Mi'} + ) + ) + +def create_database_server(config: Dict, user_id: str, server_id: str, namespace: str) -> Dict: + """Create a Database Server with FIXED port handling""" + storage_size = convert_storage_to_k8s_format(get_storage_size('database', config['image'])) + pvc_name = f"{server_id}-data" + if not k8s_manager.create_pvc(namespace, pvc_name, storage_size): + raise Exception("Failed to create PVC") + + image_map = { + 'mysql': 'mysql:8.0', + 'postgresql': 'postgres:15', + 'mongodb': 'mongo:7.0', + 'redis': 'redis:7.2' + } + + container_image = image_map.get(config['image'], image_map['mysql']) + ports = config.get('ports') or get_default_ports('database', config['image']) + + # Database-specific environment variables + env_vars = [] + if config['image'] == 'mysql': + env_vars = [ + client.V1EnvVar(name='MYSQL_ROOT_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')), + client.V1EnvVar(name='MYSQL_DATABASE', value=config.get('databaseConfig', {}).get('databaseName', 'anchorhost_db')), + client.V1EnvVar(name='MYSQL_USER', value=config.get('databaseConfig', {}).get('username', 'admin')), + client.V1EnvVar(name='MYSQL_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')) + ] + elif config['image'] == 'postgresql': + env_vars = [ + client.V1EnvVar(name='POSTGRES_DB', value=config.get('databaseConfig', {}).get('databaseName', 'anchorhost_db')), + client.V1EnvVar(name='POSTGRES_USER', value=config.get('databaseConfig', {}).get('username', 'admin')), + client.V1EnvVar(name='POSTGRES_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')) + ] + elif config['image'] == 'mongodb': + env_vars = [ + client.V1EnvVar(name='MONGO_INITDB_ROOT_USERNAME', value=config.get('databaseConfig', {}).get('username', 'admin')), + client.V1EnvVar(name='MONGO_INITDB_ROOT_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')) + ] + elif config['image'] == 'redis': + env_vars = [ + client.V1EnvVar(name='REDIS_PASSWORD', value=config.get('databaseConfig', {}).get('password', 'anchorhost123')) + ] + + # Calculate resources + cpu_cores = int(config['cpu'].split()[0]) + ram_gb = int(config['ram'].replace('GB', '')) + memory_limit = f"{ram_gb}Gi" + memory_request = f"{ram_gb * 0.5}Gi" + + # Generate SFTP credentials and port + sftp_user, sftp_pass, sftp_ext_port = generate_sftp_credentials() + sftp_container = get_sftp_sidecar_container(server_id, sftp_user, sftp_pass, get_db_mount_path(config['image'])) + + deployment = client.V1Deployment( + metadata=client.V1ObjectMeta(name=server_id, namespace=namespace), + spec=client.V1DeploymentSpec( + replicas=1, + selector=client.V1LabelSelector(match_labels={'app': server_id}), + template=client.V1PodTemplateSpec( + metadata=client.V1ObjectMeta(labels={'app': server_id}), + spec=client.V1PodSpec( + containers=[ + client.V1Container( + name=server_id, + image=container_image, + ports=[client.V1ContainerPort(container_port=p['internal']) for p in ports], + env=env_vars, + volume_mounts=[client.V1VolumeMount(name='data', mount_path=get_db_mount_path(config['image']))], + resources=client.V1ResourceRequirements( + requests={ + 'cpu': f'{cpu_cores * 50}m', + 'memory': memory_request + }, + limits={ + 'cpu': f'{cpu_cores * 1000}m', + 'memory': memory_limit + } + ) + ), + sftp_container + ], + volumes=[client.V1Volume(name='data', persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(claim_name=pvc_name))] + ) + ) + ) + ) + + apps_v1.create_namespaced_deployment(namespace=namespace, body=deployment) + + # Create Service with FIXED port handling + if ports: + service_ports = [] + for port in ports: + external_port = port['external'] + service_ports.append( + client.V1ServicePort( + name=f"port-{port['internal']}-{port['protocol']}".lower(), + port=port['internal'], + target_port=port['internal'], + node_port=external_port, + protocol=port['protocol'] + ) + ) + + # Add SFTP port to service + service_ports.append( + client.V1ServicePort( + name="sftp", + port=22, + target_port=22, + node_port=sftp_ext_port, + protocol='TCP' + ) + ) + + service = client.V1Service( + metadata=client.V1ObjectMeta( + name=f"{server_id}-service", + namespace=namespace, + labels={'app': server_id} + ), + spec=client.V1ServiceSpec( + selector={'app': server_id}, + type='NodePort', + ports=service_ports + ) + ) + try: + created_service = v1.create_namespaced_service(namespace=namespace, body=service) + # Get the actual assigned node ports + actual_ports = [] + sftp_actual_port = sftp_ext_port + + for i, service_port in enumerate(created_service.spec.ports): + if service_port.name == "sftp": + sftp_actual_port = service_port.node_port + continue + + actual_ports.append({ + 'internal': service_port.port, + 'external': service_port.node_port, + 'protocol': service_port.protocol, + 'name': service_port.name + }) + logger.info(f"โœ… Created Service for database server with actual ports: {actual_ports}, SFTP: {sftp_actual_port}") + + # Update the ports with actual node_port values + ports.clear() + ports.extend(actual_ports) + sftp_ext_port = sftp_actual_port + + except Exception as e: + logger.error(f"โš  Failed to create service: {e}") + + server_data = { + 'id': server_id, + 'name': config['name'], + 'type': 'database', + 'image': config['image'], + 'version': config['version'], + 'status': 'Creating', + 'cpu': config['cpu'], + 'ram': config['ram'], + 'storage': config['storage'], + 'ip': NODE_IP, + 'ports': ports, + 'ownerId': user_id, + 'namespace': namespace, + 'k8s_name': server_id, + 'databaseConfig': config.get('databaseConfig', {}), + 'sftp_username': sftp_user, + 'sftp_password': sftp_pass, + 'sftp_port': sftp_ext_port + } + + return create_server_record(server_data) + +def get_db_mount_path(db_type: str) -> str: + """Get the mount path for different database types""" + mount_paths = { + 'mysql': '/var/lib/mysql', + 'postgresql': '/var/lib/postgresql/data', + 'mongodb': '/data/db', + 'redis': '/data' + } + return mount_paths.get(db_type, '/data') + +def create_vps_server(config: Dict, user_id: str, server_id: str, namespace: str, progress_callback=None) -> Dict: + """Create a VPS using KubeVirt - FIXED PORT HANDLING + + Args: + config: Server configuration + user_id: User ID + server_id: Server ID + namespace: Kubernetes namespace + progress_callback: Optional callback function(phase, progress, message) for progress updates + """ + user = get_user_by_id(user_id) + + ports = config.get('ports') or get_default_ports('vps', config['image']) + storage_size = convert_storage_to_k8s_format(config['storage']) + + # Image mapping + image_map = { + 'ubuntu': { + 'url': 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img', + 'user': 'ubuntu', + 'default_password': 'ubuntu' + }, + 'debian': { + 'url': 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', + 'user': 'debian', + 'default_password': 'debian' + }, + 'alpine': { + 'url': 'https://dl-cdn.alpinelinux.org/alpine/v3.18/releases/x86_64/alpine-standard-3.18.4-x86_64.iso', + 'user': 'root', + 'default_password': 'alpine' + }, + 'centos': { + 'url': 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', + 'user': 'centos', + 'default_password': 'centos' + }, + 'windows': { + 'url': '', + 'user': 'Administrator', + 'default_password': 'Password123' + } + } + + image_config = image_map.get(config['image'], image_map['ubuntu']) + + cpu_cores = int(config['cpu'].split()[0]) + ram_gb = int(config['ram'].replace('GB', '')) + memory_limit = f"{ram_gb}Gi" + memory_request = f"{ram_gb * 0.5}Gi" + + if config['image'] == 'windows': + return create_windows_vps(config, user_id, server_id, namespace, image_config, ports, storage_size, cpu_cores, memory_limit, memory_request) + + # Create DataVolume + data_volume_name = f"{server_id}-rootdisk" + + if progress_callback: + progress_callback('Initializing', 0, 'Preparing VPS creation...') + + data_volume_body = { + 'apiVersion': 'cdi.kubevirt.io/v1beta1', + 'kind': 'DataVolume', + 'metadata': { + 'name': data_volume_name, + 'namespace': namespace, + 'labels': { + 'app': server_id, + 'server-type': 'vps', + 'managed-by': 'anchorhost' + } + }, + 'spec': { + 'source': { + 'http': { + 'url': image_config['url'] + } + }, + 'pvc': { + 'accessModes': ['ReadWriteOnce'], + 'resources': { + 'requests': { + 'storage': storage_size + } + }, + 'storageClassName': 'longhorn' + } + } + } + + dv_created = False + try: + custom_objects_api.create_namespaced_custom_object( + group="cdi.kubevirt.io", + version="v1beta1", + namespace=namespace, + plural="datavolumes", + body=data_volume_body + ) + logger.info(f"โœ“ Created DataVolume {data_volume_name}") + dv_created = True + except Exception as e: + logger.error(f"โœ— Failed to create DataVolume: {e}") + if not k8s_manager.create_pvc(namespace, data_volume_name, storage_size): + raise Exception(f"Failed to create storage: {e}") + + # Cloud-init configuration + cloud_init_user_data = f"""#cloud-config +user: {image_config['user']} +password: {image_config['default_password']} +chpasswd: + expire: false + list: | + {image_config['user']}:{image_config['default_password']} +ssh_pwauth: true +disable_root: false +package_update: true +package_upgrade: true +packages: + - qemu-guest-agent + - openssh-server + - curl + - wget + - sudo +runcmd: + - systemctl enable qemu-guest-agent + - systemctl start qemu-guest-agent + - systemctl enable ssh + - systemctl start ssh + - [sh, -c, 'sed -i "s/#PermitRootLogin.*/PermitRootLogin yes/" /etc/ssh/sshd_config'] + - [sh, -c, 'sed -i "s/PasswordAuthentication no/PasswordAuthentication yes/" /etc/ssh/sshd_config'] + - systemctl restart ssh + - [sh, -c, 'growpart /dev/vda 1 || true'] + - [sh, -c, 'resize2fs /dev/vda1 || true'] +final_message: "AnchorHost VPS ready! User: {image_config['user']}, Password: {image_config['default_password']}" +""" + + # Cloud-init network configuration (Netplan) + # We use enp1s0 as it is the default for KubeVirt with q35 machine type + cloud_init_network_data = """network: + version: 2 + ethernets: + enp1s0: + dhcp4: true +""" + + # Create VirtualMachine + vm_body = { + 'apiVersion': 'kubevirt.io/v1', + 'kind': 'VirtualMachine', + 'metadata': { + 'name': server_id, + 'namespace': namespace, + 'labels': { + 'app': server_id, + 'server-type': 'vps', + 'managed-by': 'anchorhost' + } + }, + 'spec': { + 'runStrategy': 'Always', + 'template': { + 'metadata': { + 'labels': { + 'kubevirt.io/domain': server_id, + 'app': server_id + } + }, + 'spec': { + 'domain': { + 'cpu': { + 'cores': cpu_cores, + 'sockets': 1, + 'threads': 1 + }, + 'resources': { + 'requests': { + 'memory': memory_request, + 'cpu': f'{cpu_cores * 50}m' + }, + 'limits': { + 'memory': memory_limit, + 'cpu': f'{cpu_cores * 1000}m' + } + }, + 'devices': { + 'disks': [ + { + 'name': 'rootdisk', + 'bootOrder': 1, + 'disk': { + 'bus': 'virtio' + } + }, + { + 'name': 'cloudinitdisk', + 'disk': { + 'bus': 'virtio' + } + } + ], + 'interfaces': [ + { + 'name': 'podnet', + 'masquerade': {}, + 'model': 'virtio' + } + ], + 'rng': {} + }, + 'machine': { + 'type': 'q35' + } + }, + 'volumes': [ + { + 'name': 'rootdisk', + **({'dataVolume': {'name': data_volume_name}} if dv_created else {'persistentVolumeClaim': {'claimName': data_volume_name}}) + }, + { + 'name': 'cloudinitdisk', + 'cloudInitNoCloud': { + 'userData': cloud_init_user_data, + 'networkData': cloud_init_network_data + } + } + ], + 'networks': [ + { + 'name': 'podnet', + 'pod': {} + } + ], + 'terminationGracePeriodSeconds': 180, + 'hostname': server_id + } + } + } + } + + # Wait for DataVolume if created (non-blocking with progress updates) + if dv_created and k8s_manager.connected: + logger.info("โณ Waiting for DataVolume to be ready...") + if progress_callback: + progress_callback('Pending', 0, 'Creating DataVolume...') + + max_wait = 600 + wait_interval = 5 # Check more frequently for better progress updates + elapsed = 0 + + while elapsed < max_wait: + try: + dv = custom_objects_api.get_namespaced_custom_object( + group="cdi.kubevirt.io", + version="v1beta1", + namespace=namespace, + plural="datavolumes", + name=data_volume_name + ) + + status = dv.get('status', {}) + phase = status.get('phase', '') + progress = status.get('progress', '') + + # Extract progress percentage if available + progress_percent = 0 + if progress: + # Progress format is usually "XX.X%" or "XX.XX%" + try: + progress_percent = float(progress.replace('%', '')) + except (ValueError, AttributeError): + pass + + # Map phase to progress percentage if not available + if progress_percent == 0: + phase_progress_map = { + 'Pending': 5, + 'PVCBound': 10, + 'ImportScheduled': 15, + 'ImportInProgress': 50, + 'Succeeded': 100 + } + progress_percent = phase_progress_map.get(phase, 0) + + # Create progress message + phase_messages = { + 'Pending': 'Waiting for storage to be allocated...', + 'PVCBound': 'Storage allocated, preparing import...', + 'ImportScheduled': 'Import scheduled, starting download...', + 'ImportInProgress': f'Downloading image... ({progress if progress else "in progress"})', + 'Succeeded': 'Image download completed!', + 'Failed': 'Image download failed' + } + message = phase_messages.get(phase, f'Processing... ({phase})') + + # Send progress update + if progress_callback: + progress_callback(phase, progress_percent, message) + + if phase == 'Succeeded': + logger.info("โœ… DataVolume ready!") + break + elif phase == 'Failed': + error_message = status.get('conditions', [{}])[0].get('message', 'Unknown error') if status.get('conditions') else 'Unknown error' + logger.error(f"โŒ DataVolume failed: {error_message}") + if progress_callback: + progress_callback('Failed', 0, f'Image download failed: {error_message}') + break + + except Exception as e: + logger.error(f"โš  Error checking DataVolume: {e}") + if progress_callback: + progress_callback('Error', 0, f'Error checking progress: {str(e)}') + + time.sleep(wait_interval) + elapsed += wait_interval + + if elapsed >= max_wait: + logger.warning(f"โš  DataVolume wait timeout after {max_wait}s") + if progress_callback: + progress_callback('Timeout', 0, 'Image download timed out, proceeding anyway...') + + # Create VM + if progress_callback: + progress_callback('CreatingVM', 90, 'Creating virtual machine...') + + try: + custom_objects_api.create_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + body=vm_body + ) + logger.info(f"โœ… Created VirtualMachine {server_id}") + if progress_callback: + progress_callback('Completed', 100, 'VPS created successfully!') + except Exception as e: + logger.error(f"โŒ Failed to create VM: {e}") + if progress_callback: + progress_callback('Error', 0, f'Failed to create VM: {str(e)}') + raise Exception(f"Failed to create VM: {str(e)}") + + # Create Service with FIXED port handling + if ports: + service_ports = [] + for port in ports: + # Use the actual external port from configuration + external_port = port['external'] + service_ports.append( + client.V1ServicePort( + name=f"port-{port['internal']}-{port['protocol']}".lower(), + port=port['internal'], + target_port=port['internal'], + node_port=external_port, # Use the actual external port + protocol=port['protocol'] + ) + ) + + service = client.V1Service( + metadata=client.V1ObjectMeta( + name=f"{server_id}-service", + namespace=namespace, + labels={'app': server_id} + ), + spec=client.V1ServiceSpec( + selector={'kubevirt.io/domain': server_id}, + type='NodePort', + ports=service_ports + ) + ) + try: + created_service = v1.create_namespaced_service(namespace=namespace, body=service) + # Get the actual assigned node ports from the created service + actual_ports = [] + for i, service_port in enumerate(created_service.spec.ports): + actual_ports.append({ + 'internal': service_port.port, + 'external': service_port.node_port, + 'protocol': service_port.protocol, + 'name': service_port.name + }) + logger.info(f"โœ… Created Service with actual ports: {actual_ports}") + + # Update the ports with actual node_port values + ports.clear() + ports.extend(actual_ports) + + except Exception as e: + logger.error(f"โš  Failed to create service: {e}") + + # Background status sync + if k8s_manager.connected: + threading.Thread( + target=wait_and_sync_vm_status, + args=(server_id, user_id, namespace), + daemon=True + ).start() + + # Create server record + server_data = { + 'id': server_id, + 'name': config['name'], + 'type': 'vps', + 'image': config['image'], + 'version': config['version'], + 'status': 'Creating', + 'cpu': config['cpu'], + 'ram': config['ram'], + 'storage': config['storage'], + 'ip': NODE_IP, + 'ports': ports, + 'ownerId': user_id, + 'namespace': namespace, + 'k8s_name': server_id + } + + return create_server_record(server_data) + +def wait_and_sync_vm_status(server_id: str, user_id: str, namespace: str): + """Background VM status sync""" + try: + if k8s_manager.wait_for_vm_ready(namespace, server_id): + update_server_status(server_id, 'Running', 'VM is ready') + sync_server_ports_from_k8s(server_id, user_id) + else: + update_server_status(server_id, 'Error', 'VM failed to start') + except Exception as e: + update_server_status(server_id, 'Error', f'Error: {str(e)}') + +def wait_and_sync_pod_status(server_id: str, user_id: str, namespace: str): + """Background pod status sync - wait for pods to be ready after restart""" + try: + if not k8s_manager.connected or v1 is None: + # Fallback to sync if k8s not connected + sync_server_status_from_k8s(server_id, user_id) + return + + timeout = 600 # 10 minutes timeout + start_time = time.time() + + logger.info(f"โณ Waiting for pods to be ready for server {server_id} (timeout: {timeout}s)") + + while time.time() - start_time < timeout: + try: + pods = k8s_manager.get_pods_for_server(namespace, server_id) + if pods: + # Check if pods are running and containers are ready + all_running = True + latest_message = "Pods are running" + for pod_name in pods: + try: + pod = v1.read_namespaced_pod(name=pod_name, namespace=namespace) + if pod.status.phase != 'Running': + all_running = False + latest_message = f"Pod status: {pod.status.phase}" + break + + # Check container statuses for ContainerCreating or non-ready states + if pod.status.container_statuses: + for container_status in pod.status.container_statuses: + if not container_status.ready: + all_running = False + if container_status.state.waiting: + latest_message = f"Container: {container_status.state.waiting.reason}" + else: + latest_message = "Container is not ready" + break + if not all_running: + break + except Exception as e: + logger.warning(f"Error reading pod {pod_name}: {e}") + all_running = False + break + + if all_running: + logger.info(f"โœ… Pods for server {server_id} are ready and running") + update_server_status(server_id, 'Running', 'Pods are running') + sync_server_ports_from_k8s(server_id, user_id) + return + else: + # Update status with intermediate message (e.g., ContainerCreating) + update_server_status(server_id, 'Creating', latest_message) + else: + update_server_status(server_id, 'Creating', 'Waiting for pod to start') + + # Log progress every 30 seconds + elapsed = int(time.time() - start_time) + if elapsed % 30 == 0 and elapsed > 0: + logger.info(f"โณ Waiting for pods for server {server_id}... (elapsed: {elapsed}s)") + + time.sleep(5) + + except Exception as e: + logger.error(f"Error checking pod status: {e}") + time.sleep(10) + + logger.warning(f"โš  Pods for server {server_id} not ready within {timeout} seconds") + # Don't set to Error, let periodic sync handle it - pods might still be starting + sync_server_status_from_k8s(server_id, user_id) + + except Exception as e: + logger.error(f"Error in wait_and_sync_pod_status: {e}") + sync_server_status_from_k8s(server_id, user_id) + +def create_windows_vps(config: Dict, user_id: str, server_id: str, namespace: str, image_config: Dict, + ports: List, storage_size: str, cpu_cores: int, memory_limit: str, memory_request: str) -> Dict: + """Create Windows VPS""" + pvc_name = f"{server_id}-rootdisk" + if not k8s_manager.create_pvc(namespace, pvc_name, storage_size): + raise Exception("Failed to create PVC") + + vm_body = { + 'apiVersion': 'kubevirt.io/v1', + 'kind': 'VirtualMachine', + 'metadata': { + 'name': server_id, + 'namespace': namespace + }, + 'spec': { + 'runStrategy': 'Always', + 'template': { + 'spec': { + 'domain': { + 'cpu': {'cores': cpu_cores}, + 'resources': { + 'requests': { + 'memory': memory_request, + 'cpu': f'{cpu_cores * 50}m' + }, + 'limits': { + 'memory': memory_limit, + 'cpu': f'{cpu_cores * 1000}m' + } + }, + 'devices': { + 'disks': [{'name': 'rootdisk', 'disk': {'bus': 'sata'}}], + 'interfaces': [{'name': 'default', 'model': 'e1000'}] + } + }, + 'volumes': [{'name': 'rootdisk', 'persistentVolumeClaim': {'claimName': pvc_name}}], + 'networks': [{'name': 'default', 'pod': {}}] + } + } + } + } + + custom_objects_api.create_namespaced_custom_object( + group="kubevirt.io", + version="v1", + namespace=namespace, + plural="virtualmachines", + body=vm_body + ) + + # Create Service with FIXED port handling + if ports: + service_ports = [] + for port in ports: + external_port = port['external'] + service_ports.append( + client.V1ServicePort( + name=f"port-{port['internal']}-{port['protocol']}".lower(), + port=port['internal'], + target_port=port['internal'], + node_port=external_port, + protocol=port['protocol'] + ) + ) + + service = client.V1Service( + metadata=client.V1ObjectMeta( + name=f"{server_id}-service", + namespace=namespace, + labels={'app': server_id} + ), + spec=client.V1ServiceSpec( + selector={'kubevirt.io/domain': server_id}, + type='NodePort', + ports=service_ports + ) + ) + try: + created_service = v1.create_namespaced_service(namespace=namespace, body=service) + # Get the actual assigned node ports + actual_ports = [] + for i, service_port in enumerate(created_service.spec.ports): + actual_ports.append({ + 'internal': service_port.port, + 'external': service_port.node_port, + 'protocol': service_port.protocol, + 'name': service_port.name + }) + logger.info(f"โœ… Created Service for Windows VPS with actual ports: {actual_ports}") + + # Update the ports with actual node_port values + ports.clear() + ports.extend(actual_ports) + + except Exception as e: + logger.error(f"โš  Failed to create service: {e}") + + server_data = { + 'id': server_id, + 'name': config['name'], + 'type': 'vps', + 'image': config['image'], + 'version': config['version'], + 'status': 'Creating', + 'cpu': config['cpu'], + 'ram': config['ram'], + 'storage': config['storage'], + 'ip': NODE_IP, + 'ports': ports, + 'ownerId': user_id, + 'namespace': namespace, + 'k8s_name': server_id + } + + return create_server_record(server_data) + +def create_website_server(config: Dict, user_id: str, server_id: str, namespace: str) -> Dict: + """Create Website Server with FIXED port handling""" + storage_size = convert_storage_to_k8s_format(get_storage_size('website', config['image'])) + pvc_name = f"{server_id}-data" + if not k8s_manager.create_pvc(namespace, pvc_name, storage_size): + raise Exception("Failed to create PVC") + + image_map = { + 'nginx': 'nginx:stable-alpine', + 'wordpress': 'wordpress:php8.3-apache', + 'wordpress-sqlite': 'ghcr.io/yunussandikci/wordpress-sqlite', + 'node': 'node:22-alpine' + } + + selected_image_key = config['image'] + container_image_base = image_map.get(selected_image_key, image_map['nginx']) + + # Append version if specified + if config.get('version') and selected_image_key == 'wordpress-sqlite': + container_image = f"{container_image_base}:{config['version']}" + else: + container_image = container_image_base + + ports = config.get('ports') or get_default_ports('website', selected_image_key) + + cpu_cores = int(config['cpu'].split()[0]) + ram_gb = int(config['ram'].replace('GB', '')) + memory_limit = f"{ram_gb}Gi" + memory_request = f"{ram_gb * 0.5}Gi" + + mount_path = '/usr/share/nginx/html' if selected_image_key == 'nginx' else '/var/www/html' + + # Generate SFTP credentials and port + sftp_user, sftp_pass, sftp_ext_port = generate_sftp_credentials() + sftp_container = get_sftp_sidecar_container(server_id, sftp_user, sftp_pass, mount_path) + + containers = [ + client.V1Container( + name=server_id, + image=container_image, + ports=[client.V1ContainerPort(container_port=p['internal']) for p in ports], + volume_mounts=[client.V1VolumeMount( + name='data', + mount_path=mount_path + )], + resources=client.V1ResourceRequirements( + requests={'cpu': f'{cpu_cores * 50}m', 'memory': memory_request}, + limits={'cpu': f'{cpu_cores * 1000}m', 'memory': memory_limit} + ) + ), + sftp_container + ] + + deployment = client.V1Deployment( + metadata=client.V1ObjectMeta(name=server_id, namespace=namespace), + spec=client.V1DeploymentSpec( + replicas=1, + selector=client.V1LabelSelector(match_labels={'app': server_id}), + template=client.V1PodTemplateSpec( + metadata=client.V1ObjectMeta(labels={'app': server_id}), + spec=client.V1PodSpec( + containers=containers, + volumes=[client.V1Volume(name='data', persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(claim_name=pvc_name))] + ) + ) + ) + ) + + apps_v1.create_namespaced_deployment(namespace=namespace, body=deployment) + + # Create Service with FIXED port handling + if ports: + service_ports = [] + for port in ports: + external_port = port['external'] + service_ports.append( + client.V1ServicePort( + name=f"port-{port['internal']}-{port['protocol']}".lower(), + port=port['internal'], + target_port=port['internal'], + node_port=external_port, + protocol=port['protocol'] + ) + ) + + # Add SFTP port to service + service_ports.append( + client.V1ServicePort( + name="sftp", + port=22, + target_port=22, + node_port=sftp_ext_port, + protocol='TCP' + ) + ) + + service = client.V1Service( + metadata=client.V1ObjectMeta( + name=f"{server_id}-service", + namespace=namespace, + labels={'app': server_id} + ), + spec=client.V1ServiceSpec( + selector={'app': server_id}, + type='NodePort', + ports=service_ports + ) + ) + try: + created_service = v1.create_namespaced_service(namespace=namespace, body=service) + # Get the actual assigned node ports + actual_ports = [] + sftp_actual_port = sftp_ext_port + + for i, service_port in enumerate(created_service.spec.ports): + if service_port.name == "sftp": + sftp_actual_port = service_port.node_port + continue + + actual_ports.append({ + 'internal': service_port.port, + 'external': service_port.node_port, + 'protocol': service_port.protocol, + 'name': service_port.name + }) + logger.info(f"โœ… Created Service for website with actual ports: {actual_ports}, SFTP: {sftp_actual_port}") + + # Update the ports with actual node_port values + ports.clear() + ports.extend(actual_ports) + sftp_ext_port = sftp_actual_port + + except Exception as e: + logger.error(f"โš  Failed to create service: {e}") + + server_data = { + 'id': server_id, + 'name': config['name'], + 'type': 'website', + 'image': config['image'], + 'version': config['version'], + 'status': 'Creating', + 'cpu': config['cpu'], + 'ram': config['ram'], + 'storage': config['storage'], + 'ip': NODE_IP, + 'ports': ports, + 'ownerId': user_id, + 'namespace': namespace, + 'k8s_name': server_id, + 'sftp_username': sftp_user, + 'sftp_password': sftp_pass, + 'sftp_port': sftp_ext_port + } + + return create_server_record(server_data) + +def get_minecraft_image(version: str, java_version: str = None) -> str: + """Get the correct itzg/minecraft-server image tag based on Minecraft version""" + # If Java version is explicitly provided, use it + if java_version: + # Extract version number if string like "Java 17" is passed + import re + match = re.search(r'(\d+)', str(java_version)) + if match: + ver = int(match.group(1)) + if ver >= 25: + return 'itzg/minecraft-server:java25' + elif ver >= 21: + return 'itzg/minecraft-server:java21' + elif ver >= 17: + return 'itzg/minecraft-server:java17' + elif ver == 16: + return 'itzg/minecraft-server:java17' # Java 17 is backwards compatible with 16 + elif ver >= 11: + return 'itzg/minecraft-server:java11' + elif ver == 8: + return 'itzg/minecraft-server:java8' + + if not version: + return 'itzg/minecraft-server:latest' + + # Mapping based on Minecraft version requirements + # Java 25: 26.x+ + # Java 21: 1.20.5 - 1.21.x + # Java 17: 1.18 - 1.20.4 + # Java 11: 1.17 (Java 16 specifically, but java17/11 images are common) + # Java 8: <= 1.16.5 + + try: + # Extract major and minor version (e.g., "1.20.1" -> 1, 20) + parts = version.split('.') + if len(parts) >= 1: + major = int(parts[0]) + minor = int(parts[1]) if len(parts) >= 2 else 0 + patch = int(parts[2]) if len(parts) >= 3 else 0 + + # Handling for newer versioning (26.x) + if major >= 26: + return 'itzg/minecraft-server:java25' + + # Legacy versioning (1.x) + if major == 1: + if minor >= 21 or (minor == 20 and patch >= 5): + return 'itzg/minecraft-server:java21' + elif minor >= 18: + return 'itzg/minecraft-server:java17' + elif minor == 17: + return 'itzg/minecraft-server:java17' # Use Java 17 for 1.17 + elif minor >= 12: + return 'itzg/minecraft-server:java8' + else: + return 'itzg/minecraft-server:java8' + except Exception as e: + logger.warning(f"Error parsing Minecraft version {version}: {e}") + pass + + return 'itzg/minecraft-server:latest' + +def create_game_server(config: Dict, user_id: str, server_id: str, namespace: str) -> Dict: + """Create Game Server with FIXED port handling""" + storage_size = convert_storage_to_k8s_format(get_storage_size('game', config['image'])) + pvc_name = f"{server_id}-data" + if not k8s_manager.create_pvc(namespace, pvc_name, storage_size): + raise Exception("Failed to create PVC") + + java_version = config.get('java_version') + loader = config.get('loader') + + # Minecraft specific image handling for Java versions + if config['image'] == 'minecraft': + container_image = get_minecraft_image(config.get('version', ''), java_version) + else: + image_map = { + 'fivem': 'fivem/fivem:latest', + 'cs2': 'cm2network/cs2:latest', + 'rust': 'pfeiffermax/rust-game-server:latest', + 'hytale': 'deinfreu/hytale-server:latest', + 'unturned': 'imperialplugins/unturned:latest', + 'zomboid': 'danixu86/project-zomboid-dedicated-server:latest', + 'factorio': 'factoriotools/factorio:latest', + 'ark': 'cm2network/arkserver:latest', + 'valheim': 'lloesche/valheim-server:latest' + } + if config['image'] == 'zomboid' and config.get('version') == 'unstable': + container_image = 'danixu86/project-zomboid-dedicated-server:unstable' + elif config['image'] == 'factorio' and config.get('version') == 'stable': + container_image = 'factoriotools/factorio:stable' + else: + container_image = image_map.get(config['image'], 'itzg/minecraft-server:latest') + ports = config.get('ports') or get_default_ports('game', config['image']) + + # Game-specific environment variables + env_vars = [] + if config['image'] == 'minecraft': + # Use the allocated RAM for Minecraft JVM (e.g. "4G") + memory_val = config['ram'].replace('GB', 'G') + + # Determine server type (loader) + server_type_env = 'PAPER' + if loader: + server_type_env = loader.upper() + + version_val = config.get('version', '1.20.1') + if server_type_env == 'PAPER' and version_val == '26.1': + version_val = '26.1.2' + + env_vars = [ + client.V1EnvVar(name='EULA', value='TRUE'), + client.V1EnvVar(name='TYPE', value=server_type_env), + client.V1EnvVar(name='VERSION', value=version_val), + client.V1EnvVar(name='MEMORY', value=memory_val) + ] + elif config['image'] == 'fivem': + env_vars = [ + client.V1EnvVar(name='FIVEM_LICENSE_KEY', value='your-license-key-here') + ] + elif config['image'] == 'rust': + # pfeiffermax/rust-game-server args: +server.ip 0.0.0.0 +server.port 28015 +rcon.ip 0.0.0.0 +rcon.port 28016 + # These are usually passed as command args, but some images use env + pass + elif config['image'] == 'hytale': + env_vars = [ + client.V1EnvVar(name='SERVER_IP', value='0.0.0.0'), + client.V1EnvVar(name='SERVER_PORT', value='5520'), + client.V1EnvVar(name='PROD', value='FALSE'), + client.V1EnvVar(name='DEBUG', value='FALSE'), + client.V1EnvVar(name='TZ', value='Europe/Amsterdam') + ] + elif config['image'] == 'unturned': + env_vars = [ + client.V1EnvVar(name='SERVER_TYPE', value='rm4') + ] + elif config['image'] == 'zomboid': + # Generate random admin password for Zomboid + admin_pass = str(uuid.uuid4())[:12] + env_vars = [ + client.V1EnvVar(name='ADMINUSERNAME', value='admin'), + client.V1EnvVar(name='ADMINPASSWORD', value=admin_pass), + client.V1EnvVar(name='PORT', value='16261'), + client.V1EnvVar(name='MEMORY', value=config['ram'].replace('GB', 'g')) + ] + if config.get('version') == 'unstable': + env_vars.append(client.V1EnvVar(name='STEAMAPPBRANCH', value='unstable')) + elif config['image'] == 'factorio': + env_vars = [ + client.V1EnvVar(name='LOAD_LATEST_SAVE', value='true'), + client.V1EnvVar(name='GENERATE_NEW_SAVE', value='true') + ] + + cpu_cores = int(config['cpu'].split()[0]) + ram_gb = int(config['ram'].replace('GB', '')) + memory_limit = f"{ram_gb}Gi" + memory_request = f"{ram_gb * 0.5}Gi" + + # Custom mount paths and arguments + container_args = [] + mount_path = '/data' + + if config['image'] == 'rust': + container_args = ["+server.ip", "0.0.0.0", "+server.port", "28015", "+rcon.ip", "0.0.0.0", "+rcon.port", "28016"] + elif config['image'] == 'unturned': + mount_path = '/home/steam/Unturned' + container_args = ["-SkipAssets"] + elif config['image'] == 'factorio': + mount_path = '/factorio' + elif config['image'] == 'hytale': + mount_path = '/home/container' + elif config['image'] == 'zomboid': + mount_path = '/home/steam/Zomboid' + + # Generate SFTP credentials and port + sftp_user, sftp_pass, sftp_ext_port = generate_sftp_credentials() + sftp_container = get_sftp_sidecar_container(server_id, sftp_user, sftp_pass, mount_path) + + deployment = client.V1Deployment( + metadata=client.V1ObjectMeta(name=server_id, namespace=namespace), + spec=client.V1DeploymentSpec( + replicas=1, + selector=client.V1LabelSelector(match_labels={'app': server_id}), + template=client.V1PodTemplateSpec( + metadata=client.V1ObjectMeta(labels={'app': server_id}), + spec=client.V1PodSpec( + containers=[ + client.V1Container( + name=server_id, + image=container_image, + args=container_args if container_args else None, + ports=[client.V1ContainerPort(container_port=p['internal']) for p in ports], + env=env_vars, + volume_mounts=[client.V1VolumeMount(name='data', mount_path=mount_path)], + resources=client.V1ResourceRequirements( + requests={ + 'cpu': f'{cpu_cores * 50}m', + 'memory': memory_request + }, + limits={ + 'cpu': f'{cpu_cores * 1000}m', + 'memory': memory_limit + } + ) + ), + sftp_container + ], + volumes=[client.V1Volume(name='data', persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(claim_name=pvc_name))] + ) + ) + ) + ) + + apps_v1.create_namespaced_deployment(namespace=namespace, body=deployment) + + # Create Service with FIXED port handling + if ports: + service_ports = [] + for port in ports: + external_port = port['external'] + service_ports.append( + client.V1ServicePort( + name=f"port-{port['internal']}-{port['protocol']}".lower(), + port=port['internal'], + target_port=port['internal'], + node_port=external_port, + protocol=port['protocol'] + ) + ) + + # Add SFTP port to service + service_ports.append( + client.V1ServicePort( + name="sftp", + port=22, + target_port=22, + node_port=sftp_ext_port, + protocol='TCP' + ) + ) + + service = client.V1Service( + metadata=client.V1ObjectMeta( + name=f"{server_id}-service", + namespace=namespace, + labels={'app': server_id} + ), + spec=client.V1ServiceSpec( + selector={'app': server_id}, + type='NodePort', + ports=service_ports + ) + ) + try: + created_service = v1.create_namespaced_service(namespace=namespace, body=service) + # Get the actual assigned node ports + actual_ports = [] + sftp_actual_port = sftp_ext_port + + for i, service_port in enumerate(created_service.spec.ports): + if service_port.name == "sftp": + sftp_actual_port = service_port.node_port + continue + + actual_ports.append({ + 'internal': service_port.port, + 'external': service_port.node_port, + 'protocol': service_port.protocol, + 'name': service_port.name + }) + logger.info(f"โœ… Created Service for game server with actual ports: {actual_ports}, SFTP: {sftp_actual_port}") + + # Update the ports with actual node_port values + ports.clear() + ports.extend(actual_ports) + sftp_ext_port = sftp_actual_port + + except Exception as e: + logger.error(f"โš  Failed to create service: {e}") + + server_data = { + 'id': server_id, + 'name': config['name'], + 'type': 'game', + 'image': config['image'], + 'version': config['version'], + 'status': 'Creating', + 'cpu': config['cpu'], + 'ram': config['ram'], + 'storage': config['storage'], + 'ip': NODE_IP, + 'ports': ports, + 'ownerId': user_id, + 'namespace': namespace, + 'k8s_name': server_id, + 'java_version': java_version, + 'loader': loader, + 'sftp_username': sftp_user, + 'sftp_password': sftp_pass, + 'sftp_port': sftp_ext_port + } + + return create_server_record(server_data) + +def create_server(config: Dict, user_id: str, progress_callback=None) -> Dict: + """Create a server with capacity validation - UPDATED with billing date + + Args: + config: Server configuration + user_id: User ID + progress_callback: Optional callback function(phase, progress, message) for progress updates + """ + try: + user = get_user_by_id(user_id) + if not user: + raise Exception('User not found') + + # Check if this is a demo server + is_demo = config.get('is_demo', False) + + # Step 0: Validate server name + server_name = config.get('name', '').strip() + is_valid, error_msg = validate_server_name(server_name) + if not is_valid: + raise Exception(error_msg) + + # If demo server, validate eligibility and set lowest specs + if is_demo: + eligibility = check_demo_eligibility(user_id) + if not eligibility['eligible']: + raise Exception(eligibility.get('reason', 'Not eligible for demo server')) + + # Set lowest possible specs for demo + config['cpu'] = '2 vCPU' + config['ram'] = '3GB' + config['storage'] = '20GB' + + # Mark demo as used + mark_demo_used(user_id) + logger.info(f"๐ŸŽ Creating demo server for user {user_id}") + + # Step 1: Validate capacity before proceeding + logger.info(f"๐Ÿ” Validating capacity for server: {config['name']}") + + capacity_check = validate_server_capacity(config) + if not capacity_check['success']: + error_msg = f"Insufficient capacity: {capacity_check['message']}" + logger.error(f"โŒ {error_msg}") + raise Exception(error_msg) + + logger.info(f"โœ… Capacity validation passed: {capacity_check['message']}") + + # Step 2: Check credits (skip for demo servers) + if not is_demo: + server_cost = calculate_server_cost(config) + if user.get('credits', 0) < server_cost: + raise Exception(f'Insufficient credits. Need {server_cost}, have {user.get("credits", 0)}') + + new_credits = user.get('credits', 0) - server_cost + if not update_user_credits(user_id, new_credits): + raise Exception('Failed to deduct credits') + + add_credit_transaction(user_id, -server_cost, 'deduct', f'Server creation: {config["name"]}') + else: + # Demo servers are free + server_cost = 0 + logger.info(f"๐ŸŽ Demo server created - no credits charged") + + # Set demo expiration time if demo server + demo_expires_at = None + if is_demo: + # Use UTC timezone to avoid timezone issues + demo_expires_at = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M:%SZ') + + if not k8s_manager.connected: + server_id = f"server-{str(uuid.uuid4())[:8]}" + ports = get_default_ports(config['serverType'], config['image']) + + server_data = { + 'id': server_id, + 'name': config['name'], + 'type': config['serverType'], + 'image': config['image'], + 'version': config['version'], + 'status': 'Creating', + 'cpu': config['cpu'], + 'ram': config['ram'], + 'storage': config['storage'], + 'ip': NODE_IP, + 'ports': ports, + 'ownerId': user_id, + 'namespace': f"user-{user['username']}".lower(), + 'k8s_name': server_id, + 'is_demo': is_demo, + 'demo_expires_at': demo_expires_at, + 'java_version': config.get('java_version'), + 'loader': config.get('loader') + } + + # Add database config if present + if config.get('databaseConfig'): + server_data['databaseConfig'] = config['databaseConfig'] + + server = create_server_record(server_data) + + else: + namespace = f"user-{user['username']}".lower() + sanitized_name = config['name'].lower().replace(' ', '-').replace('_', '-')[:20] + server_id = f"{sanitized_name}-{str(uuid.uuid4())[:8]}" + + if not k8s_manager.create_namespace(namespace): + raise Exception(f"Failed to create Kubernetes namespace: {namespace}") + + server_type = config['serverType'] + + if server_type == 'vps': + server = create_vps_server(config, user_id, server_id, namespace, progress_callback) + elif server_type == 'game': + server = create_game_server(config, user_id, server_id, namespace) + elif server_type == 'website': + server = create_website_server(config, user_id, server_id, namespace) + elif server_type == 'database': + server = create_database_server(config, user_id, server_id, namespace) + else: + raise Exception(f'Unknown server type: {server_type}') + + # Update server with demo fields if it's a demo server + if is_demo: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE servers + SET is_demo = ?, demo_expires_at = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (True, demo_expires_at, server['id'])) + conn.commit() + + # Reload server to get updated fields + server = get_server_by_id(server['id']) + + # Zapisz datฤ™ utworzenia serwera dla celรณw rozliczeniowych (skip for demo servers) + if not is_demo: + save_server_creation_date(server['id']) + + return server + + except Exception as e: + logger.error(f"Error creating server: {e}") + raise Exception(f'Failed to create server: {str(e)}') + +def save_server_creation_date(server_id: str) -> bool: + """Zapisz datฤ™ utworzenia serwera dla celรณw rozliczeniowych""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + # Utwรณrz tabelฤ™ jeล›li nie istnieje + cursor.execute(''' + CREATE TABLE IF NOT EXISTS server_billing ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL UNIQUE, + creation_date DATE NOT NULL, + next_billing_date DATE NOT NULL, + last_billing_date DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE CASCADE + ) + ''') + + # Utwรณrz indeksy + cursor.execute('CREATE INDEX IF NOT EXISTS idx_server_billing_server_id ON server_billing(server_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_server_billing_next_billing_date ON server_billing(next_billing_date)') + + # Oblicz daty rozliczeniowe + today = datetime.now().date() + creation_date = today.isoformat() + next_billing_date = (today + timedelta(days=30)).isoformat() # 30 dni pรณลบniej + + # Wstaw rekord + billing_id = f"bill-{str(uuid.uuid4())[:8]}" + + cursor.execute(''' + INSERT INTO server_billing + (id, server_id, creation_date, next_billing_date) + VALUES (?, ?, ?, ?) + ON CONFLICT (server_id) DO UPDATE SET + creation_date = excluded.creation_date, + next_billing_date = excluded.next_billing_date, + updated_at = CURRENT_TIMESTAMP + ''', (billing_id, server_id, creation_date, next_billing_date)) + + conn.commit() + logger.info(f"โœ“ Saved billing date for server {server_id}: next billing on {next_billing_date}") + return True + + except Exception as e: + logger.error(f"Error saving server billing date: {e}") + return False + +def validate_server_name(server_name: str) -> Tuple[bool, str]: + """Validate server name - only letters and spaces allowed, no numbers or special symbols""" + if not server_name or not server_name.strip(): + return False, "Server name cannot be empty" + + # Check if name contains only letters and spaces + if not re.match(r'^[a-zA-Z\s]+$', server_name): + return False, "Server name can only contain letters and spaces. Numbers and special symbols are not allowed." + + # Check length + if len(server_name.strip()) < 1: + return False, "Server name must be at least 1 character long" + + if len(server_name) > 50: + return False, "Server name must be 50 characters or less" + + return True, "" diff --git a/server_records.py b/server_records.py new file mode 100644 index 0000000..f973003 --- /dev/null +++ b/server_records.py @@ -0,0 +1,207 @@ +"""Server records operations.""" + + +from database_service import database_connection, dict_from_row +import json +from runtime_config import ( + logger, +) +from typing import Dict, List, Optional, Tuple +from user_service import get_user_by_id +import uuid + +__all__ = ['create_server_record', 'delete_server_record', 'get_all_servers', 'get_server_access_and_namespace', 'get_server_by_id', 'get_user_servers', 'update_server_status'] + + + +def create_server_record(server_data: Dict) -> Optional[Dict]: + """Create server record""" + try: + with database_connection() as conn: + cursor = conn.cursor() + is_demo = server_data.get('is_demo', False) + demo_expires_at = server_data.get('demo_expires_at') + + cursor.execute(''' + INSERT INTO servers (id, name, type, image, version, status, cpu, ram, storage, ip, ports, owner_id, namespace, k8s_name, is_demo, demo_expires_at, java_version, loader, sftp_username, sftp_password, sftp_port) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + server_data['id'], + server_data['name'], + server_data['type'], + server_data['image'], + server_data['version'], + server_data.get('status', 'Creating'), + server_data['cpu'], + server_data['ram'], + server_data['storage'], + server_data['ip'], + json.dumps(server_data['ports']), + server_data['ownerId'], # This should match the key in server_data + server_data.get('namespace', f"user-{server_data['ownerId']}".lower()), + server_data.get('k8s_name', server_data['id']), + is_demo, + demo_expires_at, + server_data.get('java_version'), + server_data.get('loader'), + server_data.get('sftp_username'), + server_data.get('sftp_password'), + server_data.get('sftp_port') + )) + conn.commit() + + # Return the server with owner_id field + cursor.execute('SELECT * FROM servers WHERE id = ?', (server_data['id'],)) + server = dict_from_row(cursor.fetchone()) + if server and 'ports' in server: + server['ports'] = json.loads(server['ports']) + + return server + except Exception as e: + logger.error(f"Error creating server record: {e}") + return None + +def get_server_by_id(server_id: str) -> Optional[Dict]: + """Get server by ID""" + try: + # Ensure server_id is a string + server_id = str(server_id) + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM servers WHERE id = ?', (server_id,)) + server = dict_from_row(cursor.fetchone()) + if server and 'ports' in server: + server['ports'] = json.loads(server['ports']) + + # Add backward compatibility for ownerId field + if server and 'owner_id' in server: + server['ownerId'] = server['owner_id'] + + return server + except Exception as e: + return None + +def get_server_access_and_namespace(server_id: str, requester_id: str) -> Tuple[str, Dict]: + """ + Check if a user has access to a server and return the namespace and server record. + Access is granted if user is the owner OR an admin. + Returns (namespace, server_record) + """ + server = get_server_by_id(server_id) + if not server: + raise Exception("Server not found") + + requester = get_user_by_id(requester_id) + if not requester: + raise Exception("User not found") + + # Check access (ensure string comparison for IDs) + # Handle both 'owner_id' and 'ownerId' keys for compatibility + server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) + req_id = str(requester_id) + + is_owner = server_owner_id == req_id + is_admin = requester.get('role') == 'admin' + + if not is_owner and not is_admin: + logger.warning(f"Access denied for user {requester_id} to server {server_id}. Owner: {server_owner_id}, Role: {requester.get('role')}") + raise Exception("Access denied") + + # Get owner to determine namespace + if is_owner: + owner = requester + else: + owner = get_user_by_id(server_owner_id) + + if not owner: + raise Exception("Server owner not found") + + namespace = f"user-{owner['username']}".lower() + return namespace, server + +def get_user_servers(user_id: str) -> List[Dict]: + """Get all servers for a user""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM servers WHERE owner_id = ? ORDER BY created_at DESC', (user_id,)) + servers = [dict_from_row(row) for row in cursor.fetchall()] + + for server in servers: + if server and 'ports' in server: + server['ports'] = json.loads(server['ports']) + + # Get database configuration for database servers + if server and server['type'] == 'database': + cursor.execute('SELECT * FROM database_configs WHERE server_id = ?', (server['id'],)) + db_config = dict_from_row(cursor.fetchone()) + if db_config: + server['databaseConfig'] = db_config + + return servers + except Exception as e: + return [] + +def get_all_servers() -> List[Dict]: + """Get all servers (admin only)""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM servers ORDER BY created_at DESC') + servers = [dict_from_row(row) for row in cursor.fetchall()] + + for server in servers: + if server and 'ports' in server: + try: + if isinstance(server['ports'], str): + server['ports'] = json.loads(server['ports']) + except: + server['ports'] = [] + + # Get database configuration for database servers + if server and server['type'] == 'database': + cursor.execute('SELECT * FROM database_configs WHERE server_id = ?', (server['id'],)) + db_config = dict_from_row(cursor.fetchone()) + if db_config: + server['databaseConfig'] = db_config + + return servers + except Exception as e: + logger.error(f"Error getting all servers: {e}") + return [] + +def update_server_status(server_id: str, status: str, message: str = "") -> bool: + """Update server status""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + cursor.execute( + 'UPDATE servers SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (status, server_id) + ) + + status_id = f"status-{str(uuid.uuid4())[:8]}" + cursor.execute(''' + INSERT INTO server_status_history (id, server_id, status, message) + VALUES (?, ?, ?, ?) + ''', (status_id, server_id, status, message)) + + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + return False + +def delete_server_record(server_id: str) -> bool: + """Delete server record""" + try: + with database_connection() as conn: + cursor = conn.cursor() + # Delete database configuration first + cursor.execute('DELETE FROM database_configs WHERE server_id = ?', (server_id,)) + # Then delete server + cursor.execute('DELETE FROM servers WHERE id = ?', (server_id,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + return False diff --git a/showcase_service.py b/showcase_service.py new file mode 100644 index 0000000..27196c3 --- /dev/null +++ b/showcase_service.py @@ -0,0 +1,89 @@ +"""Homepage showcase configuration and URL validation.""" + + +from database_service import database_connection +import json +from runtime_config import ( + DATABASE_URL, + logger, +) +import urllib.parse + +__all__ = ["get_homepage_showcase_links", "save_homepage_showcase_links"] + +HOMEPAGE_SHOWCASE_KEY = "homepage_showcase" + + +def _default_homepage_showcase_links(): + return [ + {"label": "Site 1", "url": ""}, + {"label": "Site 2", "url": ""}, + {"label": "Site 3", "url": ""}, + ] + +def get_homepage_showcase_links(): + """Load 3 homepage showcase slots from site_settings.""" + default = _default_homepage_showcase_links() + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM site_settings WHERE key = ?", (HOMEPAGE_SHOWCASE_KEY,)) + row = cursor.fetchone() + if not row: + return default + raw = row['value'] if isinstance(row, dict) else row[0] + data = json.loads(raw) + if not isinstance(data, list): + return default + out = [] + for i in range(3): + if i < len(data) and isinstance(data[i], dict): + label = (data[i].get('label') or f'Site {i + 1}').strip()[:160] + url = (data[i].get('url') or '').strip()[:2048] + out.append({"label": label, "url": url}) + else: + out.append({"label": f'Site {i + 1}', "url": ""}) + return out + except Exception as e: + logger.error(f"get_homepage_showcase_links: {e}") + return default + +def _normalize_showcase_url(url: str) -> str: + u = (url or '').strip() + if not u: + return '' + if not u.startswith(('http://', 'https://')): + u = 'https://' + u + return u + +def _is_valid_http_url(u: str) -> bool: + if not u: + return True + try: + p = urllib.parse.urlparse(u) + return p.scheme in ('http', 'https') and bool(p.netloc) + except Exception: + return False + +def save_homepage_showcase_links(links: list) -> None: + payload = json.dumps(links, ensure_ascii=False) + with database_connection() as conn: + cursor = conn.cursor() + if 'postgres' in DATABASE_URL: + cursor.execute( + "INSERT INTO site_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP", + (HOMEPAGE_SHOWCASE_KEY, payload) + ) + else: + cursor.execute("SELECT 1 FROM site_settings WHERE key = ?", (HOMEPAGE_SHOWCASE_KEY,)) + if cursor.fetchone(): + cursor.execute( + "UPDATE site_settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", + (payload, HOMEPAGE_SHOWCASE_KEY), + ) + else: + cursor.execute( + "INSERT INTO site_settings (key, value) VALUES (?, ?)", + (HOMEPAGE_SHOWCASE_KEY, payload), + ) + conn.commit() diff --git a/ticket_service.py b/ticket_service.py new file mode 100644 index 0000000..6a508ff --- /dev/null +++ b/ticket_service.py @@ -0,0 +1,292 @@ +"""Support ticket persistence and message operations.""" + + +from database_service import database_connection, dict_from_row +from runtime_config import ( + logger, +) +from typing import Dict, List, Optional +import uuid + +__all__ = ['add_ticket_attachment', 'add_ticket_message', 'create_ticket', 'get_all_tickets', 'get_ticket', 'get_ticket_attachments', 'get_ticket_stats', 'get_user_tickets', 'update_ticket_status'] + + + +def create_ticket(user_id: str, subject: str, description: str, priority: str = 'medium', category: str = 'general') -> Optional[Dict]: + """Create a new support ticket""" + try: + ticket_id = f"TKT-{str(uuid.uuid4())[:8]}" + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO tickets (id, user_id, subject, description, priority, category) + VALUES (?, ?, ?, ?, ?, ?) + ''', (ticket_id, user_id, subject, description, priority, category)) + conn.commit() + + cursor.execute(''' + SELECT t.*, u.username as user_username + FROM tickets t + JOIN users u ON t.user_id = u.id + WHERE t.id = ? + ''', (ticket_id,)) + ticket = dict_from_row(cursor.fetchone()) + return ticket + except Exception as e: + logger.error(f"Ticket creation failed: {e}") + return None + +def get_ticket(ticket_id: str) -> Optional[Dict]: + """Get ticket by ID with user information""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT t.*, u.username as user_username + FROM tickets t + JOIN users u ON t.user_id = u.id + WHERE t.id = ? + ''', (ticket_id,)) + ticket = dict_from_row(cursor.fetchone()) + + if ticket: + # Get ticket messages + cursor.execute(''' + SELECT tm.*, u.username as user_username + FROM ticket_messages tm + JOIN users u ON tm.user_id = u.id + WHERE tm.ticket_id = ? + ORDER BY tm.created_at ASC + ''', (ticket_id,)) + messages = [dict_from_row(row) for row in cursor.fetchall()] + ticket['messages'] = messages + + # Get message count + cursor.execute('SELECT COUNT(*) as count FROM ticket_messages WHERE ticket_id = ?', (ticket_id,)) + ticket['message_count'] = cursor.fetchone()['count'] + + # Get last reply time + cursor.execute(''' + SELECT MAX(created_at) as last_reply + FROM ticket_messages + WHERE ticket_id = ? + ''', (ticket_id,)) + last_reply = cursor.fetchone()['last_reply'] + ticket['last_reply'] = last_reply + + return ticket + except Exception as e: + logger.error(f"Error getting ticket: {e}") + return None + +def get_user_tickets(user_id: str, include_closed: bool = False) -> List[Dict]: + """Get all tickets for a user""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + if include_closed: + cursor.execute(''' + SELECT t.*, u.username as user_username + FROM tickets t + JOIN users u ON t.user_id = u.id + WHERE t.user_id = ? + ORDER BY t.updated_at DESC + ''', (user_id,)) + else: + cursor.execute(''' + SELECT t.*, u.username as user_username + FROM tickets t + JOIN users u ON t.user_id = u.id + WHERE t.user_id = ? AND t.status != 'closed' + ORDER BY t.updated_at DESC + ''', (user_id,)) + + tickets = [dict_from_row(row) for row in cursor.fetchall()] + + # Add message counts and last reply + for ticket in tickets: + cursor.execute('SELECT COUNT(*) as count FROM ticket_messages WHERE ticket_id = ?', (ticket['id'],)) + ticket['message_count'] = cursor.fetchone()['count'] + + cursor.execute(''' + SELECT MAX(created_at) as last_reply + FROM ticket_messages + WHERE ticket_id = ? + ''', (ticket['id'],)) + last_reply = cursor.fetchone()['last_reply'] + ticket['last_reply'] = last_reply + + return tickets + except Exception as e: + logger.error(f"Error getting user tickets: {e}") + return [] + +def get_all_tickets(status_filter: str = None, priority_filter: str = None) -> List[Dict]: + """Get all tickets (admin only)""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + query = ''' + SELECT t.*, u.username as user_username + FROM tickets t + JOIN users u ON t.user_id = u.id + ''' + params = [] + + conditions = [] + if status_filter and status_filter != 'all': + conditions.append('t.status = ?') + params.append(status_filter) + if priority_filter and priority_filter != 'all': + conditions.append('t.priority = ?') + params.append(priority_filter) + + if conditions: + query += ' WHERE ' + ' AND '.join(conditions) + + query += ' ORDER BY t.updated_at DESC' + + cursor.execute(query, params) + tickets = [dict_from_row(row) for row in cursor.fetchall()] + + # Add message counts and last reply + for ticket in tickets: + cursor.execute('SELECT COUNT(*) as count FROM ticket_messages WHERE ticket_id = ?', (ticket['id'],)) + ticket['message_count'] = cursor.fetchone()['count'] + + cursor.execute(''' + SELECT MAX(created_at) as last_reply + FROM ticket_messages + WHERE ticket_id = ? + ''', (ticket['id'],)) + last_reply = cursor.fetchone()['last_reply'] + ticket['last_reply'] = last_reply + + return tickets + except Exception as e: + logger.error(f"Error getting all tickets: {e}") + return [] + +def add_ticket_message(ticket_id: str, user_id: str, message: str, is_admin: bool = False) -> Optional[Dict]: + """Add a message to a ticket""" + try: + message_id = f"MSG-{str(uuid.uuid4())[:8]}" + + with database_connection() as conn: + cursor = conn.cursor() + + # Add message + cursor.execute(''' + INSERT INTO ticket_messages (id, ticket_id, user_id, message, is_admin) + VALUES (?, ?, ?, ?, ?) + ''', (message_id, ticket_id, user_id, message, is_admin)) + + # Update ticket timestamp + cursor.execute(''' + UPDATE tickets + SET updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (ticket_id,)) + + conn.commit() + + # Get the created message with user info + cursor.execute(''' + SELECT tm.*, u.username as user_username + FROM ticket_messages tm + JOIN users u ON tm.user_id = u.id + WHERE tm.id = ? + ''', (message_id,)) + message_data = dict_from_row(cursor.fetchone()) + + return message_data + except Exception as e: + logger.error(f"Error adding ticket message: {e}") + return None + +def update_ticket_status(ticket_id: str, status: str, assigned_admin_id: str = None) -> bool: + """Update ticket status""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + if assigned_admin_id: + cursor.execute(''' + UPDATE tickets + SET status = ?, assigned_admin_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (status, assigned_admin_id, ticket_id)) + else: + cursor.execute(''' + UPDATE tickets + SET status = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (status, ticket_id)) + + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error updating ticket status: {e}") + return False + +def add_ticket_attachment(ticket_id: str, message_id: str, filename: str, file_data: bytes, + mime_type: str, uploaded_by: str) -> Optional[Dict]: + """Add attachment to ticket""" + try: + attachment_id = f"ATT-{str(uuid.uuid4())[:8]}" + file_size = len(file_data) + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO ticket_attachments (id, ticket_id, message_id, filename, file_data, mime_type, file_size, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', (attachment_id, ticket_id, message_id, filename, file_data, mime_type, file_size, uploaded_by)) + conn.commit() + + cursor.execute('SELECT * FROM ticket_attachments WHERE id = ?', (attachment_id,)) + return dict_from_row(cursor.fetchone()) + except Exception as e: + logger.error(f"Error adding ticket attachment: {e}") + return None + +def get_ticket_attachments(ticket_id: str) -> List[Dict]: + """Get all attachments for a ticket""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM ticket_attachments + WHERE ticket_id = ? + ORDER BY created_at DESC + ''', (ticket_id,)) + return [dict_from_row(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting ticket attachments: {e}") + return [] + +def get_ticket_stats() -> Dict: + """Get ticket statistics""" + try: + with database_connection() as conn: + cursor = conn.cursor() + + stats = {} + + # Total tickets + cursor.execute('SELECT COUNT(*) as count FROM tickets') + stats['total'] = cursor.fetchone()['count'] + + # Tickets by status + cursor.execute('SELECT status, COUNT(*) as count FROM tickets GROUP BY status') + status_counts = cursor.fetchall() + for row in status_counts: + stats[row['status']] = row['count'] + + return stats + except Exception as e: + logger.error(f"Error getting ticket stats: {e}") + return {} diff --git a/user_service.py b/user_service.py new file mode 100644 index 0000000..6add214 --- /dev/null +++ b/user_service.py @@ -0,0 +1,445 @@ +"""User records, account settings, password hashing, and request authentication.""" + + +import bcrypt +from database_service import database_connection, dict_from_row +from datetime import datetime, timedelta, timezone +from email_service import get_email_service +from flask import request +import jwt +from runtime_config import ( + DATABASE_URL, + JWT_SECRET, + SALT_ROUNDS, + logger, +) +import sqlite3 +from typing import Dict, Optional +import uuid + +__all__ = ['add_credit_transaction', 'add_credits', 'authenticate_user', 'create_user', 'generate_oauth_state', 'generate_token', 'generate_two_factor_token', 'get_bearer_token', 'get_full_user_by_id', 'get_full_user_by_username', 'get_new_user_credits', 'get_panel_settings', 'get_pricing', 'get_site_setting', 'get_user_by_apple_id', 'get_user_by_email', 'get_user_by_google_id', 'get_user_by_id', 'get_user_by_username', 'get_user_credits', 'hash_password', 'is_demo_enabled', 'require_admin', 'set_site_setting', 'update_user_credits', 'verify_oauth_state', 'verify_password', 'verify_token'] + + + +def get_site_setting(key: str, default: str = "") -> str: + """Get a single setting value from site_settings table""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM site_settings WHERE key = ?", (key,)) + row = cursor.fetchone() + if row and row['value'] is not None: + return str(row['value']) + except Exception as e: + logger.error(f"Error fetching site setting {key}: {e}") + return default + +def set_site_setting(key: str, value: str) -> bool: + """Insert or update a setting in site_settings table""" + try: + with database_connection() as conn: + cursor = conn.cursor() + if 'postgres' in DATABASE_URL: + cursor.execute( + "INSERT INTO site_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP", + (key, str(value)) + ) + else: + cursor.execute("SELECT 1 FROM site_settings WHERE key = ?", (key,)) + if cursor.fetchone(): + cursor.execute( + "UPDATE site_settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", + (str(value), key) + ) + else: + cursor.execute( + "INSERT INTO site_settings (key, value) VALUES (?, ?)", + (key, str(value)) + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Error setting site setting {key}: {e}") + return False + +def is_demo_enabled() -> bool: + """Check if demo server creation is globally enabled""" + val = get_site_setting('demo_servers_enabled', 'true') + return str(val).lower() in ('true', '1', 'yes', 'on') + +def get_new_user_credits() -> int: + """Get the initial credits granted to new users""" + try: + val = get_site_setting('new_user_credits', '0') + return max(0, int(float(val))) + except (ValueError, TypeError): + return 0 + +def get_panel_settings() -> Dict: + """Get all panel settings""" + return { + 'demo_servers_enabled': is_demo_enabled(), + 'new_user_credits': get_new_user_credits() + } + +def create_user(username: str, email: str, password: str = None, role: str = 'user', credits: Optional[int] = None, + display_name: str = None, first_name: str = None, last_name: str = None, + address: str = None, city: str = None, country: str = None, + postal_code: str = None, phone: str = None, google_id: str = None, + apple_id: str = None, email_verified: bool = False) -> Optional[Dict]: + """Create a new user with extended profile and email verification""" + try: + user_id = f"user-{str(uuid.uuid4())[:8]}" + password_hash = hash_password(password) if password else None + email_verification_token = str(uuid.uuid4()) if not email_verified else None + + if credits is None: + credits = get_new_user_credits() + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO users (id, username, email, password_hash, google_id, apple_id, role, credits, + display_name, first_name, last_name, address, city, + country, postal_code, phone, email_verification_token, + email_verified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (user_id, username, email, password_hash, google_id, apple_id, role, credits, + display_name, first_name, last_name, address, city, + country, postal_code, phone, email_verification_token, email_verified)) + conn.commit() + + if credits > 0: + txn_id = f"txn-{str(uuid.uuid4())[:8]}" + cursor.execute(''' + INSERT INTO credit_transactions (id, user_id, amount, type, description) + VALUES (?, ?, ?, ?, ?) + ''', (txn_id, user_id, credits, 'signup', 'Sign-up bonus credits')) + conn.commit() + + # Create email verification token record if needed + if email_verification_token: + token_id = f"token-{str(uuid.uuid4())[:8]}" + expires_at = (datetime.now() + timedelta(hours=24)).isoformat() + + cursor.execute(''' + INSERT INTO email_verification_tokens (id, user_id, token, expires_at) + VALUES (?, ?, ?, ?) + ''', (token_id, user_id, email_verification_token, expires_at)) + conn.commit() + + # Send verification email + email_svc = get_email_service() + if email_svc: + success = email_svc.send_verification_email(email, username, email_verification_token) + if success: + logger.info(f"โœ… Verification email sent to {email}") + else: + logger.error(f"โŒ Failed to send verification email to {email}") + else: + logger.warning("โš ๏ธ Email service not available") + + cursor.execute(''' + SELECT id, username, email, role, credits, created_at, display_name, + first_name, last_name, address, city, country, postal_code, phone, + email_verified + FROM users WHERE id = ? + ''', (user_id,)) + user = dict_from_row(cursor.fetchone()) + + return user + except sqlite3.IntegrityError as e: + logger.error(f"User creation failed - integrity error: {e}") + return None + except Exception as e: + logger.error(f"User creation failed: {e}", exc_info=True) + return None + +def get_user_by_google_id(google_id: str) -> Optional[Dict]: + """Get user by Google ID""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id, username, email, role, credits, created_at, display_name, + first_name, last_name, address, city, country, postal_code, phone, + email_verified + FROM users WHERE google_id = ? + ''', (google_id,)) + row = cursor.fetchone() + return dict_from_row(row) if row else None + except Exception as e: + logger.error(f"Error getting user by Google ID: {e}") + return None + +def get_user_by_apple_id(apple_id: str) -> Optional[Dict]: + """Get user by Apple ID""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id, username, email, role, credits, created_at, display_name, + first_name, last_name, address, city, country, postal_code, phone, + email_verified + FROM users WHERE apple_id = ? + ''', (apple_id,)) + row = cursor.fetchone() + return dict_from_row(row) if row else None + except Exception as e: + logger.error(f"Error getting user by Apple ID: {e}") + return None + +def get_user_by_email(email: str) -> Optional[Dict]: + """Get user by email""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id, username, email, role, credits, created_at, display_name, + first_name, last_name, address, city, country, postal_code, phone, + email_verified, google_id, apple_id + FROM users WHERE email = ? + ''', (email,)) + row = cursor.fetchone() + return dict_from_row(row) if row else None + except Exception as e: + logger.error(f"Error getting user by email: {e}") + return None + +def get_pricing() -> Dict[str, float]: + """Get all pricing and multipliers from site_settings.""" + try: + # Defaults + defaults = { + 'price_cpu': 35.0, + 'price_ram': 35.0, + 'price_storage': 5.0, + 'multiplier_vps': 1.0, + 'multiplier_game': 0.8, + 'multiplier_website': 1.5, + 'multiplier_database': 1.3 + } + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT key, value FROM site_settings WHERE key LIKE 'price_%' OR key LIKE 'multiplier_%'") + rows = cursor.fetchall() + + pricing = {} + for row in rows: + key = row['key'] + val_str = row['value'] + try: + # Filter out 'None' or empty strings which might cause ValueError + if val_str and val_str.lower() != 'none': + pricing[key] = float(val_str) + except (ValueError, TypeError): + logger.warning(f"Invalid pricing value for {key}: {val_str}") + continue + + # Ensure all defaults are present if not in DB or if value was invalid + for key, val in defaults.items(): + if key not in pricing: + pricing[key] = val + + return pricing + except Exception as e: + logger.error(f"Error getting pricing: {e}") + return { + 'price_cpu': 35.0, 'price_ram': 35.0, 'price_storage': 5.0, + 'multiplier_vps': 1.0, 'multiplier_game': 0.8, + 'multiplier_website': 1.5, 'multiplier_database': 1.3 + } + +def get_user_by_id(user_id: str) -> Optional[Dict]: + """Get user by ID""" + try: + # Ensure user_id is a string + user_id = str(user_id) + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'SELECT id, username, email, role, credits, created_at FROM users WHERE id = ?', + (user_id,) + ) + return dict_from_row(cursor.fetchone()) + except Exception as e: + return None + +def get_full_user_by_id(user_id: str) -> Optional[Dict]: + """Get full user data by ID, including password and 2FA secret.""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,)) + return dict_from_row(cursor.fetchone()) + except Exception as e: + logger.error(f"Error getting full user by ID: {e}") + return None + +def get_user_by_username(username: str) -> Optional[Dict]: + """Get user by username""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id, username, email, password_hash, role, credits, created_at, email_verified + FROM users WHERE username = ? + ''', (username,)) + return dict_from_row(cursor.fetchone()) + except Exception as e: + logger.error(f"Error getting user by username: {e}") + return None + +def get_full_user_by_username(username: str) -> Optional[Dict]: + """Get full user data by username, including password and 2FA secret.""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM users WHERE username = ?', (username,)) + return dict_from_row(cursor.fetchone()) + except Exception as e: + logger.error(f"Error getting full user by username: {e}") + return None + +def update_user_credits(user_id: str, new_credits: int) -> bool: + """Update user credits""" + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE users SET credits = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + (new_credits, user_id) + ) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + return False + +def add_credit_transaction(user_id: str, amount: int, transaction_type: str, description: str = "") -> bool: + """Add credit transaction""" + try: + transaction_id = f"txn-{str(uuid.uuid4())[:8]}" + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO credit_transactions (id, user_id, amount, type, description) + VALUES (?, ?, ?, ?, ?) + ''', (transaction_id, user_id, amount, transaction_type, description)) + conn.commit() + return True + except Exception as e: + return False + +def generate_token(user_id: str) -> str: + payload = { + 'userId': user_id, + 'token_type': 'access', + 'exp': datetime.now(timezone.utc) + timedelta(hours=24) + } + return jwt.encode(payload, JWT_SECRET, algorithm='HS256') + +def generate_two_factor_token(user_id: str) -> str: + """Create a short-lived token usable only to complete the 2FA step.""" + payload = { + 'userId': user_id, + 'token_type': 'two_factor', + 'exp': datetime.now(timezone.utc) + timedelta(minutes=5), + } + return jwt.encode(payload, JWT_SECRET, algorithm='HS256') + +def verify_token(token: str) -> Optional[Dict]: + try: + return jwt.decode(token, JWT_SECRET, algorithms=['HS256']) + except jwt.InvalidTokenError: + return None + + +def generate_oauth_state(provider: str, nonce: Optional[str] = None) -> str: + """Sign short-lived OAuth state so callbacks can reject forged requests.""" + payload = { + 'purpose': 'oauth_state', + 'provider': provider, + 'exp': datetime.now(timezone.utc) + timedelta(minutes=10), + } + if nonce: + payload['nonce'] = nonce + return jwt.encode(payload, JWT_SECRET, algorithm='HS256') + + +def verify_oauth_state(state: Optional[str], provider: str) -> Optional[Dict]: + """Validate a signed OAuth state value for the expected provider.""" + if not state: + return None + try: + payload = jwt.decode(state, JWT_SECRET, algorithms=['HS256']) + except jwt.InvalidTokenError: + return None + if payload.get('purpose') != 'oauth_state' or payload.get('provider') != provider: + return None + return payload + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(SALT_ROUNDS)).decode('utf-8') + +def verify_password(password: str, hashed: str) -> bool: + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) + +def authenticate_user(token: Optional[str]) -> Optional[Dict]: + if not token: + return None + payload = verify_token(token) + if not payload or payload.get('token_type') != 'access': + return None + user_id = payload.get('userId') + if not user_id: + return None + + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id, username, email, role, credits, created_at, email_verified, two_factor_enabled + FROM users WHERE id = ? + ''', (user_id,)) + return dict_from_row(cursor.fetchone()) + except Exception as e: + logger.error(f"Error in authenticate_user: {e}") + return None + +def get_bearer_token() -> Optional[str]: + """Return a well-formed bearer token from the current HTTP request.""" + authorization = request.headers.get('Authorization', '') + scheme, separator, token = authorization.partition(' ') + if not separator or scheme.casefold() != 'bearer': + return None + token = token.strip() + return token or None + +def require_admin() -> Optional[Dict]: + """Return the authenticated admin user, or ``None`` for other callers.""" + user = authenticate_user(get_bearer_token()) + if user and user.get('role') == 'admin': + return user + return None + +def add_credits(user_id: str, amount: int) -> bool: + """Add credits to user account""" + try: + user = get_user_by_id(user_id) + if not user: + return False + + new_credits = user.get('credits', 0) + amount + if update_user_credits(user_id, new_credits): + add_credit_transaction(user_id, amount, 'add', 'Credit top-up') + return True + return False + except Exception as e: + logger.error(f"Error adding credits: {e}") + return False + +def get_user_credits(user_id: str) -> int: + """Get user's current credit balance""" + user = get_user_by_id(user_id) + return user.get('credits', 0) if user else 0 diff --git a/websocket_service.py b/websocket_service.py new file mode 100644 index 0000000..f404e4b --- /dev/null +++ b/websocket_service.py @@ -0,0 +1,3935 @@ +"""WebSocket protocol handlers, sessions, terminal access, and uploads.""" + + +import asyncio +import base64 +from database_service import database_connection, dict_from_row +from datetime import date, datetime, timezone +from domain_runtime import domain_manager +from domain_service import create_custom_domain_record, create_domain_record, delete_domain_record, get_domain_by_id, get_user_custom_domains, get_user_domains, link_domain_to_server, unlink_domain_from_server, verify_domain_dns +import io +import json +from kubernetes import client +from kubernetes.client.rest import ApiException +from kubernetes.stream import stream +from kubernetes_service import custom_objects_api, k8s_manager, v1 +import os +import paramiko +import pyotp +import qrcode +import random +import re +from runtime_config import ( + ALLOWED_ORIGINS, + CHUNK_UPLOAD_DIR, + NODE_IP, + PORT, + WS_PORT, + chunked_uploads, + connected_clients, + logger, + node_sessions, + ssh_sessions, +) +from server_access import create_server_directory, delete_server_path, download_server_file, exec_server_command, expose_port, get_server_logs, get_server_metrics, read_server_file, read_server_file_base64, reconcile_sftp, remove_port, validate_path_for_server, write_server_file +from server_capacity import validate_server_capacity +from server_demo import check_demo_eligibility, convert_demo_to_paid +from server_lifecycle import delete_server, restart_server, start_server, stop_server +from server_provisioning import change_server_version, create_server, extract_server_from_url, extract_token_from_url, generate_sftp_credentials, sync_server_ports_from_k8s, sync_server_status_from_k8s, validate_server_name +from server_records import get_all_servers, get_server_access_and_namespace, get_server_by_id, get_user_servers +import threading +from ticket_service import add_ticket_message, create_ticket, get_all_tickets, get_ticket, get_ticket_stats, get_user_tickets, update_ticket_status +import time +from typing import Dict +import urllib.parse +from user_service import add_credits, authenticate_user, create_user, generate_token, generate_two_factor_token, get_full_user_by_id, get_full_user_by_username, get_new_user_credits, get_user_by_id, get_user_by_username, get_user_credits, verify_password, verify_token +import uuid +import websockets + +__all__ = ['DateTimeEncoder', 'attach_to_game_console', 'ensure_node_shell_pod', 'execute_minecraft_save_all', 'handle_2fa_disable', 'handle_2fa_enable', 'handle_2fa_login', 'handle_2fa_setup', 'handle_add_credits', 'handle_authenticate', 'handle_change_server_version', 'handle_check_demo_eligibility', 'handle_convert_demo_to_paid', 'handle_copy_path', 'handle_create_backup', 'handle_create_custom_domain', 'handle_create_directory', 'handle_create_domain', 'handle_create_server', 'handle_create_ticket', 'handle_delete_backup', 'handle_delete_custom_domain', 'handle_delete_domain', 'handle_delete_path', 'handle_delete_server', 'handle_download_file', 'handle_exec_command', 'handle_expose_port', 'handle_game_console_command', 'handle_game_console_stream', 'handle_generate_sftp', 'handle_get_backups', 'handle_get_credits', 'handle_get_minecraft_settings', 'handle_get_server_creation_progress', 'handle_get_server_logs', 'handle_get_server_metrics', 'handle_get_servers', 'handle_get_ticket', 'handle_get_ticket_stats', 'handle_get_tickets', 'handle_link_domain', 'handle_list_custom_domains', 'handle_list_domains', 'handle_list_files', 'handle_login', 'handle_move_path', 'handle_node_terminal_connect', 'handle_node_terminal_disconnect', 'handle_node_terminal_input', 'handle_pack_zip', 'handle_read_file', 'handle_register', 'handle_remove_port', 'handle_restart_server', 'handle_restore_backup', 'handle_send_ticket_message', 'handle_start_chunked_upload', 'handle_start_server', 'handle_stop_server', 'handle_sync_server_status', 'handle_unlink_domain', 'handle_unpack_zip', 'handle_update_minecraft_settings', 'handle_update_ticket_status', 'handle_upload_file', 'handle_upload_file_chunk', 'handle_verify_custom_domain', 'handle_vps_terminal_connect', 'handle_vps_terminal_disconnect', 'handle_vps_terminal_input', 'handle_websocket', 'handle_write_file', 'notify_admins', 'notify_ticket_participants', 'parse_ls_output', 'send_message', 'send_verification_email', 'start_websocket_server', 'stream_game_console_output', 'stream_metrics', 'sync_user_domains_with_dns'] + + + +async def handle_create_server(client_id, data): + """Handle create server request - async version with capacity validation""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + + try: + # Check if this is a demo server request + is_demo = data.get('is_demo', False) + + # Validate server name first + server_name = data.get('name', '').strip() + is_valid, error_msg = validate_server_name(server_name) + if not is_valid: + await send_message(client_id, { + 'type': 'error', + 'error': error_msg + }) + return + + # Add is_demo to config + data['is_demo'] = is_demo + + # First, validate capacity + capacity_check = validate_server_capacity(data) + + if not capacity_check['success']: + # Send detailed capacity error + await send_message(client_id, { + 'type': 'capacity_error', + 'error': capacity_check['message'], + 'details': capacity_check.get('details', []), + 'error_details': capacity_check.get('error_details', []), + 'summary': capacity_check.get('summary', {}) + }) + return + + # Capacity check passed, proceed with server creation + # Create progress callback that sends websocket messages + loop = asyncio.get_event_loop() + + def progress_callback(phase, progress, message): + """Thread-safe progress callback that sends websocket messages""" + try: + # Store progress in client info for reconnection recovery + if client_id in connected_clients: + connected_clients[client_id]['server_creation_progress'] = { + 'phase': phase, + 'progress': progress, + 'message': message, + 'timestamp': time.time() + } + + # Use run_coroutine_threadsafe to send async message from executor thread + future = asyncio.run_coroutine_threadsafe( + send_message(client_id, { + 'type': 'server_creation_progress', + 'phase': phase, + 'progress': progress, + 'message': message + }), + loop + ) + # Don't wait for completion to avoid blocking + # The future will complete asynchronously + except Exception as e: + logger.error(f"Error sending progress update: {e}") + + # Run blocking create_server in executor with progress callback + server = await loop.run_in_executor(None, create_server, data, user_id, progress_callback) + + # Clear server creation progress on completion + if client_id in connected_clients: + connected_clients[client_id].pop('server_creation_progress', None) + + # Get user credits + user = get_user_by_id(user_id) + user_credits = user.get('credits', 0) if user else 0 + + await send_message(client_id, { + 'type': 'server_created', + 'server': server, + 'credits': user_credits, + 'capacity_check': { + 'success': True, + 'message': capacity_check['message'], + 'suitable_nodes': capacity_check.get('suitable_nodes', []), + 'summary': capacity_check.get('summary', {}) + } + }) + except Exception as e: + logger.error(f"Error creating server: {e}", exc_info=True) + + # Clear server creation progress on error + if client_id in connected_clients: + connected_clients[client_id].pop('server_creation_progress', None) + + # Check if it's a capacity-related error + error_msg = str(e) + if 'Insufficient capacity' in error_msg or 'No nodes have sufficient capacity' in error_msg: + await send_message(client_id, { + 'type': 'capacity_error', + 'error': error_msg, + 'details': [f'Server creation failed: {error_msg}'] + }) + else: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_check_demo_eligibility(client_id, data): + """Handle check demo eligibility request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + + try: + eligibility = check_demo_eligibility(user_id) + await send_message(client_id, { + 'type': 'demo_eligibility', + 'eligible': eligibility['eligible'], + 'has_used_demo': eligibility['has_used_demo'], + 'has_purchased_credits': eligibility['has_purchased_credits'], + 'reason': eligibility.get('reason') + }) + except Exception as e: + logger.error(f"Error checking demo eligibility: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_convert_demo_to_paid(client_id, data): + """Handle convert demo to paid server request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + if not server_id: + await send_message(client_id, {'type': 'error', 'error': 'Server ID required'}) + return + + try: + result = convert_demo_to_paid(server_id, user_id) + + # Get updated server and credits + server = get_server_by_id(server_id) + user = get_user_by_id(user_id) + credits = user.get('credits', 0) if user else 0 + + await send_message(client_id, { + 'type': 'demo_converted', + 'server': server, + 'credits': credits, + 'credits_charged': result['credits_charged'], + 'new_balance': result['new_balance'] + }) + except Exception as e: + logger.error(f"Error converting demo to paid: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_game_console_command(client_id, data): + """Handle game console command""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + command = data.get('command') + + if not server_id or not command: + await send_message(client_id, {'type': 'error', 'error': 'Server ID and command required'}) + return + + try: + result = attach_to_game_console(server_id, user_id, command) + + await send_message(client_id, { + 'type': 'game_console_result', + 'serverId': server_id, + 'command': command, + 'result': result + }) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_game_console_stream(client_id, data): + """Handle game console output stream request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + tail_lines = data.get('tailLines', 50) + + if not server_id: + await send_message(client_id, {'type': 'error', 'error': 'Server ID required'}) + return + + try: + output = stream_game_console_output(server_id, user_id, tail_lines) + + await send_message(client_id, { + 'type': 'game_console_output', + 'serverId': server_id, + 'output': output + }) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_vps_terminal_connect(client_id, data): + """Handle VPS SSH terminal connection""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + if server['type'] != 'vps': + await send_message(client_id, {'type': 'error', 'error': 'Not a VPS server'}) + return + + # Find SSH port + ssh_port = 22 + if server.get('ports'): + for p in server['ports']: + if p.get('name') == 'ssh' or p.get('internal') == 22: + ssh_port = p.get('external', 22) + break + + # Determine credentials + # Image mapping for default credentials (fallback) + image_map = { + 'ubuntu': {'user': 'ubuntu', 'password': 'ubuntu'}, + 'debian': {'user': 'debian', 'password': 'debian'}, + 'alpine': {'user': 'root', 'password': 'alpine'}, + 'centos': {'user': 'centos', 'password': 'centos'} + } + + default_creds = image_map.get(server['image'], image_map['ubuntu']) + ssh_user = default_creds['user'] + ssh_password = default_creds['password'] + + # Internal IP for backend to connect (as requested) + # We use 192.168.10.2 instead of the node IP for internal bridge access + target_ip = "192.168.10.2" + + await send_message(client_id, { + 'type': 'vps_terminal_output', + 'serverId': server_id, + 'output': f"\r\n\x1b[32m[AnchorHost]\x1b[0m Connecting to {server['ip']} (SSH port {ssh_port})...\r\n" + }) + + # Initialize Paramiko + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + ssh.connect(target_ip, port=ssh_port, username=ssh_user, password=ssh_password, timeout=10) + + # Open interactive shell + channel = ssh.invoke_shell(term='xterm', width=80, height=24) + channel.setblocking(0) # Non-blocking for async reading + + # Store session + ssh_sessions[client_id] = { + 'client': ssh, + 'channel': channel, + 'serverId': server_id + } + + await send_message(client_id, { + 'type': 'vps_terminal_output', + 'serverId': server_id, + 'output': "\x1b[32m[AnchorHost]\x1b[0m SSH connection established.\r\n" + }) + + # Start background reading task + async def read_ssh_output(): + try: + while client_id in ssh_sessions and client_id in connected_clients: + if channel.recv_ready(): + data = channel.recv(4096).decode('utf-8', errors='ignore') + if data: + await send_message(client_id, { + 'type': 'vps_terminal_output', + 'serverId': server_id, + 'output': data + }) + await asyncio.sleep(0.05) + except Exception as e: + logger.error(f"SSH Read Error: {e}") + finally: + await handle_vps_terminal_disconnect(client_id, {}) + + asyncio.create_task(read_ssh_output()) + + except Exception as e: + await send_message(client_id, { + 'type': 'vps_terminal_output', + 'serverId': server_id, + 'output': f"\x1b[31m[AnchorHost] Connection failed: {str(e)}\x1b[0m\r\n" + }) + + except Exception as e: + logger.error(f"VPS Terminal Error: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_vps_terminal_input(client_id, data): + """Handle input from client to SSH channel""" + session = ssh_sessions.get(client_id) + if not session: + return + + input_data = data.get('input') + if input_data: + try: + session['channel'].send(input_data) + except Exception as e: + logger.error(f"SSH Send Error: {e}") + await handle_vps_terminal_disconnect(client_id, {}) + +async def handle_vps_terminal_disconnect(client_id, data): + """Clean up SSH session""" + session = ssh_sessions.pop(client_id, None) + if session: + try: + session['channel'].close() + session['client'].close() + + # Notify frontend if it's still connected + if client_id in connected_clients: + await send_message(client_id, { + 'type': 'vps_terminal_output', + 'serverId': session['serverId'], + 'output': "\r\n\x1b[31m[AnchorHost] SSH session closed.\x1b[0m\r\n" + }) + except Exception as e: + logger.error(f"SSH Cleanup Error: {e}") + +async def ensure_node_shell_pod(node_name: str) -> str: + """Ensure a privileged shell pod exists on the node in kube-system namespace""" + pod_name = f"node-shell-{node_name.lower().replace('.', '-').replace('_', '-')}" + namespace = "kube-system" + + try: + pod = v1.read_namespaced_pod(name=pod_name, namespace=namespace) + if pod.status.phase == 'Running': + return pod_name + elif pod.status.phase in ['Failed', 'Succeeded']: + v1.delete_namespaced_pod(name=pod_name, namespace=namespace) + await asyncio.sleep(1) + except Exception: + pass + + # Create the pod + pod_manifest = client.V1Pod( + metadata=client.V1ObjectMeta( + name=pod_name, + namespace=namespace, + labels={"app": "anchorhost-node-shell", "node": node_name} + ), + spec=client.V1PodSpec( + node_name=node_name, + host_pid=True, + host_network=True, + host_ipc=True, + tolerations=[ + client.V1Toleration(operator="Exists") + ], + containers=[ + client.V1Container( + name="shell", + image="alpine:latest", + command=["sleep", "86400"], + security_context=client.V1SecurityContext(privileged=True), + volume_mounts=[ + client.V1VolumeMount(name="host-root", mount_path="/host") + ] + ) + ], + volumes=[ + client.V1Volume( + name="host-root", + host_path=client.V1HostPathVolumeSource(path="/") + ) + ], + restart_policy="Always" + ) + ) + + try: + v1.create_namespaced_pod(namespace=namespace, body=pod_manifest) + except Exception as e: + logger.info(f"Pod creation notice for {pod_name}: {e}") + + # Wait for pod to be running + for _ in range(30): + await asyncio.sleep(0.5) + try: + pod = v1.read_namespaced_pod(name=pod_name, namespace=namespace) + if pod.status.phase == 'Running': + return pod_name + except Exception: + pass + + return pod_name + +async def handle_node_terminal_connect(client_id, data): + """Handle interactive terminal connection to a Kubernetes node""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info.get('authenticated'): + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + user = get_user_by_id(user_id) + if not user or user.get('role') != 'admin': + await send_message(client_id, {'type': 'error', 'error': 'Admin access required'}) + return + + node_name = data.get('nodeName') + if not node_name: + await send_message(client_id, {'type': 'error', 'error': 'Node name required'}) + return + + try: + await send_message(client_id, { + 'type': 'node_terminal_output', + 'nodeName': node_name, + 'output': f"\r\n\x1b[32m[AnchorHost]\x1b[0m Initializing node shell on \x1b[36m{node_name}\x1b[0m...\r\n" + }) + + loop = asyncio.get_event_loop() + pod_name = await ensure_node_shell_pod(node_name) + + def create_ws_client(): + try: + return stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + 'kube-system', + container='shell', + command=['nsenter', '-t', '1', '-m', '-u', '-i', '-n', '-p', '--', '/bin/sh'], + stderr=True, + stdin=True, + stdout=True, + tty=True, + _preload_content=False + ) + except Exception as ns_err: + logger.warning(f"nsenter failed ({ns_err}), falling back to chroot: {ns_err}") + return stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + 'kube-system', + container='shell', + command=['chroot', '/host', '/bin/sh'], + stderr=True, + stdin=True, + stdout=True, + tty=True, + _preload_content=False + ) + + ws_client = await loop.run_in_executor(None, create_ws_client) + + node_sessions[client_id] = { + 'ws_client': ws_client, + 'nodeName': node_name + } + + await send_message(client_id, { + 'type': 'node_terminal_output', + 'nodeName': node_name, + 'output': f"\x1b[32m[AnchorHost]\x1b[0m Node terminal connected to \x1b[1m{node_name}\x1b[0m (host root namespace).\r\n\r\n" + }) + + async def read_node_output(): + try: + while client_id in node_sessions and client_id in connected_clients: + session = node_sessions.get(client_id) + if not session or not session['ws_client'].is_open(): + break + + def read_from_ws(): + if ws_client.is_open(): + try: + return ws_client.read_stdout(timeout=0.05) + except Exception: + return None + return None + + output = await loop.run_in_executor(None, read_from_ws) + if output: + await send_message(client_id, { + 'type': 'node_terminal_output', + 'nodeName': node_name, + 'output': output + }) + await asyncio.sleep(0.02) + except Exception as e: + logger.error(f"Node terminal read error: {e}") + finally: + await handle_node_terminal_disconnect(client_id, {'nodeName': node_name}) + + asyncio.create_task(read_node_output()) + + except Exception as e: + logger.error(f"Node terminal error: {e}", exc_info=True) + await send_message(client_id, { + 'type': 'node_terminal_output', + 'nodeName': node_name, + 'output': f"\x1b[31m[AnchorHost] Connection failed: {str(e)}\x1b[0m\r\n" + }) + +async def handle_node_terminal_input(client_id, data): + """Handle input sent to node terminal session""" + session = node_sessions.get(client_id) + if not session: + return + input_data = data.get('input') + if input_data and session['ws_client'].is_open(): + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, session['ws_client'].write_stdin, input_data) + except Exception as e: + logger.error(f"Node terminal input write error: {e}") + await handle_node_terminal_disconnect(client_id, {}) + +async def handle_node_terminal_disconnect(client_id, data): + """Clean up node terminal session""" + session = node_sessions.pop(client_id, None) + if session: + try: + ws_client = session.get('ws_client') + if ws_client and ws_client.is_open(): + ws_client.close() + if client_id in connected_clients: + await send_message(client_id, { + 'type': 'node_terminal_output', + 'nodeName': session.get('nodeName'), + 'output': "\r\n\x1b[33m[AnchorHost] Node terminal session closed.\x1b[0m\r\n" + }) + except Exception as e: + logger.error(f"Node terminal cleanup error: {e}") + +async def handle_websocket(websocket): + client_id = str(uuid.uuid4()) + + # Extract path from websocket object + try: + path = websocket.request.path if hasattr(websocket, 'request') else getattr(websocket, 'path', '/') + except Exception as e: + logger.warning(f"Could not extract path from websocket: {e}") + path = '/' + + # Log connection details + remote_address = websocket.remote_address if hasattr(websocket, 'remote_address') else 'unknown' + safe_path = urllib.parse.urlsplit(path).path + logger.info(f"New WebSocket connection: {client_id} from {remote_address}, path: {safe_path}") + + token = extract_token_from_url(path) + server_id = extract_server_from_url(path) + user = None + if token: + user = authenticate_user(token) + if user: + connected_clients[client_id] = { + 'authenticated': True, + 'user_id': user['id'], + 'username': user['username'], + 'role': user.get('role', 'user'), + 'websocket': websocket, + 'server_id': server_id + } + + await send_message(client_id, { + 'type': 'authenticated', + 'user': { + 'id': user['id'], + 'username': user['username'], + 'email': user['email'], + 'role': user['role'], + 'credits': user.get('credits', 0), + 'email_verified': user.get('email_verified', False), + 'two_factor_enabled': user.get('two_factor_enabled', False) + } + }) + + if server_id: + # Run sync operations in executor to not block event loop + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, sync_server_status_from_k8s, server_id, user['id']) + await loop.run_in_executor(None, sync_server_ports_from_k8s, server_id, user['id']) + asyncio.create_task(stream_metrics(client_id, server_id, user['id'])) + else: + connected_clients[client_id] = { + 'authenticated': False, + 'websocket': websocket, + 'server_id': server_id + } + + try: + async for message in websocket: + try: + data = json.loads(message) + message_type = data.get('type') + + logger.info(f"๐Ÿ“จ Received {message_type} from client {client_id}") + + # All handlers are already async, just await them + if message_type == 'authenticate': + await handle_authenticate(client_id, data) + elif message_type == 'login': + await handle_login(client_id, data) + elif message_type == '2fa_login': + await handle_2fa_login(client_id, data) + elif message_type == '2fa_setup': + await handle_2fa_setup(client_id, data) + elif message_type == '2fa_enable': + await handle_2fa_enable(client_id, data) + elif message_type == '2fa_disable': + await handle_2fa_disable(client_id, data) + elif message_type == 'register': + await handle_register(client_id, data) + elif message_type == 'get_servers': + await handle_get_servers(client_id, data) + elif message_type == 'get_server_creation_progress': + await handle_get_server_creation_progress(client_id, data) + elif message_type == 'create_server': + await handle_create_server(client_id, data) + elif message_type == 'delete_server': + await handle_delete_server(client_id, data) + elif message_type == 'start_server': + await handle_start_server(client_id, data) + elif message_type == 'stop_server': + await handle_stop_server(client_id, data) + elif message_type == 'restart_server': + await handle_restart_server(client_id, data) + elif message_type == 'get_server_logs': + await handle_get_server_logs(client_id, data) + elif message_type == 'get_server_metrics': + await handle_get_server_metrics(client_id, data) + elif message_type == 'list_files': + await handle_list_files(client_id, data) + elif message_type == 'read_file': + await handle_read_file(client_id, data) + elif message_type == 'write_file': + await handle_write_file(client_id, data) + elif message_type == 'get_credits': + await handle_get_credits(client_id, data) + elif message_type == 'add_credits': + await handle_add_credits(client_id, data) + elif message_type == 'expose_port': + await handle_expose_port(client_id, data) + elif message_type == 'remove_port': + await handle_remove_port(client_id, data) + elif message_type == 'sync_server_status': + await handle_sync_server_status(client_id, data) + elif message_type == 'change_server_version': + await handle_change_server_version(client_id, data) + elif message_type == 'get_minecraft_settings': + await handle_get_minecraft_settings(client_id, data) + elif message_type == 'update_minecraft_settings': + await handle_update_minecraft_settings(client_id, data) + elif message_type == 'get_backups': + await handle_get_backups(client_id, data) + elif message_type == 'create_backup': + await handle_create_backup(client_id, data) + elif message_type == 'restore_backup': + await handle_restore_backup(client_id, data) + elif message_type == 'delete_backup': + await handle_delete_backup(client_id, data) + elif message_type == 'exec_command': + await handle_exec_command(client_id, data) + elif message_type == 'create_directory': + await handle_create_directory(client_id, data) + elif message_type == 'delete_path': + await handle_delete_path(client_id, data) + elif message_type == 'move_path': + await handle_move_path(client_id, data) + elif message_type == 'copy_path': + await handle_copy_path(client_id, data) + elif message_type == 'generate_sftp': + await handle_generate_sftp(client_id, data) + elif message_type == 'get_credits': + + await handle_pack_zip(client_id, data) + elif message_type == 'unpack_zip': + await handle_unpack_zip(client_id, data) + elif message_type == 'upload_file': + await handle_upload_file(client_id, data) + elif message_type == 'start_chunked_upload': + await handle_start_chunked_upload(client_id, data) + elif message_type == 'upload_file_chunk': + await handle_upload_file_chunk(client_id, data) + elif message_type == 'download_file': + await handle_download_file(client_id, data) + elif message_type == 'create_ticket': + await handle_create_ticket(client_id, data) + elif message_type == 'get_tickets': + await handle_get_tickets(client_id, data) + elif message_type == 'get_ticket': + await handle_get_ticket(client_id, data) + elif message_type == 'send_ticket_message': + await handle_send_ticket_message(client_id, data) + elif message_type == 'update_ticket_status': + await handle_update_ticket_status(client_id, data) + elif message_type == 'get_ticket_stats': + await handle_get_ticket_stats(client_id, data) + elif message_type == 'game_console_command': + await handle_game_console_command(client_id, data) + elif message_type == 'game_console_stream': + await handle_game_console_stream(client_id, data) + elif message_type == 'vps_terminal_connect': + await handle_vps_terminal_connect(client_id, data) + elif message_type == 'vps_terminal_input': + await handle_vps_terminal_input(client_id, data) + elif message_type == 'vps_terminal_disconnect': + await handle_vps_terminal_disconnect(client_id, data) + elif message_type == 'node_terminal_connect': + await handle_node_terminal_connect(client_id, data) + elif message_type == 'node_terminal_input': + await handle_node_terminal_input(client_id, data) + elif message_type == 'node_terminal_disconnect': + await handle_node_terminal_disconnect(client_id, data) + elif message_type == 'create_domain': + await handle_create_domain(client_id, data) + elif message_type == 'delete_domain': + await handle_delete_domain(client_id, data) + elif message_type == 'list_domains': + await handle_list_domains(client_id, data) + elif message_type == 'link_domain': + await handle_link_domain(client_id, data) + elif message_type == 'unlink_domain': + await handle_unlink_domain(client_id, data) + elif message_type == 'verify_custom_domain': + await handle_verify_custom_domain(client_id, data) + elif message_type == 'create_custom_domain': + await handle_create_custom_domain(client_id, data) + elif message_type == 'list_custom_domains': + await handle_list_custom_domains(client_id, data) + elif message_type == 'delete_custom_domain': + await handle_delete_custom_domain(client_id, data) + elif message_type == 'check_demo_eligibility': + await handle_check_demo_eligibility(client_id, data) + elif message_type == 'convert_demo_to_paid': + await handle_convert_demo_to_paid(client_id, data) + elif message_type == 'ping': + await send_message(client_id, {'type': 'pong'}) + else: + await send_message(client_id, {'type': 'error', 'error': f'Unknown message type: {message_type}'}) + + except json.JSONDecodeError: + await send_message(client_id, {'type': 'error', 'error': 'Invalid JSON'}) + except Exception as e: + logger.error(f"Error handling message: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + + except websockets.exceptions.ConnectionClosed: + logger.info(f"WebSocket connection closed for client {client_id}") + finally: + # Clean up SSH session if exists + await handle_vps_terminal_disconnect(client_id, {}) + # Clean up Node terminal session if exists + await handle_node_terminal_disconnect(client_id, {}) + + if client_id in connected_clients: + del connected_clients[client_id] +# Domain WebSocket handlers +# Replace the domain WebSocket handlers in app.py with these fixed versions: + +# Domain WebSocket handlers + """Handle create domain request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + username = client_info['username'] + subdomain_name = data.get('subdomain') + server_id = data.get('serverId') + + logger.info(f"DEBUG: Creating domain for user {username}, subdomain: {subdomain_name}") + + if not subdomain_name: + await send_message(client_id, {'type': 'error', 'error': 'Subdomain name required'}) + return + + if not domain_manager: + logger.error("Domain manager not initialized!") + await send_message(client_id, {'type': 'error', 'error': 'Domain service not available'}) + return + + # Add API token check + if not domain_manager.desesec_api_token: + logger.warning("Using default API token - this might not work!") + + try: + # Check if subdomain is available + availability_result = domain_manager.check_domain_availability(subdomain_name, username) + logger.info(f"DEBUG: Availability check: {availability_result}") + + if not availability_result.get('available', False): + await send_message(client_id, {'type': 'error', 'error': availability_result.get('message', 'Domain not available')}) + return + + # Create the subdomain + result = domain_manager.create_subdomain(username, subdomain_name) + logger.info(f"DEBUG: Create subdomain result: {result}") + + if result.get('success') and result.get('domain_data'): + # Save to database + domain_record = { + 'user_id': user_id, + 'subdomain': result['domain_data']['subdomain'], + 'full_domain': result['domain_data']['domain'], + 'record_type': result['domain_data']['record_type'], + 'ip_address': result['domain_data']['ip_address'], + 'ttl': result['domain_data'].get('ttl', 3600), + 'server_id': server_id, + 'created_at': result['domain_data'].get('created_at', datetime.now().isoformat()) + } + + db_domain = create_domain_record(domain_record) + + if db_domain: + await send_message(client_id, { + 'type': 'domain_created', + 'domain': db_domain, + 'message': result.get('message', 'Domain created successfully') + }) + else: + logger.error("Failed to save domain to database!") + await send_message(client_id, { + 'type': 'error', + 'error': 'Domain created in DNS but failed to save to database' + }) + else: + await send_message(client_id, {'type': 'error', 'error': result.get('message', 'Failed to create domain')}) + + except Exception as e: + logger.error(f"Error creating domain: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_delete_domain(client_id, data): + """Handle delete domain request - FIXED VERSION with proper domain type handling""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + username = client_info['username'] + domain_id = data.get('domainId') + + if not domain_id: + await send_message(client_id, {'type': 'error', 'error': 'Domain ID required'}) + return + + if not domain_manager: + await send_message(client_id, {'type': 'error', 'error': 'Domain service not available'}) + return + + try: + # Get domain from database + domain = get_domain_by_id(domain_id) + if not domain or domain['user_id'] != user_id: + await send_message(client_id, {'type': 'error', 'error': 'Domain not found or access denied'}) + return + + # Check if it's a custom domain + is_custom = domain.get('is_custom', False) + + # If domain is linked to a server, unlink it first (this will delete IngressRoute) + if domain.get('server_id'): + logger.info(f"Domain {domain.get('full_domain') or domain.get('domain')} is linked to server {domain['server_id']}, unlinking first...") + + # Unlink the domain (this will delete the IngressRoute) + if unlink_domain_from_server(domain_id): + logger.info(f"โœ“ Unlinked domain from server and deleted IngressRoute") + else: + logger.warning(f"โš  Failed to unlink domain from server, but will continue with deletion") + + # Delete from DNS ONLY if it's a delegated domain + if not is_custom: + result = domain_manager.delete_subdomain(username, domain['subdomain']) + + if not result.get('success'): + # Log warning but continue with database deletion + logger.warning(f"Failed to delete from DNS: {result.get('message')}") + + # Delete from database (works for both delegated and custom domains) + if delete_domain_record(domain_id): + domain_name = domain.get('domain') if is_custom else domain.get('full_domain') + await send_message(client_id, { + 'type': 'custom_domain_deleted' if is_custom else 'domain_deleted', + 'domainId': domain_id, + 'message': f'{"Custom domain" if is_custom else "Domain"} {domain_name} deleted successfully' + }) + else: + await send_message(client_id, { + 'type': 'error', + 'error': 'Failed to delete domain from database' + }) + + except Exception as e: + logger.error(f"Error deleting domain: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_list_domains(client_id, data): + """Handle list domains request - FIXED to separate custom and delegated domains""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + username = client_info['username'] + include_serverless = data.get('includeServerless', True) + + try: + # Get from database - THIS RETURNS ALL DOMAINS (both delegated and custom) + all_domains = get_user_domains(user_id, include_serverless) + + # IMPORTANT: Separate delegated domains from custom domains + delegated_domains = [d for d in all_domains if not d.get('is_custom')] + + # Also sync with DNS to ensure consistency for delegated domains only + if domain_manager: + # First ensure the user domain exists + user_domain = f"u-{username}.{domain_manager.base_domain}" + domain_exists = domain_manager._check_domain_exists(user_domain) + + if domain_exists is None: + logger.error(f"Error checking domain {user_domain}") + elif not domain_exists: + # Create the user domain if it doesn't exist + logger.info(f"Creating user domain: {user_domain}") + if domain_manager._create_domain(user_domain): + logger.info(f"โœ“ Created user domain: {user_domain}") + else: + logger.error(f"โœ— Failed to create user domain: {user_domain}") + + # Now list delegated domains from DNS + result = domain_manager.list_user_domains(username) + + if result.get('success'): + # Create a set of existing delegated domains from database + db_domain_set = {d['full_domain'] for d in delegated_domains} + + # Add any delegated domains from DNS that aren't in database + for dns_domain in result.get('domains', []): + if dns_domain['full_domain'] not in db_domain_set: + # Add to database + domain_record = { + 'user_id': user_id, + 'subdomain': dns_domain['subdomain'], + 'full_domain': dns_domain['full_domain'], + 'record_type': dns_domain['record_type'], + 'ip_address': dns_domain['ip_address'], + 'ttl': dns_domain.get('ttl', 3600), + 'created_at': dns_domain.get('created_at', datetime.now().isoformat()) + } + create_domain_record(domain_record) + + # Refresh delegated domains list from database + all_domains_refreshed = get_user_domains(user_id, include_serverless) + delegated_domains = [d for d in all_domains_refreshed if not d.get('is_custom')] + + # SEND ONLY DELEGATED DOMAINS in the domains_list response + # Custom domains should be fetched via list_custom_domains + await send_message(client_id, { + 'type': 'domains_list', + 'domains': delegated_domains, # ONLY delegated domains + 'count': len(delegated_domains) + }) + + except Exception as e: + logger.error(f"Error listing domains: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_unlink_domain(client_id, data): + """Handle unlink domain from server request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + domain_id = data.get('domainId') + + if not domain_id: + await send_message(client_id, {'type': 'error', 'error': 'Domain ID required'}) + return + + try: + # Verify domain belongs to user + domain = get_domain_by_id(domain_id) + if not domain or domain['user_id'] != user_id: + await send_message(client_id, {'type': 'error', 'error': 'Domain not found or access denied'}) + return + + # Unlink domain from server + if unlink_domain_from_server(domain_id): + await send_message(client_id, { + 'type': 'domain_unlinked', + 'domainId': domain_id, + 'message': 'Domain unlinked from server' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to unlink domain'}) + + except Exception as e: + logger.error(f"Error unlinking domain: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_authenticate(client_id, data): + token = data.get('token') + if not token: + await send_message(client_id, {'type': 'auth_error', 'error': 'No token'}) + return + + user = authenticate_user(token) + if not user: + await send_message(client_id, {'type': 'auth_error', 'error': 'Invalid token'}) + return + + connected_clients[client_id]['authenticated'] = True + connected_clients[client_id]['user_id'] = user['id'] + connected_clients[client_id]['username'] = user['username'] + connected_clients[client_id]['role'] = user.get('role', 'user') + + await send_message(client_id, { + 'type': 'authenticated', + 'user': { + 'id': user['id'], + 'username': user['username'], + 'email': user['email'], + 'role': user['role'], + 'credits': user.get('credits', 0), + 'email_verified': user.get('email_verified', False), + 'two_factor_enabled': user.get('two_factor_enabled', False) + } + }) + +async def handle_login(client_id, data): + username = data.get('username') + password = data.get('password') + + if not username or not password: + await send_message(client_id, {'type': 'auth_error', 'error': 'Username and password required'}) + return + + user = get_full_user_by_username(username) + if not user or not verify_password(password, user['password_hash']): + await send_message(client_id, {'type': 'auth_error', 'error': 'Invalid credentials'}) + return + + if user['two_factor_enabled']: + # Issue a temporary token and ask for 2FA code + temp_token = generate_two_factor_token(user['id']) + await send_message(client_id, { + 'type': '2fa_required', + 'temp_token': temp_token + }) + return + + token = generate_token(user['id']) + + connected_clients[client_id]['authenticated'] = True + connected_clients[client_id]['user_id'] = user['id'] + connected_clients[client_id]['username'] = user['username'] + connected_clients[client_id]['role'] = user.get('role', 'user') + + await send_message(client_id, { + 'type': 'authenticated', + 'user': { + 'id': user['id'], + 'username': user['username'], + 'email': user['email'], + 'role': user['role'], + 'credits': user.get('credits', 0), + 'email_verified': user.get('email_verified', False), + 'two_factor_enabled': user.get('two_factor_enabled', False) + }, + 'token': token + }) + +async def handle_2fa_login(client_id, data): + temp_token = data.get('temp_token') + otp_code = data.get('otp_code') + + if not temp_token or not otp_code: + await send_message(client_id, {'type': 'auth_error', 'error': 'Temporary token and OTP code are required'}) + return + + payload = verify_token(temp_token) + if not payload or payload.get('token_type') != 'two_factor': + await send_message(client_id, {'type': 'auth_error', 'error': 'Invalid temporary token'}) + return + + user = get_full_user_by_id(payload['userId']) + if not user: + await send_message(client_id, {'type': 'auth_error', 'error': 'User not found'}) + return + + secret = user.get('two_factor_secret') + if not secret: + logger.error(f"User {user['id']} missing 2FA secret") + await send_message(client_id, {'type': 'auth_error', 'error': '2FA not configured'}) + return + + # Ensure secret is clean + secret = str(secret).replace(' ', '').strip() + + # Ensure OTP code is clean + if otp_code: + otp_code = str(otp_code).replace(' ', '') + + try: + totp = pyotp.TOTP(secret) + current_otp = totp.now() + except Exception as e: + logger.error(f"Error initializing TOTP for user {user['id']}: {e}") + await send_message(client_id, {'type': 'auth_error', 'error': '2FA configuration error'}) + return + + # Using valid_window=4 to allow for +/- 120 seconds clock drift + logger.info(f"Verifying OTP for user {user['id']} with server time {datetime.now(timezone.utc)}") + logger.info(f"Debug: Server expects OTP: {current_otp} (approx). Provided: {otp_code}") + + if not totp.verify(otp_code, valid_window=4): + logger.warning(f"2FA failed for user {user['id']}. OTP: {otp_code}. Server expected: {current_otp}") + await send_message(client_id, {'type': 'auth_error', 'error': 'Invalid OTP code'}) + return + + token = generate_token(user['id']) + + connected_clients[client_id]['authenticated'] = True + connected_clients[client_id]['user_id'] = user['id'] + connected_clients[client_id]['username'] = user['username'] + connected_clients[client_id]['role'] = user.get('role', 'user') + + await send_message(client_id, { + 'type': 'authenticated', + 'user': { + 'id': user['id'], + 'username': user['username'], + 'email': user['email'], + 'role': user['role'], + 'credits': user.get('credits', 0), + 'email_verified': user.get('email_verified', False), + 'two_factor_enabled': user.get('two_factor_enabled', False) + }, + 'token': token + }) + +async def handle_2fa_setup(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + user = get_full_user_by_id(user_id) + if not user: + await send_message(client_id, {'type': 'error', 'error': 'User not found'}) + return + + # Generate a new secret if one doesn't exist or if re-setup is requested + secret = pyotp.random_base32() + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('UPDATE users SET two_factor_secret = ? WHERE id = ?', (secret, user_id)) + conn.commit() + + provisioning_uri = pyotp.totp.TOTP(secret).provisioning_uri(name=user['email'], issuer_name='AnchorHost') + + # Generate QR code + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(provisioning_uri) + qr.make(fit=True) + img = qr.make_image(fill='black', back_color='white') + + buffered = io.BytesIO() + img.save(buffered) + qr_code_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8") + + await send_message(client_id, { + 'type': '2fa_setup_details', + 'provisioning_uri': provisioning_uri, + 'qr_code': qr_code_base64 + }) + +async def handle_2fa_enable(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + otp_code = data.get('otp_code') + + if not otp_code: + await send_message(client_id, {'type': 'error', 'error': 'OTP code is required'}) + return + + user = get_full_user_by_id(user_id) + if not user or not user['two_factor_secret']: + await send_message(client_id, {'type': 'error', 'error': '2FA not set up'}) + return + + totp = pyotp.TOTP(user['two_factor_secret']) + if not totp.verify(otp_code, valid_window=2): + await send_message(client_id, {'type': 'error', 'error': 'Invalid OTP code'}) + return + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('UPDATE users SET two_factor_enabled = True WHERE id = ?', (user_id,)) + conn.commit() + + await send_message(client_id, {'type': '2fa_enabled', 'success': True}) + +async def handle_2fa_disable(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + otp_code = data.get('otp_code') + + if not otp_code: + await send_message(client_id, {'type': 'error', 'error': 'OTP code is required'}) + return + + user = get_full_user_by_id(user_id) + if not user or not user['two_factor_secret']: + await send_message(client_id, {'type': 'error', 'error': '2FA not enabled'}) + return + + totp = pyotp.TOTP(user['two_factor_secret']) + if not totp.verify(otp_code, valid_window=2): + await send_message(client_id, {'type': 'error', 'error': 'Invalid OTP code'}) + return + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('UPDATE users SET two_factor_enabled = False WHERE id = ?', (user_id,)) + conn.commit() + + await send_message(client_id, {'type': '2fa_disabled', 'success': True}) + +async def handle_register(client_id, data): + username = data.get('username') + password = data.get('password') + email = data.get('email') + + if not username or not password or not email: + await send_message(client_id, {'type': 'auth_error', 'error': 'All fields required'}) + return + + if get_user_by_username(username): + await send_message(client_id, {'type': 'auth_error', 'error': 'Username exists'}) + return + + initial_credits = get_new_user_credits() + user = create_user(username, email, password, 'user', initial_credits) + if not user: + await send_message(client_id, {'type': 'auth_error', 'error': 'Failed to create user'}) + return + + if k8s_manager.connected: + try: + k8s_manager.create_namespace(f"user-{username}".lower()) + except Exception: + pass + + token = generate_token(user['id']) + + connected_clients[client_id]['authenticated'] = True + connected_clients[client_id]['user_id'] = user['id'] + connected_clients[client_id]['username'] = username + + await send_message(client_id, { + 'type': 'authenticated', + 'user': { + 'id': user['id'], + 'username': username, + 'email': email, + 'role': 'user', + 'credits': 200, + 'email_verified': False # ADD THIS LINE - new users are not verified + }, + 'token': token + }) + +async def handle_get_servers(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + role = client_info.get('role', 'user') + + # Run blocking DB operation in executor + loop = asyncio.get_event_loop() + if role == 'admin': + servers = await loop.run_in_executor(None, get_all_servers) + else: + servers = await loop.run_in_executor(None, get_user_servers, user_id) + # Proactively reconcile SFTP for all user servers to ensure they are fully working + for srv in servers: + if srv['type'] != 'vps': + threading.Thread(target=reconcile_sftp, args=(srv['id'], user_id), daemon=True).start() + + # Debug log to verify SFTP fields + if servers and len(servers) > 0: + first_srv = servers[0] + logger.info(f"DEBUG: First server in list: id={first_srv.get('id')}, sftp_user={first_srv.get('sftp_username')}, sftp_port={first_srv.get('sftp_port')}") + + await send_message(client_id, { + + 'type': 'servers_list', + 'servers': servers, + 'count': len(servers) + }) + +async def handle_get_server_creation_progress(client_id, data): + """Handle request for current server creation progress""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + # Get stored progress if available + progress = client_info.get('server_creation_progress') + if progress: + # Check if progress is recent (within last 5 minutes) + if time.time() - progress.get('timestamp', 0) < 300: + await send_message(client_id, { + 'type': 'server_creation_progress', + 'phase': progress.get('phase', ''), + 'progress': progress.get('progress', 0), + 'message': progress.get('message', 'Creating server...') + }) + else: + # Progress is stale, clear it + client_info.pop('server_creation_progress', None) + await send_message(client_id, { + 'type': 'server_creation_progress', + 'phase': 'Unknown', + 'progress': 0, + 'message': 'No active server creation found' + }) + else: + # No progress stored + await send_message(client_id, { + 'type': 'server_creation_progress', + 'phase': 'Unknown', + 'progress': 0, + 'message': 'No active server creation found' + }) + +async def handle_create_domain(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + username = client_info['username'] + subdomain_name = data.get('subdomain') + server_id = data.get('serverId') + + logger.info(f"DEBUG: Creating domain for user {username}, subdomain: {subdomain_name}") + + if not subdomain_name: + await send_message(client_id, {'type': 'error', 'error': 'Subdomain name required'}) + return + + if not domain_manager: + logger.error("Domain manager not initialized!") + await send_message(client_id, {'type': 'error', 'error': 'Domain service not available'}) + return + + # Validate subdomain name + if not domain_manager._is_valid_subdomain(subdomain_name): + await send_message(client_id, {'type': 'error', 'error': 'Invalid subdomain name. Use only lowercase letters, numbers, and hyphens (1-63 chars)'}) + return + + # Add API token check + if not domain_manager.desesec_api_token: + logger.warning("Using default API token - this might not work!") + + try: + # Check if subdomain is available (improved check) + user_domain = f"u-{username}.{domain_manager.base_domain}" + full_subdomain = f"{subdomain_name}.{user_domain}" + + # Check if domain already exists in database + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id FROM user_domains WHERE full_domain = ? AND user_id = ?', + (full_subdomain, user_id)) + if cursor.fetchone(): + await send_message(client_id, {'type': 'error', 'error': 'Domain already exists in your account'}) + return + + # Check with deSEC API - this will create the user domain if it doesn't exist + logger.info(f"Creating domain for user: u-{username}.{domain_manager.base_domain}") + + # The create_subdomain method in DomainManager already handles creating the user domain + # if it doesn't exist, so we just need to call it + result = domain_manager.create_subdomain(username, subdomain_name) + logger.info(f"DEBUG: Create subdomain result: {result}") + + if result.get('success') and result.get('domain_data'): + # Save to database + domain_record = { + 'user_id': user_id, + 'subdomain': result['domain_data']['subdomain'], + 'full_domain': result['domain_data']['domain'], + 'record_type': result['domain_data']['record_type'], + 'ip_address': result['domain_data']['ip_address'], + 'ttl': result['domain_data'].get('ttl', 3600), + 'server_id': server_id, + 'created_at': result['domain_data'].get('created_at', datetime.now().isoformat()) + } + + db_domain = create_domain_record(domain_record) + + if db_domain: + await send_message(client_id, { + 'type': 'domain_created', + 'domain': db_domain, + 'message': result.get('message', 'Domain created successfully') + }) + else: + logger.error("Failed to save domain to database!") + # Try to clean up the DNS record since DB failed + domain_manager.delete_subdomain(username, subdomain_name) + await send_message(client_id, { + 'type': 'error', + 'error': 'Failed to save domain to database. Domain creation rolled back.' + }) + else: + await send_message(client_id, {'type': 'error', 'error': result.get('message', 'Failed to create domain')}) + + except Exception as e: + logger.error(f"Error creating domain: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_delete_server(client_id, data): + """Handle delete server request - async version""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + if not server_id: + await send_message(client_id, {'type': 'error', 'error': 'Server ID required'}) + return + + try: + loop = asyncio.get_event_loop() + success = await loop.run_in_executor(None, delete_server, server_id, user_id) + if success: + await send_message(client_id, { + 'type': 'server_deleted', + 'serverId': server_id, + 'credits': get_user_credits(user_id) + }) + except Exception as e: + logger.error(f"Error deleting server: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_start_server(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + try: + success = start_server(server_id, user_id) + if success: + await send_message(client_id, {'type': 'server_updated', 'serverId': server_id, 'status': 'Starting'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_stop_server(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + try: + success = stop_server(server_id, user_id) + if success: + await send_message(client_id, {'type': 'server_updated', 'serverId': server_id, 'status': 'Stopping'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_restart_server(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + if not server_id: + await send_message(client_id, {'type': 'error', 'error': 'Server ID required'}) + return + + try: + success = restart_server(server_id, user_id) + if success: + await send_message(client_id, { + 'type': 'server_updated', + 'serverId': server_id, + 'status': 'Restarting' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to restart'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_sync_server_status(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + if not server_id: + await send_message(client_id, {'type': 'error', 'error': 'Server ID required'}) + return + + try: + sync_server_status_from_k8s(server_id, user_id) + sync_server_ports_from_k8s(server_id, user_id) + server = get_server_by_id(server_id) + + await send_message(client_id, { + 'type': 'server_updated', + 'server': server + }) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_add_credits(client_id, data): + """Handle add credits request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + amount = data.get('amount', 0) + + if amount <= 0: + await send_message(client_id, {'type': 'error', 'error': 'Invalid credit amount'}) + return + + success = add_credits(user_id, amount) + + if success: + await send_message(client_id, { + 'type': 'credits_added', + 'amount': amount, + 'new_balance': get_user_credits(user_id) + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to add credits'}) + +def sync_user_domains_with_dns(user_id: str) -> bool: + """Sync user domains between database and DNS""" + try: + user = get_user_by_id(user_id) + if not user or not domain_manager: + return False + + username = user['username'] + + # Get domains from DNS + dns_result = domain_manager.list_user_domains(username) + if not dns_result.get('success'): + return False + + dns_domains = {d['full_domain']: d for d in dns_result.get('domains', [])} + + # Get domains from database + db_domains = get_user_domains(user_id, True) + db_domain_set = {d['full_domain'] for d in db_domains} + + # Add missing DNS domains to database + for dns_domain in dns_result.get('domains', []): + if dns_domain['full_domain'] not in db_domain_set: + domain_record = { + 'user_id': user_id, + 'subdomain': dns_domain['subdomain'], + 'full_domain': dns_domain['full_domain'], + 'record_type': dns_domain['record_type'], + 'ip_address': dns_domain['ip_address'], + 'ttl': dns_domain.get('ttl', 3600), + 'created_at': dns_domain.get('created_at', datetime.now().isoformat()) + } + create_domain_record(domain_record) + logger.info(f"Added missing domain to DB: {dns_domain['full_domain']}") + + return True + + except Exception as e: + logger.error(f"Error syncing domains: {e}") + return False + +async def handle_expose_port(client_id, data): + """Handle expose port request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + internal_port = data.get('internalPort') + protocol = data.get('protocol', 'TCP') + + if not server_id or not internal_port: + await send_message(client_id, {'type': 'error', 'error': 'Server ID and internal port required'}) + return + + try: + success = expose_port(server_id, user_id, internal_port, protocol) + + if success: + await send_message(client_id, { + 'type': 'port_exposed', + 'serverId': server_id, + 'internalPort': internal_port, + 'protocol': protocol, + 'message': 'Port exposed successfully' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to expose port'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_remove_port(client_id, data): + """Handle remove port request - FIXED VERSION""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + internal_port = data.get('internalPort') + protocol = data.get('protocol', 'TCP') + + if not server_id or not internal_port: + await send_message(client_id, {'type': 'error', 'error': 'Server ID and internal port required'}) + return + + try: + success = remove_port(server_id, user_id, internal_port, protocol) + + if success: + await send_message(client_id, { + 'type': 'port_removed', + 'serverId': server_id, + 'internalPort': internal_port, + 'protocol': protocol, + 'message': 'Port removed successfully' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to remove port'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_exec_command(client_id, data): + """Handle execute command request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + command = data.get('command') + + if not server_id or not command: + await send_message(client_id, {'type': 'error', 'error': 'Server ID and command required'}) + return + + try: + result = exec_server_command(server_id, user_id, command) + + await send_message(client_id, { + 'type': 'command_result', + 'serverId': server_id, + 'command': command, + 'result': result + }) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_create_directory(client_id, data): + """Handle create directory request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + dir_path = data.get('dirPath') + + if not server_id or not dir_path: + await send_message(client_id, {'type': 'error', 'error': 'Server ID and directory path required'}) + return + + try: + # Validate path for Minecraft servers + dir_path = validate_path_for_server(server_id, dir_path) + + success = create_server_directory(server_id, user_id, dir_path) + + if success: + await send_message(client_id, { + 'type': 'directory_created', + 'serverId': server_id, + 'dirPath': dir_path, + 'message': 'Directory created successfully' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to create directory'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_delete_path(client_id, data): + """Handle delete path request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + path = data.get('path') + + if not server_id or not path: + await send_message(client_id, {'type': 'error', 'error': 'Server ID and path required'}) + return + + try: + # Validate path for Minecraft servers + path = validate_path_for_server(server_id, path) + + success = delete_server_path(server_id, user_id, path) + + if success: + await send_message(client_id, { + 'type': 'path_deleted', + 'serverId': server_id, + 'path': path, + 'message': 'Path deleted successfully' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to delete path'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_move_path(client_id, data): + """Handle move/rename path request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + src_path = data.get('srcPath') + dest_path = data.get('destPath') + paths = data.get('paths') # Optional list for batch move + + if not server_id or (not src_path and not paths) or not dest_path: + await send_message(client_id, {'type': 'error', 'error': 'Server ID, source and destination paths required'}) + return + + try: + namespace, _ = get_server_access_and_namespace(server_id, user_id) + pods = k8s_manager.get_pods_for_server(namespace, server_id) + if not pods: + await send_message(client_id, {'type': 'error', 'error': 'Server pod not found'}) + return + + if paths and isinstance(paths, list): + # Batch move + success_count = 0 + errors = [] + for p in paths: + v_src = validate_path_for_server(server_id, p) + v_dest = validate_path_for_server(server_id, f"{dest_path.rstrip('/')}/{p.split('/')[-1]}") + if k8s_manager.move_path(namespace, pods[0], v_src, v_dest): + success_count += 1 + else: + errors.append(f"Failed to move {p}") + + await send_message(client_id, { + 'type': 'path_moved', + 'serverId': server_id, + 'message': f'Moved {success_count} of {len(paths)} items', + 'successCount': success_count, + 'errors': errors + }) + else: + # Single move + src_path = validate_path_for_server(server_id, src_path) + dest_path = validate_path_for_server(server_id, dest_path) + + if k8s_manager.move_path(namespace, pods[0], src_path, dest_path): + await send_message(client_id, { + 'type': 'path_moved', + 'serverId': server_id, + 'srcPath': src_path, + 'destPath': dest_path, + 'message': 'Path moved successfully' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to move path'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_copy_path(client_id, data): + """Handle copy path request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + src_path = data.get('srcPath') + dest_path = data.get('destPath') + paths = data.get('paths') # Optional list for batch copy + + if not server_id or (not src_path and not paths) or not dest_path: + await send_message(client_id, {'type': 'error', 'error': 'Server ID, source and destination paths required'}) + return + + try: + namespace, _ = get_server_access_and_namespace(server_id, user_id) + pods = k8s_manager.get_pods_for_server(namespace, server_id) + if not pods: + await send_message(client_id, {'type': 'error', 'error': 'Server pod not found'}) + return + + if paths and isinstance(paths, list): + # Batch copy + success_count = 0 + errors = [] + for p in paths: + v_src = validate_path_for_server(server_id, p) + v_dest = validate_path_for_server(server_id, f"{dest_path.rstrip('/')}/{p.split('/')[-1]}") + if k8s_manager.copy_path(namespace, pods[0], v_src, v_dest): + success_count += 1 + else: + errors.append(f"Failed to copy {p}") + + await send_message(client_id, { + 'type': 'path_copied', + 'serverId': server_id, + 'message': f'Copied {success_count} of {len(paths)} items', + 'successCount': success_count, + 'errors': errors + }) + else: + # Single copy + src_path = validate_path_for_server(server_id, src_path) + dest_path = validate_path_for_server(server_id, dest_path) + + if k8s_manager.copy_path(namespace, pods[0], src_path, dest_path): + await send_message(client_id, { + 'type': 'path_copied', + 'serverId': server_id, + 'srcPath': src_path, + 'destPath': dest_path, + 'message': 'Path copied successfully' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to copy path'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_pack_zip(client_id, data): + """Handle pack directory to zip request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + dir_path = data.get('dirPath') + zip_path = data.get('zipPath') + + if not server_id or not dir_path or not zip_path: + await send_message(client_id, {'type': 'zip_error', 'error': 'Server ID, directory path, and zip path required'}) + return + + try: + # Validate paths for Minecraft servers + dir_path = validate_path_for_server(server_id, dir_path) + zip_path = validate_path_for_server(server_id, zip_path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + await send_message(client_id, {'type': 'zip_error', 'error': 'Kubernetes not connected'}) + return + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + await send_message(client_id, {'type': 'zip_error', 'error': 'Server pod not found'}) + return + + # Use zip command to pack directory + # Escape paths properly for shell + import shlex + escaped_dir = shlex.quote(dir_path) + escaped_zip = shlex.quote(zip_path) + dirname = os.path.basename(dir_path.rstrip('/')) + escaped_dirname = shlex.quote(dirname) + + # Create zip file containing directory contents + # Try zip, then jar (common in Minecraft), then python3 + command = f'cd {escaped_dir} && (zip -r {escaped_zip} . || jar cMf {escaped_zip} . || python3 -m zipfile -c {escaped_zip} .) || (cd {escaped_dir}/.. && (zip -r {escaped_zip} {escaped_dirname} || jar cMf {escaped_zip} {escaped_dirname} || python3 -m zipfile -c {escaped_zip} {escaped_dirname}))' + result = k8s_manager.exec_command(namespace, pods[0], ['sh', '-c', command]) + + # Check if zip file was created + check_command = f'test -f {escaped_zip} && echo "exists" || echo "not_found"' + check_result = k8s_manager.exec_command(namespace, pods[0], ['sh', '-c', check_command]) + + if 'exists' in check_result: + await send_message(client_id, { + 'type': 'zip_packed', + 'serverId': server_id, + 'dirPath': dir_path, + 'zipPath': zip_path, + 'message': 'Directory packed successfully' + }) + else: + await send_message(client_id, {'type': 'zip_error', 'error': f'Failed to create zip file. Command output: {result}'}) + except Exception as e: + logger.error(f"Error packing zip: {e}") + await send_message(client_id, {'type': 'zip_error', 'error': str(e)}) + +async def handle_unpack_zip(client_id, data): + """Handle unpack zip file request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + zip_path = data.get('zipPath') + extract_path = data.get('extractPath') + + if not server_id or not zip_path or not extract_path: + await send_message(client_id, {'type': 'zip_error', 'error': 'Server ID, zip path, and extract path required'}) + return + + try: + # Validate paths for Minecraft servers + zip_path = validate_path_for_server(server_id, zip_path) + extract_path = validate_path_for_server(server_id, extract_path) + + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + if not k8s_manager.connected: + await send_message(client_id, {'type': 'zip_error', 'error': 'Kubernetes not connected'}) + return + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + await send_message(client_id, {'type': 'zip_error', 'error': 'Server pod not found'}) + return + + # Use unzip command to extract + import shlex + escaped_zip = shlex.quote(zip_path) + escaped_extract = shlex.quote(extract_path) + + # Extract zip file to extract_path + # Try unzip, then jar, then python3 + command = f'unzip -o {escaped_zip} -d {escaped_extract} || (mkdir -p {escaped_extract} && cd {escaped_extract} && jar xf {escaped_zip}) || python3 -m zipfile -e {escaped_zip} {escaped_extract}' + result = k8s_manager.exec_command(namespace, pods[0], ['sh', '-c', command]) + + # Check if extraction was successful (unzip returns 0 on success) + # We'll check by verifying files were extracted + check_command = f'test -f {escaped_zip} && ls -la {escaped_extract} | head -5' + check_result = k8s_manager.exec_command(namespace, pods[0], ['sh', '-c', check_command]) + + if check_result and ('total' in check_result or len(check_result.strip()) > 0): + await send_message(client_id, { + 'type': 'zip_unpacked', + 'serverId': server_id, + 'zipPath': zip_path, + 'extractPath': extract_path, + 'message': 'Zip file unpacked successfully' + }) + else: + await send_message(client_id, {'type': 'zip_error', 'error': f'Failed to unpack zip file. Command output: {result}'}) + except Exception as e: + logger.error(f"Error unpacking zip: {e}") + await send_message(client_id, {'type': 'zip_error', 'error': str(e)}) + +async def handle_upload_file(client_id, data): + """Handle upload file request (now deprecated - all uploads use chunked upload)""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + file_path = data.get('filePath') + file_content = data.get('fileContent') + is_base64 = data.get('isBase64', True) + + if not server_id or not file_path or not file_content: + await send_message(client_id, {'type': 'error', 'error': 'Server ID, file path, and content required'}) + return + + try: + # Validate path for Minecraft servers + file_path = validate_path_for_server(server_id, file_path) + + # Use binary upload for all files to support all file types + user = get_user_by_id(user_id) + if not user: + await send_message(client_id, {'type': 'error', 'error': 'User not found'}) + return + + if not k8s_manager.connected: + await send_message(client_id, { + 'type': 'file_uploaded', + 'serverId': server_id, + 'filePath': file_path, + 'message': 'File uploaded successfully' + }) + return + + namespace = f"user-{user['username']}".lower() + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + await send_message(client_id, {'type': 'error', 'error': 'Server pod not found'}) + return + + # Use write_file_binary for all files to support binary files + success = k8s_manager.write_file_binary(namespace, pods[0], file_path, file_content) + + if success: + await send_message(client_id, { + 'type': 'file_uploaded', + 'serverId': server_id, + 'filePath': file_path, + 'message': 'File uploaded successfully' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to upload file'}) + except Exception as e: + logger.error(f"Error uploading file: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_start_chunked_upload(client_id, data): + """Handle start of chunked upload - creates temporary file for disk-based storage""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + file_path = data.get('filePath') + total_chunks = data.get('totalChunks') + total_size = data.get('totalSize') + + if not server_id or not file_path or not total_chunks: + await send_message(client_id, { + 'type': 'upload_error', + 'error': 'Server ID, file path, and total chunks required', + 'serverId': server_id, + 'filePath': file_path + }) + return + + try: + # Validate path for Minecraft servers + file_path = validate_path_for_server(server_id, file_path) + + # Check access and get namespace + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + # Create unique upload ID + # Sanitize file_path to avoid path separator issues in upload_id + sanitized_file_path = file_path.replace('/', '_').replace('\\', '_') + upload_id = f"{user_id}_{server_id}_{sanitized_file_path}_{int(time.time() * 1000)}" + + # Create temporary file for storing chunks on disk (avoids memory exhaustion) + temp_file_path = os.path.join(CHUNK_UPLOAD_DIR, f"{upload_id}.tmp") + + # Ensure parent directory exists (should already exist, but be safe) + os.makedirs(os.path.dirname(temp_file_path), exist_ok=True) + + # Initialize upload tracking with disk-based storage + chunked_uploads[upload_id] = { + 'user_id': user_id, + 'server_id': server_id, + 'file_path': file_path, + 'temp_file': temp_file_path, + 'total_chunks': total_chunks, + 'total_size': total_size, + 'received_chunks': set(), # Track which chunks we've received + 'client_id': client_id, + 'start_time': time.time() # Track when upload started for cleanup + } + + # Create empty temporary file + with open(temp_file_path, 'wb') as f: + pass # Create empty file + + # Send acknowledgment to start sending chunks + await send_message(client_id, { + 'type': 'upload_started', + 'serverId': server_id, + 'filePath': file_path, + 'uploadId': upload_id + }) + except Exception as e: + logger.error(f"Error starting chunked upload: {e}") + await send_message(client_id, { + 'type': 'upload_error', + 'error': str(e), + 'serverId': server_id if 'server_id' in locals() else None, + 'filePath': file_path if 'file_path' in locals() else None + }) + # Clean up temp file if it was created + if 'temp_file_path' in locals() and os.path.exists(temp_file_path): + try: + os.remove(temp_file_path) + except: + pass + +async def handle_upload_file_chunk(client_id, data): + """Handle a single chunk of a chunked upload - writes chunks to disk to avoid memory exhaustion""" + client_info = connected_clients.get(client_id) + user_id = client_info['user_id'] if client_info else None + server_id = data.get('serverId') + file_path = data.get('filePath') + + if not client_info or not client_info['authenticated']: + await send_message(client_id, { + 'type': 'upload_error', + 'error': 'Not authenticated', + 'serverId': server_id, + 'filePath': file_path + }) + return + + chunk_index = data.get('chunkIndex') + chunk_data = data.get('chunkData') + total_chunks = data.get('totalChunks') + + if not server_id or not file_path or chunk_index is None or not chunk_data: + await send_message(client_id, { + 'type': 'upload_error', + 'error': 'Missing required chunk data', + 'serverId': server_id, + 'filePath': file_path + }) + return + + upload_id = None + temp_file_path = None + + try: + # Find the upload by matching user_id, server_id, and file_path + for uid, upload_info in chunked_uploads.items(): + if (upload_info['user_id'] == user_id and + upload_info['server_id'] == server_id and + upload_info['file_path'] == file_path): + upload_id = uid + break + + if not upload_id: + await send_message(client_id, { + 'type': 'upload_error', + 'error': 'Upload session not found', + 'serverId': server_id, + 'filePath': file_path + }) + return + + upload_info = chunked_uploads[upload_id] + temp_file_path = upload_info['temp_file'] + + # Ensure CHUNK_UPLOAD_DIR and parent directory exist (in case they were cleaned up or don't exist) + os.makedirs(CHUNK_UPLOAD_DIR, exist_ok=True) + parent_dir = os.path.dirname(temp_file_path) + if parent_dir != CHUNK_UPLOAD_DIR: + # Only create subdirectory if temp_file_path contains subdirectories + os.makedirs(parent_dir, exist_ok=True) + + # Skip if we already received this chunk (idempotent) + if chunk_index in upload_info['received_chunks']: + logger.warning(f"Chunk {chunk_index} already received for upload {upload_id}, skipping") + else: + # Decode base64 chunk and write directly to disk file + chunk_bytes = base64.b64decode(chunk_data) + + # Write chunk to temporary file at correct position + # We need to write chunks in order, so we'll append and sort later, or use a different approach + # For simplicity, we'll store chunks in a sparse file structure + # Create file if it doesn't exist before opening with 'r+b' mode + if not os.path.exists(temp_file_path): + # Create empty file if it doesn't exist + try: + with open(temp_file_path, 'wb') as f: + pass + logger.debug(f"Created temp file: {temp_file_path}") + except Exception as e: + logger.error(f"Failed to create temp file {temp_file_path}: {e}") + raise + + with open(temp_file_path, 'r+b') as f: + # Calculate position: assume max chunk size and write at offset + # Better approach: use a dictionary file or append with metadata + # For now, append chunks with index metadata + f.seek(0, 2) # Seek to end + # Write chunk index and size before chunk data for reassembly + f.write(chunk_index.to_bytes(4, 'big')) + f.write(len(chunk_bytes).to_bytes(4, 'big')) + f.write(chunk_bytes) + + upload_info['received_chunks'].add(chunk_index) + + # Send acknowledgment + await send_message(client_id, { + 'type': 'upload_chunk_received', + 'serverId': server_id, + 'filePath': file_path, + 'chunkIndex': chunk_index, + 'receivedChunks': len(upload_info['received_chunks']), + 'totalChunks': total_chunks + }) + + # Check if all chunks received + if len(upload_info['received_chunks']) >= upload_info['total_chunks']: + # Reassemble file from disk - stream chunks to final file to avoid memory exhaustion + logger.info(f"All chunks received for {upload_id}, reassembling file from disk") + + # Ensure file exists before reading + if not os.path.exists(temp_file_path): + raise Exception(f"Temporary file not found: {temp_file_path}") + + # Create final reassembled file path + reassembled_file_path = temp_file_path.replace('.tmp', '_reassembled.tmp') + + try: + # Read chunks from disk and write directly to final file in order (streaming approach) + # First pass: read all chunk metadata to build index + chunk_positions = {} # {chunk_index: (file_position, size)} + with open(temp_file_path, 'rb') as f: + while True: + # Read chunk index + index_bytes = f.read(4) + if not index_bytes: + break + chunk_idx = int.from_bytes(index_bytes, 'big') + + # Read chunk size + size_bytes = f.read(4) + if not size_bytes: + break + chunk_size = int.from_bytes(size_bytes, 'big') + + # Store position where chunk data starts (after index and size bytes) + data_start_pos = f.tell() + chunk_positions[chunk_idx] = (data_start_pos, chunk_size) + + # Skip chunk data to next chunk + f.seek(chunk_size, 1) + + # Verify we have all chunks + if len(chunk_positions) != upload_info['total_chunks']: + raise Exception(f"Missing chunks: expected {upload_info['total_chunks']}, got {len(chunk_positions)}") + + # Second pass: read chunks in order and write to final file (streaming) + # This avoids loading all chunks into memory at once + with open(temp_file_path, 'rb') as in_f, open(reassembled_file_path, 'wb') as out_f: + for i in range(upload_info['total_chunks']): + if i not in chunk_positions: + raise Exception(f"Missing chunk {i} in reassembly") + + file_pos, chunk_size = chunk_positions[i] + + # Seek to chunk data (skip index and size bytes) + in_f.seek(file_pos) + + # Read and write chunk in smaller pieces to avoid large memory allocation + bytes_written = 0 + read_chunk_size = 1024 * 1024 # 1MB at a time + while bytes_written < chunk_size: + read_size = min(read_chunk_size, chunk_size - bytes_written) + chunk_data = in_f.read(read_size) + if len(chunk_data) != read_size: + raise Exception(f"Incomplete chunk {i} read: expected {read_size}, got {len(chunk_data)}") + out_f.write(chunk_data) + bytes_written += read_size + + # Clear chunk positions to free memory + chunk_positions.clear() + + # Upload complete file using streaming method to avoid memory issues + try: + namespace, _ = get_server_access_and_namespace(server_id, user_id) + except Exception as access_error: + await send_message(client_id, { + 'type': 'upload_error', + 'error': str(access_error), + 'serverId': server_id, + 'filePath': file_path + }) + return + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + await send_message(client_id, { + 'type': 'upload_error', + 'error': 'Server pod not found', + 'serverId': server_id, + 'filePath': file_path + }) + return + + # Validate path for Minecraft servers before uploading + file_path = validate_path_for_server(server_id, file_path) + + # Upload the file using streaming method (reads from disk, doesn't load into memory) + success = k8s_manager.write_file_binary_from_disk(namespace, pods[0], file_path, reassembled_file_path) + + if success: + await send_message(client_id, { + 'type': 'file_uploaded', + 'serverId': server_id, + 'filePath': file_path, + 'message': 'File uploaded successfully' + }) + logger.info(f"Successfully uploaded file {file_path} for upload {upload_id}") + else: + await send_message(client_id, { + 'type': 'upload_error', + 'error': 'Failed to upload file', + 'serverId': server_id, + 'filePath': file_path + }) + finally: + # Clean up temporary files and upload tracking + try: + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + except Exception as cleanup_error: + logger.warning(f"Error cleaning up temp file {temp_file_path}: {cleanup_error}") + + try: + if reassembled_file_path and os.path.exists(reassembled_file_path): + os.remove(reassembled_file_path) + except Exception as cleanup_error: + logger.warning(f"Error cleaning up reassembled file {reassembled_file_path}: {cleanup_error}") + + del chunked_uploads[upload_id] + + except Exception as e: + logger.error(f"Error handling upload chunk: {e}", exc_info=True) + await send_message(client_id, { + 'type': 'upload_error', + 'error': str(e), + 'serverId': server_id if 'server_id' in locals() else None, + 'filePath': file_path if 'file_path' in locals() else None + }) + # Clean up on error + if upload_id and upload_id in chunked_uploads: + temp_file = chunked_uploads[upload_id].get('temp_file') + try: + if temp_file and os.path.exists(temp_file): + os.remove(temp_file) + except: + pass + del chunked_uploads[upload_id] + +async def handle_download_file(client_id, data): + """Handle download file request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + file_path = data.get('filePath') + + if not server_id or not file_path: + await send_message(client_id, {'type': 'error', 'error': 'Server ID and file path required'}) + return + + try: + content = download_server_file(server_id, user_id, file_path) + + await send_message(client_id, { + 'type': 'file_downloaded', + 'serverId': server_id, + 'filePath': file_path, + 'fileContent': content, + 'isBase64': True + }) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_list_files(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + path = data.get('path', '/') + + try: + # Validate path for Minecraft servers + path = validate_path_for_server(server_id, path) + + # Use exec_command instead of direct file listing for better compatibility + result = exec_server_command(server_id, user_id, f'ls -la "{path}" 2>/dev/null || echo "no_access"') + + # Parse the result and convert to files list + files = parse_ls_output(result, path) + + await send_message(client_id, { + 'type': 'files_list', + 'serverId': server_id, + 'path': path, + 'files': files + }) + except Exception as e: + logger.error(f"Error listing files: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +def parse_ls_output(output, path): + """Parse ls -la output and convert to file list format""" + files = [] + lines = output.strip().split('\n') + + for line in lines: + # Skip the "total X" line and empty lines + if line.startswith('total ') or not line.strip(): + continue + + try: + parts = line.split() + if len(parts) < 9: + continue + + permissions = parts[0] + name = ' '.join(parts[8:]) + + if name in ['.', '..']: + continue + + file_type = "directory" if permissions.startswith('d') else "file" + + # Get file size + size = 0 + try: + size = int(parts[4]) + except (ValueError, IndexError): + pass + + # Parse modification time + try: + # Format: "Jan 01 12:00" or "Jan 01 2023" + date_parts = parts[5:8] + if len(date_parts) == 3: + month, day, time_or_year = date_parts + if ':' in time_or_year: + # Current year, use current year + year = datetime.now().year + modified_str = f"{month} {day} {year} {time_or_year}" + modified = datetime.strptime(modified_str, "%b %d %Y %H:%M") + else: + # Different year + year = time_or_year + modified_str = f"{month} {day} {year}" + modified = datetime.strptime(modified_str, "%b %d %Y") + else: + modified = datetime.now() + except Exception as e: + logger.warning(f"Error parsing date: {e}") + modified = datetime.now() + + files.append({ + "name": name, + "type": file_type, + "size": size, + "modified": modified.isoformat() + }) + + except Exception as e: + logger.warning(f"Error parsing file entry: {line}, error: {e}") + continue + + return files + +async def handle_read_file(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + file_path = data.get('filePath') + + try: + # Check if file is an image by extension + image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico'] + is_image = any(file_path.lower().endswith(ext) for ext in image_extensions) + + if is_image: + content = read_server_file_base64(server_id, user_id, file_path) + await send_message(client_id, { + 'type': 'file_content', + 'serverId': server_id, + 'filePath': file_path, + 'content': content, + 'isBase64': True, + 'isImage': True + }) + else: + content = read_server_file(server_id, user_id, file_path) + await send_message(client_id, { + 'type': 'file_content', + 'serverId': server_id, + 'filePath': file_path, + 'content': content + }) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_get_minecraft_settings(client_id, data): + """Get Minecraft server.properties settings""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + try: + content = read_server_file(server_id, user_id, 'server.properties') + if not content: + # Try /data/server.properties if default fails + content = read_server_file(server_id, user_id, '/data/server.properties') + + settings = {} + if content: + for line in content.splitlines(): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + settings[key.strip()] = value.strip() + + await send_message(client_id, { + 'type': 'minecraft_settings', + 'serverId': server_id, + 'settings': settings + }) + except Exception as e: + logger.error(f"Error getting Minecraft settings: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_update_minecraft_settings(client_id, data): + """Update Minecraft server.properties settings""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + updates = data.get('settings', {}) + + try: + # 1. Read existing content + file_path = '/data/server.properties' + content = read_server_file(server_id, user_id, file_path) + + if not content: + # Try server.properties if /data/ fails (though validate_path should handle it) + file_path = 'server.properties' + content = read_server_file(server_id, user_id, file_path) + + if not content: + # If still no content, create a new one or fail + content = "# Minecraft server properties\n" + file_path = '/data/server.properties' + + # 2. Parse and update + lines = content.splitlines() + new_lines = [] + updated_keys = set() + + for line in lines: + trimmed = line.strip() + if trimmed and not trimmed.startswith('#') and '=' in trimmed: + key = trimmed.split('=', 1)[0].strip() + if key in updates: + new_lines.append(f"{key}={updates[key]}") + updated_keys.add(key) + else: + new_lines.append(line) + else: + new_lines.append(line) + + # 3. Add any keys that weren't in the original file + for key, value in updates.items(): + if key not in updated_keys: + new_lines.append(f"{key}={value}") + + # 4. Write back + new_content = "\n".join(new_lines) + success = write_server_file(server_id, user_id, file_path, new_content) + + if success: + await send_message(client_id, { + 'type': 'minecraft_settings_updated', + 'serverId': server_id + }) + # Optionally restart server to apply changes? + # User might want to change multiple things before restarting. + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to save settings'}) + + except Exception as e: + logger.error(f"Error updating Minecraft settings: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_get_backups(client_id, data): + """List backups for a server""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + server_id = data.get('serverId') + if not server_id: + await send_message(client_id, {'type': 'error', 'error': 'Server ID required'}) + return + + try: + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM backups WHERE server_id = ? ORDER BY created_at DESC', (server_id,)) + rows = cursor.fetchall() + backups = [dict_from_row(row) for row in rows] + + await send_message(client_id, { + 'type': 'backups_list', + 'serverId': server_id, + 'backups': backups + }) + except Exception as e: + logger.error(f"Error getting backups: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_create_backup(client_id, data): + """Trigger a new backup for a server""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + backup_name = data.get('name', f"Manual Backup {datetime.now().strftime('%Y-%m-%d %H:%M')}") + + if not server_id: + await send_message(client_id, {'type': 'error', 'error': 'Server ID required'}) + return + + try: + # Get server info + server = get_server_by_id(server_id) + if not server: + raise Exception("Server not found") + + backup_id = str(uuid.uuid4()) + + # Insert into DB + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO backups (id, server_id, name, type, status, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ''', (backup_id, server_id, backup_name, 'Manual', 'In Progress', datetime.now())) + conn.commit() + + # Notify client + await send_message(client_id, { + 'type': 'backup_status_updated', + 'serverId': server_id, + 'backup': { + 'id': backup_id, + 'status': 'In Progress', + 'name': backup_name + } + }) + + # Start backup in background thread/task + async def run_backup(): + try: + server_type = server.get('type') + image = server.get('image') + k8s_name = server.get('k8s_name') + namespace = server.get('namespace') + + # 1. Minecraft Consistency (RCON) + if image == 'minecraft': + await execute_minecraft_save_all(server_id) + + # 2. Identify the PVC and Volume + # PVC is {server_id}-data for app/game servers, {server_id}-rootdisk for VPS + pvc_name = f"{server_id}-data" + if server_type == 'vps': + pvc_name = f"{server_id}-rootdisk" + + # Get PVC to find volumeName + pvc = v1.read_namespaced_persistent_volume_claim(pvc_name, namespace) + volume_name = pvc.spec.volume_name + + if not volume_name: + raise Exception(f"Could not find volume for PVC {pvc_name}") + + # 3. Create Longhorn Snapshot + snapshot_obj = { + "apiVersion": "longhorn.io/v1beta2", + "kind": "Snapshot", + "metadata": { + "name": backup_id, + "namespace": "longhorn-system", + "labels": { + "app": server_id, + "backup-id": backup_id + } + }, + "spec": { + "volume": volume_name, + "createSnapshot": True + } + } + custom_objects_api.create_namespaced_custom_object( + group="longhorn.io", + version="v1beta2", + namespace="longhorn-system", + plural="snapshots", + body=snapshot_obj + ) + + # Wait for snapshot to be ready + logger.info(f"Waiting for Longhorn snapshot {backup_id} to be ready...") + snapshot_ready = False + size = "Unknown" + for _ in range(30): # 60 seconds max + await asyncio.sleep(2) + snap = custom_objects_api.get_namespaced_custom_object( + group="longhorn.io", + version="v1beta2", + namespace="longhorn-system", + plural="snapshots", + name=backup_id + ) + status = snap.get('status', {}) + if status and status.get('readyToUse'): + snapshot_ready = True + # Use restoreSize (full volume size) instead of incremental size + size = status.get('restoreSize', 'Unknown') + logger.info(f"Longhorn snapshot {backup_id} is ready. Restore Size: {size}") + break + + if not snapshot_ready: + raise Exception(f"Longhorn snapshot {backup_id} did not become ready in time") + + # 4. Update DB + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE backups SET status = ?, completed_at = ?, size = ? + WHERE id = ? + ''', ('Completed', datetime.now(), size, backup_id)) + conn.commit() + + await send_message(client_id, { + 'type': 'backup_status_updated', + 'serverId': server_id, + 'backup': { + 'id': backup_id, + 'status': 'Completed', + 'size': size + } + }) + except Exception as e: + logger.error(f"Backup failed for {server_id}: {e}") + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('UPDATE backups SET status = ? WHERE id = ?', ('Failed', backup_id)) + conn.commit() + await send_message(client_id, { + 'type': 'error', + 'error': f"Backup failed: {str(e)}" + }) + + asyncio.create_task(run_backup()) + + except Exception as e: + logger.error(f"Error triggering backup: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def execute_minecraft_save_all(server_id): + """Send save-all command via RCON if server is running""" + try: + # Get server port from ports JSON + server = get_server_by_id(server_id) + ports = json.loads(server['ports']) + rcon_port = next((p['external'] for p in ports if p['name'] == 'rcon'), None) + + if rcon_port: + from mcrcon import MCRcon + # Get RCON password from environment or common secret + rcon_pass = os.environ.get('MINECRAFT_RCON_PASSWORD', 'change-me-in-production') + + with MCRcon(NODE_IP, rcon_pass, port=rcon_port) as mcr: + mcr.command("/save-off") + mcr.command("/save-all") + # Small delay to ensure save is complete + await asyncio.sleep(2) + mcr.command("/save-on") + except Exception as e: + logger.warning(f"Failed to coordinate Minecraft save for {server_id}: {e}") + +async def handle_delete_backup(client_id, data): + """Delete a backup""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + backup_id = data.get('backupId') + server_id = data.get('serverId') + user_id = client_info['user_id'] + + if not backup_id or not server_id: + await send_message(client_id, {'type': 'error', 'error': 'Backup ID and Server ID required'}) + return + + try: + # Get namespace + namespace, _ = get_server_access_and_namespace(server_id, user_id) + + # Delete from Kubernetes (Longhorn Snapshot) + try: + custom_objects_api.delete_namespaced_custom_object( + group="longhorn.io", + version="v1beta2", + namespace="longhorn-system", + plural="snapshots", + name=backup_id + ) + logger.info(f"โœ“ Deleted Longhorn Snapshot {backup_id}") + except Exception as e: + logger.warning(f"Could not delete Longhorn Snapshot {backup_id}: {e}") + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM backups WHERE id = ?', (backup_id,)) + conn.commit() + + await send_message(client_id, { + 'type': 'backup_deleted', + 'serverId': server_id, + 'backupId': backup_id + }) + except Exception as e: + logger.error(f"Error deleting backup: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_restore_backup(client_id, data): + """Restore a backup to a server""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + backup_id = data.get('backupId') + server_id = data.get('serverId') + user_id = client_info['user_id'] + + if not backup_id or not server_id: + await send_message(client_id, {'type': 'error', 'error': 'Backup ID and Server ID required'}) + return + + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + await send_message(client_id, { + 'type': 'info', + 'message': 'Restoration process started. Stopping server...' + }) + + # 1. Stop server + stop_server(server_id, user_id) + + if k8s_manager.connected: + # Wait for pods to be gone (Wait up to 60s) + logger.info(f"Waiting for pods of {server_id} to stop...") + for i in range(12): + pods = v1.list_namespaced_pod(namespace, label_selector=f"app={server_id}") + if not pods.items: + break + logger.info(f" ... {len(pods.items)} pods still running") + await asyncio.sleep(5) + + # 2. Handle PVC restoration + pvc_name = f"{server_id}-data" + if server['type'] == 'vps': pvc_name = f"{server_id}-rootdisk" + + try: + # Get old PVC spec to preserve storage size and storage class + old_pvc = v1.read_namespaced_persistent_volume_claim(pvc_name, namespace) + storage_size = old_pvc.spec.resources.requests['storage'] + storage_class = old_pvc.spec.storage_class_name + + await send_message(client_id, { + 'type': 'info', + 'message': 'Recreating volume from backup...' + }) + + # Delete old PVC + v1.delete_namespaced_persistent_volume_claim(pvc_name, namespace) + + # Wait for PVC to be gone + for _ in range(12): + try: + v1.read_namespaced_persistent_volume_claim(pvc_name, namespace) + await asyncio.sleep(5) + except ApiException as e: + if e.status == 404: + break + + # 3. Create new PVC from backup + pvc_body = client.V1PersistentVolumeClaim( + metadata=client.V1ObjectMeta(name=pvc_name), + spec=client.V1PersistentVolumeClaimSpec( + access_modes=["ReadWriteOnce"], + resources=client.V1ResourceRequirements( + requests={"storage": storage_size} + ), + storage_class_name=storage_class, + data_source=client.V1TypedLocalObjectReference( + api_group="longhorn.io", + kind="Snapshot", + name=backup_id + ) + ) + ) + v1.create_namespaced_persistent_volume_claim(namespace=namespace, body=pvc_body) + logger.info(f"โœ“ Created new PVC {pvc_name} from backup {backup_id}") + + except ApiException as e: + if e.status == 404: + raise Exception(f"PVC {pvc_name} not found") + raise e + else: + await asyncio.sleep(2) # Mock delay + + # 4. Start server + start_server(server_id, user_id) + + await send_message(client_id, { + 'type': 'info', + 'message': 'Restoration completed successfully. Server is starting.' + }) + logger.info(f"โœ“ Successfully restored backup {backup_id} for server {server_id}") + + except Exception as e: + logger.error(f"Error restoring backup: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_change_server_version(client_id, data): + """Handle change server version request - free of charge, billing date unchanged""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + new_version = data.get('version') + java_version = data.get('java_version') + loader = data.get('loader') + + if not server_id or not new_version: + await send_message(client_id, {'type': 'error', 'error': 'Server ID and version required'}) + return + + try: + # Run blocking change_server_version in executor + loop = asyncio.get_event_loop() + # Use lambda or functools.partial to pass keyword arguments if run_in_executor doesn't support them directly in this context + # But run_in_executor supports *args, not **kwargs directly for the function. + # We need to wrap it. + import functools + func = functools.partial(change_server_version, server_id, user_id, new_version, java_version=java_version, loader=loader) + success = await loop.run_in_executor(None, func) + + if success: + # Get updated server info + server = get_server_by_id(server_id) + await send_message(client_id, { + 'type': 'server_version_changed', + 'serverId': server_id, + 'version': new_version, + 'server': server + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to change server version'}) + except Exception as e: + logger.error(f"Error changing server version: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_write_file(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + file_path = data.get('filePath') + content = data.get('content') + + try: + success = write_server_file(server_id, user_id, file_path, content) + if success: + await send_message(client_id, {'type': 'file_written', 'serverId': server_id, 'filePath': file_path}) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to write file'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_generate_sftp(client_id, data): + """Generate SFTP credentials for a server""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + # Only for non-VPS + if server['type'] == 'vps': + await send_message(client_id, {'type': 'error', 'error': 'SFTP sidecar not supported for VPS'}) + return + + u, p, port = generate_sftp_credentials() + + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'UPDATE servers SET sftp_username = ?, sftp_password = ?, sftp_port = ? WHERE id = ?', + (u, p, port, server_id) + ) + conn.commit() + + # Reconcile K8s resources + reconcile_sftp(server_id, user_id) + + await send_message(client_id, { + 'type': 'sftp_generated', + 'serverId': server_id, + 'sftp_username': u, + 'sftp_password': p, + 'sftp_port': port + }) + + logger.info(f"โœ“ Generated SFTP credentials for server {server_id}") + + except Exception as e: + logger.error(f"Error generating SFTP: {e}") + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_get_credits(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + + try: + credits = get_user_credits(user_id) + await send_message(client_id, {'type': 'credits_balance', 'credits': credits}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_get_server_logs(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + try: + logs = get_server_logs(server_id, user_id) + await send_message(client_id, {'type': 'server_logs', 'serverId': server_id, 'logs': logs}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_get_server_metrics(client_id, data): + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + server_id = data.get('serverId') + + try: + metrics = get_server_metrics(server_id, user_id) + + # Proactively reconcile SFTP if needed + # This ensures that even old servers get SFTP sidecar eventually + # We only do this occasionally or if metrics are successful + if metrics and random.random() < 0.1: # 10% chance to reconcile on metrics call to avoid overload + reconcile_sftp(server_id, user_id) + + await send_message(client_id, {'type': 'server_metrics', 'serverId': server_id, 'metrics': metrics}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_create_ticket(client_id, data): + """Handle create ticket request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + subject = data.get('subject') + description = data.get('description') + priority = data.get('priority', 'medium') + category = data.get('category', 'general') + + if not subject or not description: + await send_message(client_id, {'type': 'error', 'error': 'Subject and description required'}) + return + + try: + ticket = create_ticket(user_id, subject, description, priority, category) + + if ticket: + await send_message(client_id, { + 'type': 'ticket_created', + 'ticket': ticket + }) + + # Notify admins about new ticket + await notify_admins('new_ticket', ticket) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to create ticket'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_get_tickets(client_id, data): + """Handle get tickets request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + role = client_info.get('role', 'user') + status_filter = data.get('status_filter') + priority_filter = data.get('priority_filter') + + try: + if role == 'admin': + tickets = get_all_tickets(status_filter, priority_filter) + else: + include_closed = data.get('include_closed', False) + tickets = get_user_tickets(user_id, include_closed) + + await send_message(client_id, { + 'type': 'tickets_list', + 'tickets': tickets + }) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_get_ticket(client_id, data): + """Handle get ticket request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + ticket_id = data.get('ticket_id') + if not ticket_id: + await send_message(client_id, {'type': 'error', 'error': 'Ticket ID required'}) + return + + try: + ticket = get_ticket(ticket_id) + + if ticket: + # Check if user has access to this ticket + user_id = client_info['user_id'] + role = client_info.get('role', 'user') + + if role != 'admin' and str(ticket.get('user_id')) != str(user_id): + await send_message(client_id, {'type': 'error', 'error': 'Access denied'}) + return + + await send_message(client_id, { + 'type': 'ticket_details', + 'ticket': ticket + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Ticket not found'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_send_ticket_message(client_id, data): + """Handle send ticket message request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + role = client_info.get('role', 'user') + ticket_id = data.get('ticket_id') + message = data.get('message') + + if not ticket_id or not message: + await send_message(client_id, {'type': 'error', 'error': 'Ticket ID and message required'}) + return + + try: + # Verify ticket access + ticket = get_ticket(ticket_id) + if not ticket: + await send_message(client_id, {'type': 'error', 'error': 'Ticket not found'}) + return + + if role != 'admin' and str(ticket.get('user_id')) != str(user_id): + await send_message(client_id, {'type': 'error', 'error': 'Access denied'}) + return + + # Add message + is_admin = (role == 'admin') + message_data = add_ticket_message(ticket_id, user_id, message, is_admin) + + if message_data: + await send_message(client_id, { + 'type': 'ticket_message_sent', + 'message': message_data + }) + + # Notify other participants + await notify_ticket_participants(ticket_id, 'new_message', { + 'ticket_id': ticket_id, + 'message': message_data + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to send message'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_update_ticket_status(client_id, data): + """Handle update ticket status request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + role = client_info.get('role', 'user') + if role != 'admin': + await send_message(client_id, {'type': 'error', 'error': 'Admin access required'}) + return + + ticket_id = data.get('ticket_id') + status = data.get('status') + + if not ticket_id or not status: + await send_message(client_id, {'type': 'error', 'error': 'Ticket ID and status required'}) + return + + try: + success = update_ticket_status(ticket_id, status, client_info['user_id']) + + if success: + await send_message(client_id, { + 'type': 'ticket_status_updated', + 'ticket_id': ticket_id, + 'status': status + }) + + # Notify ticket owner about status change + await notify_ticket_participants(ticket_id, 'status_updated', { + 'ticket_id': ticket_id, + 'status': status, + 'updated_by': client_info['username'] + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to update ticket status'}) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_get_ticket_stats(client_id, data): + """Handle get ticket statistics request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + try: + stats = get_ticket_stats() + await send_message(client_id, { + 'type': 'ticket_stats', + 'stats': stats + }) + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def notify_admins(event_type: str, data: Dict): + """Notify all connected admins about an event""" + for client_id, client_info in connected_clients.items(): + if client_info.get('authenticated') and client_info.get('role') == 'admin': + try: + await send_message(client_id, { + 'type': f'admin_notification_{event_type}', + 'data': data + }) + except Exception as e: + logger.error(f"Error notifying admin {client_id}: {e}") + +async def notify_ticket_participants(ticket_id: str, event_type: str, data: Dict): + """Notify all participants of a ticket about an event""" + ticket = get_ticket(ticket_id) + if not ticket: + return + + participants = set() + participants.add(ticket['user_id']) # Ticket owner + + # Add all users who sent messages in the ticket + for message in ticket.get('messages', []): + participants.add(message['user_id']) + + for client_id, client_info in connected_clients.items(): + if client_info.get('authenticated') and client_info.get('user_id') in participants: + try: + await send_message(client_id, { + 'type': f'ticket_{event_type}', + 'data': data + }) + except Exception as e: + logger.error(f"Error notifying ticket participant {client_id}: {e}") + +async def stream_metrics(client_id: str, server_id: str, user_id: str): + while client_id in connected_clients and connected_clients[client_id].get('authenticated'): + try: + metrics = get_server_metrics(server_id, user_id) + await send_message(client_id, { + 'type': 'server_metrics', + 'serverId': server_id, + 'metrics': metrics + }) + await asyncio.sleep(1) + except Exception: + await asyncio.sleep(5) + +async def send_message(client_id, message): + """Send message to WebSocket client with proper error handling""" + if client_id not in connected_clients: + logger.warning(f"Cannot send message to {client_id}: client not connected") + return False + + try: + websocket = connected_clients[client_id]['websocket'] + + # Use custom encoder to handle datetime objects + message_json = json.dumps(message, cls=DateTimeEncoder) + + # Log outgoing messages for debugging + message_type = message.get('type', 'unknown') + logger.debug(f"๐Ÿ“ค Sending {message_type} to {client_id}") + + await websocket.send(message_json) + logger.debug(f"โœ“ Successfully sent {message_type} to {client_id}") + return True + + except websockets.exceptions.ConnectionClosed as e: + logger.warning(f"Connection closed while sending to {client_id}: {e}") + if client_id in connected_clients: + del connected_clients[client_id] + return False + + except TypeError as e: + logger.error(f"JSON serialization error for {client_id}: {e}") + logger.error(f"Problematic message: {message}") + if client_id in connected_clients: + del connected_clients[client_id] + return False + + except Exception as e: + logger.error(f"Error sending message to {client_id}: {e}", exc_info=True) + if client_id in connected_clients: + del connected_clients[client_id] + return False + +def send_verification_email(email: str, token: str): + """Send verification email (placeholder implementation)""" + try: + verification_url = f"http://localhost:{PORT}/verify-email?token={token}" + logger.info(f"Verification email would be sent to {email} with token {token}") + logger.info(f"Verification URL: {verification_url}") + # TODO: Implement actual email sending service + except Exception as e: + logger.error(f"Error sending verification email: {e}") + + +def start_websocket_server(): + """Start WebSocket server in a separate thread with proper event loop""" + def run_server(): + logger.info(f"Starting WebSocket server on port {WS_PORT}...") + + # Create a new event loop for this thread + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def start_server(): + """Async function to start the WebSocket server""" + try: + server = await websockets.serve( + handle_websocket, + "0.0.0.0", + WS_PORT, + origins=[None, *ALLOWED_ORIGINS], + ping_interval=20, # Send ping every 20 seconds + ping_timeout=60, # Wait up to 60 seconds for pong + max_size=10 * 1024 * 1024, # 10MB max message size for file uploads + compression=None # Disable compression for better performance + ) + + logger.info(f"โœ“ WebSocket server started successfully on 0.0.0.0:{WS_PORT}") + logger.info(f" Listening for connections...") + + # Keep the server running + await asyncio.Future() # run forever + + except OSError as e: + if e.errno == 98 or "Address already in use" in str(e): + logger.error(f"โŒ Port {WS_PORT} is already in use!") + logger.error(f" Kill the process using: lsof -ti:{WS_PORT} | xargs kill -9") + else: + logger.error(f"โŒ WebSocket server OS error: {e}") + except Exception as e: + logger.error(f"โŒ WebSocket server failed to start: {e}") + logger.error(f" Error type: {type(e).__name__}") + import traceback + logger.error(traceback.format_exc()) + + try: + # Run the server + loop.run_until_complete(start_server()) + except KeyboardInterrupt: + logger.info("WebSocket server shutting down...") + except Exception as e: + logger.error(f"WebSocket server error: {e}") + finally: + # Clean up + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.close() + logger.info("WebSocket server stopped") + + thread = threading.Thread(target=run_server, daemon=True, name="WebSocketServer") + thread.start() + return thread + + +async def handle_link_domain(client_id, data): + """Handle link domain to server request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + username = client_info['username'] + domain_id = data.get('domainId') + server_id = data.get('serverId') + + if not domain_id: + await send_message(client_id, {'type': 'error', 'error': 'Domain ID required'}) + return + + logger.info(f"DEBUG: Linking domain {domain_id} to server {server_id} for user {user_id}") + + try: + # Verify domain belongs to user + domain = get_domain_by_id(domain_id) + if not domain or domain['user_id'] != user_id: + logger.error(f"Domain not found or access denied. Domain: {domain}, user_id: {user_id}") + await send_message(client_id, {'type': 'error', 'error': 'Domain not found or access denied'}) + return + + # If server_id is None or empty string, unlink + if not server_id or server_id == '': + if unlink_domain_from_server(domain_id): + await send_message(client_id, { + 'type': 'domain_unlinked', + 'domainId': domain_id, + 'message': 'Domain unlinked from server and IngressRoute removed' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to unlink domain'}) + return + + # Verify server belongs to user or admin + server = get_server_by_id(server_id) + if not server: + logger.error(f"Server not found: {server_id}") + await send_message(client_id, {'type': 'error', 'error': 'Server not found'}) + return + + requester = get_user_by_id(user_id) + server_owner_id = str(server.get('owner_id') or server.get('ownerId', '')) + req_id = str(user_id) + + if server_owner_id != req_id and (not requester or requester.get('role') != 'admin'): + logger.error(f"Server access denied. Server owner: {server_owner_id}, User: {req_id}") + await send_message(client_id, {'type': 'error', 'error': 'Server not found or access denied'}) + return + + # Check if server is a website or VPS + if server['type'] not in ['website', 'vps']: + await send_message(client_id, {'type': 'error', 'error': 'Domain can only be linked to Website or VPS servers'}) + return + + # Link domain to server (this will create IngressRoute) + if link_domain_to_server(domain_id, server_id): + await send_message(client_id, { + 'type': 'domain_linked', + 'domainId': domain_id, + 'serverId': server_id, + 'message': f'Domain linked to server {server["name"]} with IngressRoute created' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to link domain'}) + + except Exception as e: + logger.error(f"Error linking domain: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + + +async def handle_verify_custom_domain(client_id, data): + """Handle custom domain verification request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + domain = data.get('domain') + + if not domain: + await send_message(client_id, {'type': 'error', 'error': 'Domain required'}) + return + + try: + # Verify domain DNS + result = verify_domain_dns(domain) + + await send_message(client_id, { + 'type': 'custom_domain_verified', + 'domain': domain, + 'verified': result['verified'], + 'message': result['message'], + 'ip_address': result.get('ip_address'), + 'current_ip': result.get('current_ip') + }) + + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_create_custom_domain(client_id, data): + """Handle create custom domain request with enhanced validation""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + domain = data.get('domain') + server_id = data.get('serverId') + + if not domain: + await send_message(client_id, {'type': 'error', 'error': 'Domain required'}) + return + + # Normalize domain + domain = domain.lower().strip() + + # Basic domain format validation + if not re.match(r'^[a-z0-9.-]+\.[a-z]{2,}$', domain): + await send_message(client_id, { + 'type': 'error', + 'error': 'Invalid domain format' + }) + return + + try: + # Verify domain DNS with new restrictions + verification = verify_domain_dns(domain) + + if verification.get('blocked'): + await send_message(client_id, { + 'type': 'error', + 'error': verification['message'] + }) + return + + if verification.get('already_used'): + await send_message(client_id, { + 'type': 'error', + 'error': verification['message'] + }) + return + + if not verification['verified']: + await send_message(client_id, { + 'type': 'error', + 'error': f'Domain does not point to our server. Current IP: {verification.get("current_ip", "unknown")}' + }) + return + + # Create custom domain record + domain_record = { + 'user_id': user_id, + 'domain': domain, + 'record_type': 'A', + 'ip_address': NODE_IP, + 'ttl': 3600, + 'server_id': server_id, + 'created_at': datetime.now().isoformat() + } + + db_domain = create_custom_domain_record(domain_record) + + if db_domain: + await send_message(client_id, { + 'type': 'custom_domain_created', + 'domain': db_domain, + 'message': f'Custom domain {domain} added successfully' + }) + else: + await send_message(client_id, {'type': 'error', 'error': 'Failed to create custom domain'}) + + except Exception as e: + logger.error(f"Error creating custom domain: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_list_custom_domains(client_id, data): + """Handle list custom domains request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + + try: + domains = get_user_custom_domains(user_id) + + await send_message(client_id, { + 'type': 'custom_domains_list', + 'domains': domains, + 'count': len(domains) + }) + + except Exception as e: + await send_message(client_id, {'type': 'error', 'error': str(e)}) + +async def handle_delete_custom_domain(client_id, data): + """Handle delete custom domain request""" + client_info = connected_clients.get(client_id) + if not client_info or not client_info['authenticated']: + await send_message(client_id, {'type': 'error', 'error': 'Not authenticated'}) + return + + user_id = client_info['user_id'] + username = client_info['username'] + domain_id = data.get('domainId') + + if not domain_id: + await send_message(client_id, {'type': 'error', 'error': 'Domain ID required'}) + return + + try: + # Get domain from database + with database_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT * FROM user_domains + WHERE id = ? AND user_id = ? AND is_custom = TRUE + ''', (domain_id, user_id)) + + domain = dict_from_row(cursor.fetchone()) + + if not domain: + await send_message(client_id, {'type': 'error', 'error': 'Custom domain not found or access denied'}) + return + + # If domain was linked to a server, delete IngressRoute + if domain.get('server_id'): + namespace = f"user-{username}".lower() + # Extract subdomain for IngressRoute name + domain_name = domain['domain'] + subdomain = domain_name.split('.')[0] if '.' in domain_name else domain_name + ingressroute_name = f"{subdomain}-ingress" + + # Delete IngressRoute in Kubernetes + if k8s_manager.connected: + try: + custom_objects_api.delete_namespaced_custom_object( + group="traefik.io", + version="v1alpha1", + namespace=namespace, + plural="ingressroutes", + name=ingressroute_name + ) + logger.info(f"Deleted IngressRoute for custom domain: {domain_name}") + except ApiException as e: + if e.status != 404: + logger.warning(f"Error deleting IngressRoute: {e.reason}") + + # Delete from database + cursor.execute('DELETE FROM user_domains WHERE id = ?', (domain_id,)) + conn.commit() + + logger.info(f"โœ“ Deleted custom domain: {domain.get('domain')}") + + await send_message(client_id, { + 'type': 'custom_domain_deleted', + 'domainId': domain_id, + 'message': 'Custom domain deleted successfully' + }) + + except Exception as e: + logger.error(f"Error deleting custom domain: {e}", exc_info=True) + await send_message(client_id, {'type': 'error', 'error': str(e)}) + + +class DateTimeEncoder(json.JSONEncoder): + """Custom JSON encoder that handles datetime objects""" + def default(self, obj): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + return super().default(obj) + +def attach_to_game_console(server_id: str, user_id: str, command: str) -> str: + """Attach to game server console and send command""" + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + if server['type'] != 'game': + return "Not a game server" + + if not k8s_manager.connected: + return f"Mock game console: {command}\n[Server] Command executed" + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return "No pods found" + + # Send command to game server's stdin + result = k8s_manager.attach_and_send_command(namespace, pods[0], command, container_name=server_id) + return result + except Exception as e: + logger.error(f"Error attaching to game console: {e}") + return f"Error: {str(e)}" + +def stream_game_console_output(server_id: str, user_id: str, tail_lines: int = 50) -> str: + """Get recent game console output""" + try: + namespace, server = get_server_access_and_namespace(server_id, user_id) + + if server['type'] != 'game': + return "Not a game server" + + if not k8s_manager.connected: + return "Mock game console output:\n[Server] Server is running\n[Server] Type 'help' for commands" + + pods = k8s_manager.get_pods_for_server(namespace, server_id) + + if not pods: + return "No pods found" + + # Get pod logs which contain game server output + logs = k8s_manager.get_pod_logs(namespace, pods[0], tail_lines, container_name=server_id) + return logs + except Exception as e: + logger.error(f"Error streaming game console: {e}") + return f"Error: {str(e)}"