initialize XIVLauncher Remote OTP project with web interface and Docker support
This commit is contained in:
commit
6ce356ffed
8 changed files with 717 additions and 0 deletions
183
main.py
Normal file
183
main.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue