183 lines
6.4 KiB
Python
183 lines
6.4 KiB
Python
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")
|