import os import time import sqlite3 import asyncio import pyotp import httpx import base64 import secrets import urllib.parse from fastapi import FastAPI, Request from fastapi.responses import Response, FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from cryptography.fernet import Fernet WEB_PASSWORD = os.getenv("WEB_PASSWORD", "admin") ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY") 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() def init_db(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() 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 NOT NULL, last_status TEXT DEFAULT 'Unknown', last_checked REAL DEFAULT 0 ) ''') conn.commit() conn.close() init_db() @app.middleware("http") async def basic_auth_middleware(request: Request, call_next): if request.url.path.startswith("/api/") or request.url.path == "/" or request.url.path.startswith("/static/"): auth = request.headers.get("Authorization") if not auth or not auth.startswith("Basic "): return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized") try: decoded = base64.b64decode(auth[6:]).decode() username, password = decoded.split(":", 1) # Accept any username, just check password if not secrets.compare_digest(password, WEB_PASSWORD): return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized") except: return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized") return await call_next(request) class InstanceCreate(BaseModel): name: str ip: str port: int = 4646 secret: str 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() async def poll_instances(): while True: try: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() c.execute('SELECT * FROM instances') 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" else: status_msg = f"Failed: {resp.status_code}" except (httpx.ConnectError, httpx.TimeoutException): status_msg = "Offline" except Exception as e: status_msg = f"Error: {str(e)}" conn = sqlite3.connect(DB_PATH) 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") def get_instances(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() c.execute('SELECT id, name, ip, port, last_status, last_checked FROM instances') instances = [dict(row) for row in c.fetchall()] conn.close() return instances @app.post("/api/instances") def create_instance(inst: InstanceCreate): cleaned = clean_secret(inst.secret) encrypted = fernet.encrypt(cleaned.encode()).decode() conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(''' INSERT INTO instances (name, ip, port, encrypted_secret) VALUES (?, ?, ?, ?) ''', (inst.name, inst.ip, inst.port, encrypted)) conn.commit() conn.close() return {"status": "ok"} @app.delete("/api/instances/{id}") def delete_instance(id: int): conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute('DELETE FROM instances WHERE id=?', (id,)) conn.commit() conn.close() return {"status": "ok"} # Mount root and static @app.get("/") def read_root(): return FileResponse("static/index.html") app.mount("/static", StaticFiles(directory="static"), name="static")