initialize XIVLauncher Remote OTP project with web interface and Docker support

This commit is contained in:
CPTN Cosmo 2026-04-18 16:21:51 +02:00
commit 6ce356ffed
8 changed files with 717 additions and 0 deletions

183
main.py Normal file
View file

@ -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")