From 6ce356ffed48af3f14d41e44cfcd4a0032a2086b Mon Sep 17 00:00:00 2001 From: cosmo Date: Sat, 18 Apr 2026 16:21:51 +0200 Subject: [PATCH] initialize XIVLauncher Remote OTP project with web interface and Docker support --- Dockerfile | 15 +++ README.md | 37 +++++++ docker-compose.yml | 15 +++ main.py | 183 +++++++++++++++++++++++++++++++ requirements.txt | 6 + static/app.js | 117 ++++++++++++++++++++ static/index.html | 79 ++++++++++++++ static/style.css | 265 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 717 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/style.css diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..07eb478 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Ensure data directory exists +RUN mkdir -p /app/data + +EXPOSE 8080 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e30582c --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# XIVLauncher Remote OTP + +A lightweight web application running in a minimal Docker container to automatically inject OTP codes into remote XIVLauncher instances. + +## How it works + +XIVLauncher can expose an internal OTP server that listens for incoming HTTP requests containing your TOTP code. +This application acts as a central manager. You can add the IP address, Port (default 4646), and your OTP secret. The application will securely store the secret and continuously poll your remote XIVLauncher instances. When the instance starts and the port becomes available, the application automatically generates your current OTP and injects it. + +## Features +- **Password Protected**: The entire web interface is protected by basic authentication. +- **Secure Storage**: OTP Secrets are stored in a local SQLite database encrypted with a Fernet key (`ENCRYPTION_KEY`). +- **Minimal Footprint**: Built using Python FastAPI, using only the necessary dependencies, and runs in a lightweight container. +- **Background Polling**: Checks instances continuously without blocking. + +## Setup & Running + +1. Clone or copy this directory. +2. Edit `docker-compose.yml`: + - Change `WEB_PASSWORD` to your desired password for the web interface (the username can be anything, e.g., `admin`). + - Change `ENCRYPTION_KEY` to a securely generated Fernet key. + *You can generate one by running: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`* +3. Start the container: + ```bash + docker-compose up -d + ``` +4. Access the web interface at `http://localhost:8080`. +5. Login using any username and your `WEB_PASSWORD`. + +## Adding Instances +- **Name**: A friendly name (e.g., "Steam Deck"). +- **IP Address**: The local IP address of your XIVLauncher instance. +- **Port**: Default is 4646. +- **OTP Secret**: Your base32 OTP secret or an `otpauth://` URL. + +## Important Note +If you change your `ENCRYPTION_KEY`, the application will no longer be able to decrypt your previously saved OTP secrets. Ensure you backup your key if you intend to keep your configuration across deployments. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ad2584 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.8' + +services: + remote-otp: + build: . + container_name: xivlauncher-remote-otp + ports: + - "8080:8080" + volumes: + - ./data:/app/data + environment: + - WEB_PASSWORD=admin + # Generate a secure key using: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + - ENCRYPTION_KEY=CHANGE_ME_TO_A_VALID_FERNET_KEY + restart: unless-stopped diff --git a/main.py b/main.py new file mode 100644 index 0000000..266905a --- /dev/null +++ b/main.py @@ -0,0 +1,183 @@ +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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7fa07f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +cryptography==41.0.5 +pyotp==2.9.0 +httpx==0.25.1 +pydantic==2.4.2 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..ee05f2b --- /dev/null +++ b/static/app.js @@ -0,0 +1,117 @@ +document.addEventListener('DOMContentLoaded', () => { + const addForm = document.getElementById('add-form'); + const instancesList = document.getElementById('instances-list'); + const refreshBtn = document.getElementById('refresh-btn'); + const portInput = document.getElementById('port'); + const ufwCmd = document.getElementById('ufw-cmd'); + const iptablesCmd = document.getElementById('iptables-cmd'); + + portInput.addEventListener('input', (e) => { + const port = e.target.value || '4646'; + ufwCmd.textContent = `sudo ufw allow ${port}/tcp`; + iptablesCmd.textContent = `sudo iptables -I INPUT -p tcp --dport ${port} -j ACCEPT`; + }); + + const fetchInstances = async () => { + try { + const res = await fetch('/api/instances'); + if (res.status === 401) { + return; // Let browser handle auth + } + const data = await res.json(); + renderInstances(data); + } catch (e) { + console.error('Failed to fetch instances:', e); + } + }; + + const renderInstances = (instances) => { + if (instances.length === 0) { + instancesList.innerHTML = '
No instances configured yet.
'; + return; + } + + instancesList.innerHTML = instances.map(inst => { + let statusClass = 'status-unknown'; + if (inst.last_status === 'Sent') statusClass = 'status-sent'; + else if (inst.last_status === 'Offline') statusClass = 'status-offline'; + else if (inst.last_status.startsWith('Failed')) statusClass = 'status-offline'; + + const timeAgo = inst.last_checked ? Math.round((Date.now()/1000 - inst.last_checked)) + 's ago' : 'Never'; + + return ` +
+
+

${escapeHtml(inst.name)}

+
+ ${escapeHtml(inst.ip)}:${inst.port} + ${escapeHtml(inst.last_status)} + Last checked: ${timeAgo} +
+
+ +
+ `; + }).join(''); + }; + + addForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const name = document.getElementById('name').value; + const ip = document.getElementById('ip').value; + const port = parseInt(document.getElementById('port').value, 10); + const secret = document.getElementById('secret').value; + + try { + const res = await fetch('/api/instances', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name, ip, port, secret }) + }); + + if (res.ok) { + addForm.reset(); + document.getElementById('port').value = "4646"; + fetchInstances(); + } else { + alert('Failed to add instance'); + } + } catch (e) { + console.error('Error adding instance', e); + } + }); + + refreshBtn.addEventListener('click', fetchInstances); + + window.deleteInstance = async (id) => { + if (!confirm('Are you sure you want to delete this instance?')) return; + try { + const res = await fetch(`/api/instances/${id}`, { method: 'DELETE' }); + if (res.ok) { + fetchInstances(); + } + } catch (e) { + console.error('Error deleting instance', e); + } + }; + + function escapeHtml(unsafe) { + if (!unsafe) return ''; + return unsafe.toString() + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + fetchInstances(); + setInterval(fetchInstances, 5000); +}); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..f00e2ed --- /dev/null +++ b/static/index.html @@ -0,0 +1,79 @@ + + + + + + XIVLauncher Remote OTP + + + + +
+
+ +
+ +
+
+

Add Instance

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ +
+

Firewall Helper

+

Run these commands on the machine running XIVLauncher to allow incoming connections on the specified port.

+
+ UFW + sudo ufw allow 4646/tcp +
+
+ iptables + sudo iptables -I INPUT -p tcp --dport 4646 -j ACCEPT +
+
+ +
+
+

Managed Instances

+ +
+
+ +
+
+
+
+ + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6669adb --- /dev/null +++ b/static/style.css @@ -0,0 +1,265 @@ +:root { + --bg-color: #0f172a; + --card-bg: #1e293b; + --primary: #3b82f6; + --primary-hover: #2563eb; + --text-main: #f8fafc; + --text-muted: #94a3b8; + --border-color: #334155; + --status-online: #10b981; + --status-offline: #ef4444; + --status-sent: #3b82f6; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Inter', sans-serif; + background-color: var(--bg-color); + color: var(--text-main); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +header { + margin-bottom: 2rem; +} + +.logo { + display: flex; + align-items: center; + gap: 1rem; +} + +.logo svg { + color: var(--primary); +} + +.logo h1 { + font-size: 1.5rem; + font-weight: 600; +} + +.card { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.card h2 { + font-size: 1.25rem; + margin-bottom: 1.5rem; + font-weight: 600; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-row { + display: flex; + gap: 1rem; +} + +.form-row .form-group { + flex: 1; +} + +label { + display: block; + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 0.5rem; + font-weight: 500; +} + +input { + width: 100%; + background-color: var(--bg-color); + border: 1px solid var(--border-color); + color: var(--text-main); + padding: 0.75rem 1rem; + border-radius: 8px; + font-family: inherit; + font-size: 1rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + font-weight: 500; + border-radius: 8px; + cursor: pointer; + border: none; + transition: background-color 0.2s, transform 0.1s; + font-family: inherit; + font-size: 1rem; +} + +.btn-primary { + background-color: var(--primary); + color: white; + width: 100%; + margin-top: 0.5rem; +} + +.btn-primary:hover { + background-color: var(--primary-hover); +} + +.btn-primary:active { + transform: translateY(1px); +} + +.btn-icon { + padding: 0.5rem; + background: transparent; + color: var(--text-muted); + border-radius: 6px; +} + +.btn-icon:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--text-main); +} + +.instances-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.instances-header h2 { + margin-bottom: 0; +} + +.instances-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.instance-item { + background-color: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + transition: transform 0.2s; +} + +.instance-item:hover { + transform: translateX(4px); + border-color: rgba(255, 255, 255, 0.2); +} + +.instance-info h3 { + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.instance-meta { + font-size: 0.875rem; + color: var(--text-muted); + display: flex; + gap: 1rem; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.5rem; + border-radius: 999px; + background-color: rgba(255, 255, 255, 0.1); +} + +.status-badge::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-badge.status-offline::before { background-color: var(--status-offline); } +.status-badge.status-sent::before { background-color: var(--status-sent); } +.status-badge.status-unknown::before { background-color: var(--text-muted); } + +.delete-btn { + color: var(--status-offline); + background: transparent; + border: none; + padding: 0.5rem; + cursor: pointer; + border-radius: 6px; + transition: background-color 0.2s; +} + +.delete-btn:hover { + background-color: rgba(239, 68, 68, 0.1); +} + +.empty-state { + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +.helper-text { + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.code-block { + background-color: #0f172a; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 0.75rem; + position: relative; + font-family: monospace; + font-size: 0.875rem; + display: flex; + flex-direction: column; +} + +.code-label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + font-weight: 700; + margin-bottom: 0.25rem; + font-family: 'Inter', sans-serif; +} + +.code-block code { + color: #34d399; +}