import os import time import sqlite3 import asyncio import pyotp import httpx import base64 import secrets import urllib.parse from typing import Optional from fastapi import FastAPI, Request, Depends, HTTPException from fastapi.responses import Response, FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from passlib.context import CryptContext from pydantic import BaseModel from cryptography.fernet import Fernet pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") WEB_PASSWORD = os.getenv("WEB_PASSWORD", "admin") ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY") FIREWALL_HOST_IP = os.getenv("FIREWALL_HOST_IP") DB_PATH = "data/instances.db" if not ENCRYPTION_KEY or ENCRYPTION_KEY == "CHANGE_ME_TO_A_VALID_FERNET_KEY": print("WARNING: ENCRYPTION_KEY not set or invalid.") print("Generating a random key for this session, but secrets will be lost on restart!") ENCRYPTION_KEY = Fernet.generate_key().decode() try: fernet = Fernet(ENCRYPTION_KEY) except ValueError: print("ERROR: ENCRYPTION_KEY must be a valid 32-byte base64 encoded string.") exit(1) app = FastAPI() app.add_middleware(SessionMiddleware, secret_key=ENCRYPTION_KEY) def get_password_hash(password): # bcrypt has a 72-byte limit return pwd_context.hash(password[:72]) def verify_password(plain_password, hashed_password): return pwd_context.verify(plain_password[:72], hashed_password) def get_db(): conn = sqlite3.connect(DB_PATH, timeout=20.0) conn.execute("PRAGMA journal_mode=WAL;") return conn def init_db(): conn = get_db() c = conn.cursor() # Create users table c.execute(''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, hashed_password TEXT NOT NULL, is_admin INTEGER DEFAULT 0 ) ''') c.execute(''' CREATE TABLE IF NOT EXISTS otp_secrets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, encrypted_secret TEXT NOT NULL, user_id INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users (id) ) ''') # Create instances table first if it doesn't exist c.execute(''' CREATE TABLE IF NOT EXISTS instances ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, ip TEXT NOT NULL, port INTEGER NOT NULL, encrypted_secret TEXT, otp_secret_id INTEGER, last_status TEXT DEFAULT 'Unknown', last_checked REAL DEFAULT 0, user_id INTEGER, FOREIGN KEY (user_id) REFERENCES users (id), FOREIGN KEY (otp_secret_id) REFERENCES otp_secrets (id) ) ''') # Create activity_logs table c.execute(''' CREATE TABLE IF NOT EXISTS activity_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, level TEXT NOT NULL, message TEXT NOT NULL ) ''') # Now check for column migrations c.execute("PRAGMA table_info(instances)") columns = [col[1] for col in c.fetchall()] if "user_id" not in columns: print("Migrating instances table to include user_id...") c.execute('ALTER TABLE instances ADD COLUMN user_id INTEGER REFERENCES users(id)') if "otp_secret_id" not in columns: print("Migrating instances table to include otp_secret_id...") c.execute('ALTER TABLE instances ADD COLUMN otp_secret_id INTEGER REFERENCES otp_secrets(id)') # Migration: Move existing encrypted_secret to otp_secrets table c.execute('SELECT id, name, encrypted_secret, user_id FROM instances WHERE encrypted_secret IS NOT NULL') instances_to_migrate = c.fetchall() for inst_id, inst_name, secret, user_id in instances_to_migrate: if secret and user_id: # Create a new OTP secret entry secret_name = f"Migrated ({inst_name})" c.execute('INSERT INTO otp_secrets (name, encrypted_secret, user_id) VALUES (?, ?, ?)', (secret_name, secret, user_id)) otp_secret_id = c.lastrowid # Update instance to point to the new OTP secret c.execute('UPDATE instances SET otp_secret_id = ? WHERE id = ?', (otp_secret_id, inst_id)) # Create or update default admin if no users exist or only one user exists c.execute('SELECT COUNT(*) FROM users') count = c.fetchone()[0] if count == 0: print("Creating default admin user...") admin_hash = get_password_hash(WEB_PASSWORD) c.execute('INSERT INTO users (username, hashed_password, is_admin) VALUES (?, ?, ?)', ('admin', admin_hash, 1)) elif count == 1: # If there's only one user and it's 'admin', ensure its password matches WEB_PASSWORD c.execute('SELECT id, username FROM users LIMIT 1') user = c.fetchone() if user[1] == 'admin': print("Ensuring admin password matches WEB_PASSWORD...") admin_hash = get_password_hash(WEB_PASSWORD) c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (admin_hash, user[0])) conn.commit() conn.close() def log_activity(level: str, message: str): try: conn = get_db() c = conn.cursor() c.execute('INSERT INTO activity_logs (timestamp, level, message) VALUES (?, ?, ?)', (time.time(), level, message)) conn.commit() conn.close() except Exception as e: print(f"Failed to log activity: {e}") init_db() log_activity("INFO", "Database initialized / application startup.") def is_authenticated(request: Request): if not request.session.get("user_id"): raise HTTPException(status_code=401, detail="Not authenticated") return True def is_admin(request: Request): if not request.session.get("is_admin"): raise HTTPException(status_code=403, detail="Admin access required") return True @app.post("/api/login") async def login(request: Request): data = await request.json() username = data.get("username") password = data.get("password") conn = get_db() conn.row_factory = sqlite3.Row c = conn.cursor() c.execute('SELECT * FROM users WHERE username = ?', (username,)) user = c.fetchone() conn.close() if user and verify_password(password, user['hashed_password']): request.session["user_id"] = user['id'] request.session["username"] = user['username'] request.session["is_admin"] = bool(user['is_admin']) log_activity("INFO", f"User '{user['username']}' logged in successfully.") return {"status": "ok", "user": {"username": user['username'], "is_admin": bool(user['is_admin'])}} log_activity("WARNING", f"Failed login attempt for username '{username}'.") raise HTTPException(status_code=401, detail="Invalid username or password") @app.get("/api/logout") async def logout(request: Request): username = request.session.get("username", "Unknown") request.session.clear() log_activity("INFO", f"User '{username}' logged out.") return {"status": "ok"} @app.get("/api/auth/status") async def auth_status(request: Request): if request.session.get("user_id"): return { "authenticated": True, "user": { "username": request.session.get("username"), "is_admin": request.session.get("is_admin") } } return {"authenticated": False} # User Management (Admin Only) @app.get("/api/users", dependencies=[Depends(is_authenticated), Depends(is_admin)]) def get_users(): conn = get_db() conn.row_factory = sqlite3.Row c = conn.cursor() c.execute('SELECT id, username, is_admin FROM users') users = [dict(row) for row in c.fetchall()] conn.close() return users @app.post("/api/users", dependencies=[Depends(is_authenticated), Depends(is_admin)]) async def create_user(request: Request): data = await request.json() username = data.get("username") password = data.get("password") is_admin = 1 if data.get("is_admin") else 0 if not username or not password: raise HTTPException(status_code=400, detail="Username and password required") hashed = get_password_hash(password) try: conn = get_db() c = conn.cursor() c.execute('INSERT INTO users (username, hashed_password, is_admin) VALUES (?, ?, ?)', (username, hashed, is_admin)) conn.commit() conn.close() admin_username = request.session.get("username", "Admin") log_activity("INFO", f"Admin '{admin_username}' created user '{username}' (Admin: {bool(is_admin)}).") except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="Username already exists") return {"status": "ok"} @app.delete("/api/users/{id}", dependencies=[Depends(is_authenticated), Depends(is_admin)]) def delete_user(id: int, request: Request): if id == request.session.get("user_id"): raise HTTPException(status_code=400, detail="Cannot delete yourself") conn = get_db() c = conn.cursor() c.execute('SELECT username FROM users WHERE id=?', (id,)) target_user_row = c.fetchone() target_username = target_user_row[0] if target_user_row else f"ID {id}" c.execute('DELETE FROM users WHERE id=?', (id,)) # Also delete their instances? Let's say yes for cleanliness. c.execute('DELETE FROM instances WHERE user_id=?', (id,)) conn.commit() conn.close() admin_username = request.session.get("username", "Admin") log_activity("INFO", f"Admin '{admin_username}' deleted user '{target_username}' and all associated instances.") return {"status": "ok"} @app.put("/api/users/{id}/password", dependencies=[Depends(is_authenticated), Depends(is_admin)]) async def admin_reset_password(id: int, request: Request): data = await request.json() new_password = data.get("password") if not new_password: raise HTTPException(status_code=400, detail="New password required") hashed = get_password_hash(new_password) conn = get_db() c = conn.cursor() c.execute('SELECT username FROM users WHERE id=?', (id,)) target_user_row = c.fetchone() target_username = target_user_row[0] if target_user_row else f"ID {id}" c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, id)) conn.commit() conn.close() admin_username = request.session.get("username", "Admin") log_activity("INFO", f"Admin '{admin_username}' reset password for user '{target_username}'.") return {"status": "ok"} # Own Password Change (All Users) @app.put("/api/users/me/password", dependencies=[Depends(is_authenticated)]) async def change_own_password(request: Request): data = await request.json() new_password = data.get("password") if not new_password: raise HTTPException(status_code=400, detail="New password required") user_id = request.session.get("user_id") username = request.session.get("username") hashed = get_password_hash(new_password) conn = get_db() c = conn.cursor() c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, user_id)) conn.commit() conn.close() log_activity("INFO", f"User '{username}' updated their password.") return {"status": "ok"} @app.put("/api/users/me/username", dependencies=[Depends(is_authenticated)]) async def change_own_username(request: Request): data = await request.json() new_username = data.get("username") if not new_username: raise HTTPException(status_code=400, detail="New username required") user_id = request.session.get("user_id") old_username = request.session.get("username") try: conn = get_db() c = conn.cursor() c.execute('UPDATE users SET username = ? WHERE id = ?', (new_username, user_id)) conn.commit() conn.close() request.session["username"] = new_username log_activity("INFO", f"User '{old_username}' updated their username to '{new_username}'.") except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="Username already exists") return {"status": "ok"} class OTPSecretCreate(BaseModel): name: str secret: str class OTPSecretUpdate(BaseModel): name: str secret: Optional[str] = None class InstanceCreate(BaseModel): name: str ip: str port: int = 4646 otp_secret_id: int class InstanceUpdate(BaseModel): name: str ip: str port: int = 4646 otp_secret_id: int def clean_secret(secret_input): secret_clean = secret_input.strip() if secret_clean.lower().startswith('otpauth://'): try: parsed = urllib.parse.urlparse(secret_clean) params = urllib.parse.parse_qs(parsed.query) if 'secret' in params: return params['secret'][0].replace(" ", "").upper() except Exception: pass return secret_clean.replace(" ", "").upper() # OTP Secret Management @app.get("/api/otp-secrets", dependencies=[Depends(is_authenticated)]) def get_otp_secrets(request: Request): user_id = request.session.get("user_id") conn = get_db() conn.row_factory = sqlite3.Row c = conn.cursor() c.execute('SELECT id, name FROM otp_secrets WHERE user_id = ?', (user_id,)) secrets = [dict(row) for row in c.fetchall()] conn.close() return secrets @app.post("/api/otp-secrets", dependencies=[Depends(is_authenticated)]) def create_otp_secret(secret_data: OTPSecretCreate, request: Request): user_id = request.session.get("user_id") username = request.session.get("username") cleaned = clean_secret(secret_data.secret) encrypted = fernet.encrypt(cleaned.encode()).decode() conn = get_db() c = conn.cursor() c.execute(''' INSERT INTO otp_secrets (name, encrypted_secret, user_id) VALUES (?, ?, ?) ''', (secret_data.name, encrypted, user_id)) conn.commit() conn.close() log_activity("INFO", f"User '{username}' created OTP secret '{secret_data.name}'.") return {"status": "ok"} @app.put("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)]) def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request): user_id = request.session.get("user_id") username = request.session.get("username") conn = get_db() c = conn.cursor() # Verify ownership c.execute('SELECT name FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id)) row = c.fetchone() if not row: conn.close() raise HTTPException(status_code=404, detail="OTP Secret not found") old_name = row[0] if secret_data.secret: cleaned = clean_secret(secret_data.secret) encrypted = fernet.encrypt(cleaned.encode()).decode() c.execute(''' UPDATE otp_secrets SET name=?, encrypted_secret=? WHERE id=? AND user_id=? ''', (secret_data.name, encrypted, id, user_id)) log_activity("INFO", f"User '{username}' updated OTP secret '{old_name}' (name updated to '{secret_data.name}' and key value changed).") else: c.execute(''' UPDATE otp_secrets SET name=? WHERE id=? AND user_id=? ''', (secret_data.name, id, user_id)) log_activity("INFO", f"User '{username}' updated OTP secret name '{old_name}' to '{secret_data.name}'.") conn.commit() conn.close() return {"status": "ok"} @app.delete("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)]) def delete_otp_secret(id: int, request: Request): user_id = request.session.get("user_id") username = request.session.get("username") conn = get_db() c = conn.cursor() # Verify ownership c.execute('SELECT name FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id)) row = c.fetchone() if not row: conn.close() raise HTTPException(status_code=404, detail="OTP Secret not found") secret_name = row[0] # Check if used by any instances c.execute('SELECT COUNT(*) FROM instances WHERE otp_secret_id=?', (id,)) if c.fetchone()[0] > 0: conn.close() raise HTTPException(status_code=400, detail="Cannot delete secret because it is in use by one or more instances") c.execute('DELETE FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id)) conn.commit() conn.close() log_activity("INFO", f"User '{username}' deleted OTP secret '{secret_name}'.") return {"status": "ok"} async def poll_instances(): while True: try: conn = get_db() conn.row_factory = sqlite3.Row c = conn.cursor() c.execute(''' SELECT i.*, s.encrypted_secret FROM instances i JOIN otp_secrets s ON i.otp_secret_id = s.id ''') instances = c.fetchall() conn.close() async with httpx.AsyncClient(timeout=1.0) as client: for inst in instances: # Skip if we checked recently and it was successful (60s cooldown) if inst['last_status'] == 'Sent' and (time.time() - inst['last_checked'] < 60): continue ip = inst['ip'] port = inst['port'] try: url = f"http://{ip}:{port}/ffxivlauncher/" # Poll the server endpoint to see if it's accepting connections await client.get(url, timeout=0.5) secret = fernet.decrypt(inst['encrypted_secret'].encode()).decode() totp = pyotp.TOTP(secret) otp = totp.now() send_url = f"http://{ip}:{port}/ffxivlauncher/{otp}" resp = await client.get(send_url, timeout=1.0) if resp.status_code == 200: status_msg = "Sent" log_activity("INFO", f"Polled instance '{inst['name']}' ({ip}:{port}) - OTP sent successfully.") else: status_msg = f"Failed: {resp.status_code}" log_activity("WARNING", f"Polled instance '{inst['name']}' ({ip}:{port}) - Failed: HTTP status {resp.status_code}.") except (httpx.ConnectError, httpx.TimeoutException): status_msg = "Offline" log_activity("INFO", f"Polled instance '{inst['name']}' ({ip}:{port}) - Offline (no response on port {port}).") except Exception as e: status_msg = f"Error: {str(e)}" log_activity("ERROR", f"Polled instance '{inst['name']}' ({ip}:{port}) - Error: {str(e)}.") conn = get_db() c = conn.cursor() c.execute(''' UPDATE instances SET last_status = ?, last_checked = ? WHERE id = ? ''', (status_msg, time.time(), inst['id'])) conn.commit() conn.close() except Exception as e: print(f"Poller error: {e}") await asyncio.sleep(5) @app.on_event("startup") async def startup_event(): asyncio.create_task(poll_instances()) @app.get("/api/instances", dependencies=[Depends(is_authenticated)]) def get_instances(request: Request): user_id = request.session.get("user_id") conn = get_db() conn.row_factory = sqlite3.Row c = conn.cursor() c.execute(''' SELECT i.id, i.name, i.ip, i.port, i.last_status, i.last_checked, i.otp_secret_id, s.name as otp_secret_name FROM instances i LEFT JOIN otp_secrets s ON i.otp_secret_id = s.id WHERE i.user_id = ? ''', (user_id,)) instances = [dict(row) for row in c.fetchall()] conn.close() return instances @app.post("/api/instances", dependencies=[Depends(is_authenticated)]) def create_instance(inst: InstanceCreate, request: Request): user_id = request.session.get("user_id") username = request.session.get("username") conn = get_db() c = conn.cursor() c.execute(''' INSERT INTO instances (name, ip, port, otp_secret_id, user_id, encrypted_secret) VALUES (?, ?, ?, ?, ?, ?) ''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, user_id, "")) conn.commit() conn.close() log_activity("INFO", f"User '{username}' created instance '{inst.name}' ({inst.ip}:{inst.port}).") return {"status": "ok"} @app.put("/api/instances/{id}", dependencies=[Depends(is_authenticated)]) def update_instance(id: int, inst: InstanceUpdate, request: Request): user_id = request.session.get("user_id") username = request.session.get("username") conn = get_db() c = conn.cursor() # Verify ownership c.execute('SELECT name FROM instances WHERE id=? AND user_id=?', (id, user_id)) row = c.fetchone() if not row: conn.close() raise HTTPException(status_code=404, detail="Instance not found") old_name = row[0] c.execute(''' UPDATE instances SET name=?, ip=?, port=?, otp_secret_id=? WHERE id=? AND user_id=? ''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, id, user_id)) conn.commit() conn.close() log_activity("INFO", f"User '{username}' updated instance '{old_name}' -> '{inst.name}' ({inst.ip}:{inst.port}).") return {"status": "ok"} @app.delete("/api/instances/{id}", dependencies=[Depends(is_authenticated)]) def delete_instance(id: int, request: Request): user_id = request.session.get("user_id") username = request.session.get("username") conn = get_db() c = conn.cursor() c.execute('SELECT name FROM instances WHERE id=? AND user_id=?', (id, user_id)) row = c.fetchone() if not row: conn.close() raise HTTPException(status_code=404, detail="Instance not found") inst_name = row[0] c.execute('DELETE FROM instances WHERE id=? AND user_id=?', (id, user_id)) conn.commit() conn.close() log_activity("INFO", f"User '{username}' deleted instance '{inst_name}'.") return {"status": "ok"} @app.get("/api/config") def get_config(): return {"firewall_host_ip": FIREWALL_HOST_IP} @app.get("/api/logs", dependencies=[Depends(is_authenticated)]) def get_logs(): conn = get_db() conn.row_factory = sqlite3.Row c = conn.cursor() c.execute('SELECT id, timestamp, level, message FROM activity_logs ORDER BY id DESC LIMIT 100') logs = [dict(row) for row in c.fetchall()] conn.close() return logs # Mount root and static @app.get("/") def read_root(): return FileResponse("static/index.html") app.mount("/static", StaticFiles(directory="static"), name="static")