517 lines
18 KiB
Python
517 lines
18 KiB
Python
import os
|
|
import time
|
|
import sqlite3
|
|
import asyncio
|
|
import pyotp
|
|
import httpx
|
|
import base64
|
|
import secrets
|
|
import urllib.parse
|
|
from typing import Optional
|
|
from fastapi import FastAPI, Request, Depends, HTTPException
|
|
from fastapi.responses import Response, FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
from passlib.context import CryptContext
|
|
from pydantic import BaseModel
|
|
from cryptography.fernet import Fernet
|
|
|
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
|
|
WEB_PASSWORD = os.getenv("WEB_PASSWORD", "admin")
|
|
ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY")
|
|
FIREWALL_HOST_IP = os.getenv("FIREWALL_HOST_IP")
|
|
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()
|
|
app.add_middleware(SessionMiddleware, secret_key=ENCRYPTION_KEY)
|
|
|
|
def get_password_hash(password):
|
|
# bcrypt has a 72-byte limit
|
|
return pwd_context.hash(password[:72])
|
|
|
|
def verify_password(plain_password, hashed_password):
|
|
return pwd_context.verify(plain_password[:72], hashed_password)
|
|
|
|
def get_db():
|
|
conn = sqlite3.connect(DB_PATH, timeout=20.0)
|
|
conn.execute("PRAGMA journal_mode=WAL;")
|
|
return conn
|
|
|
|
def init_db():
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
|
|
# Create users table
|
|
c.execute('''
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
hashed_password TEXT NOT NULL,
|
|
is_admin INTEGER DEFAULT 0
|
|
)
|
|
''')
|
|
|
|
c.execute('''
|
|
CREATE TABLE IF NOT EXISTS otp_secrets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
encrypted_secret TEXT NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
|
)
|
|
''')
|
|
|
|
# Create instances table first if it doesn't exist
|
|
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,
|
|
otp_secret_id INTEGER,
|
|
last_status TEXT DEFAULT 'Unknown',
|
|
last_checked REAL DEFAULT 0,
|
|
user_id INTEGER,
|
|
FOREIGN KEY (user_id) REFERENCES users (id),
|
|
FOREIGN KEY (otp_secret_id) REFERENCES otp_secrets (id)
|
|
)
|
|
''')
|
|
|
|
# Now check for column migrations
|
|
c.execute("PRAGMA table_info(instances)")
|
|
columns = [col[1] for col in c.fetchall()]
|
|
|
|
if "user_id" not in columns:
|
|
print("Migrating instances table to include user_id...")
|
|
c.execute('ALTER TABLE instances ADD COLUMN user_id INTEGER REFERENCES users(id)')
|
|
|
|
if "otp_secret_id" not in columns:
|
|
print("Migrating instances table to include otp_secret_id...")
|
|
c.execute('ALTER TABLE instances ADD COLUMN otp_secret_id INTEGER REFERENCES otp_secrets(id)')
|
|
|
|
# Migration: Move existing encrypted_secret to otp_secrets table
|
|
c.execute('SELECT id, name, encrypted_secret, user_id FROM instances WHERE encrypted_secret IS NOT NULL')
|
|
instances_to_migrate = c.fetchall()
|
|
for inst_id, inst_name, secret, user_id in instances_to_migrate:
|
|
if secret and user_id:
|
|
# Create a new OTP secret entry
|
|
secret_name = f"Migrated ({inst_name})"
|
|
c.execute('INSERT INTO otp_secrets (name, encrypted_secret, user_id) VALUES (?, ?, ?)',
|
|
(secret_name, secret, user_id))
|
|
otp_secret_id = c.lastrowid
|
|
# Update instance to point to the new OTP secret
|
|
c.execute('UPDATE instances SET otp_secret_id = ? WHERE id = ?', (otp_secret_id, inst_id))
|
|
|
|
# Create or update default admin if no users exist or only one user exists
|
|
c.execute('SELECT COUNT(*) FROM users')
|
|
count = c.fetchone()[0]
|
|
if count == 0:
|
|
print("Creating default admin user...")
|
|
admin_hash = get_password_hash(WEB_PASSWORD)
|
|
c.execute('INSERT INTO users (username, hashed_password, is_admin) VALUES (?, ?, ?)',
|
|
('admin', admin_hash, 1))
|
|
elif count == 1:
|
|
# If there's only one user and it's 'admin', ensure its password matches WEB_PASSWORD
|
|
c.execute('SELECT id, username FROM users LIMIT 1')
|
|
user = c.fetchone()
|
|
if user[1] == 'admin':
|
|
print("Ensuring admin password matches WEB_PASSWORD...")
|
|
admin_hash = get_password_hash(WEB_PASSWORD)
|
|
c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (admin_hash, user[0]))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
init_db()
|
|
|
|
def is_authenticated(request: Request):
|
|
if not request.session.get("user_id"):
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
return True
|
|
|
|
def is_admin(request: Request):
|
|
if not request.session.get("is_admin"):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return True
|
|
|
|
@app.post("/api/login")
|
|
async def login(request: Request):
|
|
data = await request.json()
|
|
username = data.get("username")
|
|
password = data.get("password")
|
|
|
|
conn = get_db()
|
|
conn.row_factory = sqlite3.Row
|
|
c = conn.cursor()
|
|
c.execute('SELECT * FROM users WHERE username = ?', (username,))
|
|
user = c.fetchone()
|
|
conn.close()
|
|
|
|
if user and verify_password(password, user['hashed_password']):
|
|
request.session["user_id"] = user['id']
|
|
request.session["username"] = user['username']
|
|
request.session["is_admin"] = bool(user['is_admin'])
|
|
return {"status": "ok", "user": {"username": user['username'], "is_admin": bool(user['is_admin'])}}
|
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
|
|
@app.get("/api/logout")
|
|
async def logout(request: Request):
|
|
request.session.clear()
|
|
return {"status": "ok"}
|
|
|
|
@app.get("/api/auth/status")
|
|
async def auth_status(request: Request):
|
|
if request.session.get("user_id"):
|
|
return {
|
|
"authenticated": True,
|
|
"user": {
|
|
"username": request.session.get("username"),
|
|
"is_admin": request.session.get("is_admin")
|
|
}
|
|
}
|
|
return {"authenticated": False}
|
|
|
|
# User Management (Admin Only)
|
|
@app.get("/api/users", dependencies=[Depends(is_authenticated), Depends(is_admin)])
|
|
def get_users():
|
|
conn = get_db()
|
|
conn.row_factory = sqlite3.Row
|
|
c = conn.cursor()
|
|
c.execute('SELECT id, username, is_admin FROM users')
|
|
users = [dict(row) for row in c.fetchall()]
|
|
conn.close()
|
|
return users
|
|
|
|
@app.post("/api/users", dependencies=[Depends(is_authenticated), Depends(is_admin)])
|
|
async def create_user(request: Request):
|
|
data = await request.json()
|
|
username = data.get("username")
|
|
password = data.get("password")
|
|
is_admin = 1 if data.get("is_admin") else 0
|
|
|
|
if not username or not password:
|
|
raise HTTPException(status_code=400, detail="Username and password required")
|
|
|
|
hashed = get_password_hash(password)
|
|
try:
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
c.execute('INSERT INTO users (username, hashed_password, is_admin) VALUES (?, ?, ?)',
|
|
(username, hashed, is_admin))
|
|
conn.commit()
|
|
conn.close()
|
|
except sqlite3.IntegrityError:
|
|
raise HTTPException(status_code=400, detail="Username already exists")
|
|
return {"status": "ok"}
|
|
|
|
@app.delete("/api/users/{id}", dependencies=[Depends(is_authenticated), Depends(is_admin)])
|
|
def delete_user(id: int, request: Request):
|
|
if id == request.session.get("user_id"):
|
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
c.execute('DELETE FROM users WHERE id=?', (id,))
|
|
# Also delete their instances? Let's say yes for cleanliness.
|
|
c.execute('DELETE FROM instances WHERE user_id=?', (id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"status": "ok"}
|
|
|
|
@app.put("/api/users/{id}/password", dependencies=[Depends(is_authenticated), Depends(is_admin)])
|
|
async def admin_reset_password(id: int, request: Request):
|
|
data = await request.json()
|
|
new_password = data.get("password")
|
|
if not new_password:
|
|
raise HTTPException(status_code=400, detail="New password required")
|
|
hashed = get_password_hash(new_password)
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, id))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"status": "ok"}
|
|
|
|
# Own Password Change (All Users)
|
|
@app.put("/api/users/me/password", dependencies=[Depends(is_authenticated)])
|
|
async def change_own_password(request: Request):
|
|
data = await request.json()
|
|
new_password = data.get("password")
|
|
if not new_password:
|
|
raise HTTPException(status_code=400, detail="New password required")
|
|
user_id = request.session.get("user_id")
|
|
hashed = get_password_hash(new_password)
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, user_id))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"status": "ok"}
|
|
|
|
@app.put("/api/users/me/username", dependencies=[Depends(is_authenticated)])
|
|
async def change_own_username(request: Request):
|
|
data = await request.json()
|
|
new_username = data.get("username")
|
|
if not new_username:
|
|
raise HTTPException(status_code=400, detail="New username required")
|
|
user_id = request.session.get("user_id")
|
|
try:
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
c.execute('UPDATE users SET username = ? WHERE id = ?', (new_username, user_id))
|
|
conn.commit()
|
|
conn.close()
|
|
except sqlite3.IntegrityError:
|
|
raise HTTPException(status_code=400, detail="Username already exists")
|
|
return {"status": "ok"}
|
|
|
|
class OTPSecretCreate(BaseModel):
|
|
name: str
|
|
secret: str
|
|
|
|
class OTPSecretUpdate(BaseModel):
|
|
name: str
|
|
secret: Optional[str] = None
|
|
|
|
class InstanceCreate(BaseModel):
|
|
name: str
|
|
ip: str
|
|
port: int = 4646
|
|
otp_secret_id: int
|
|
|
|
class InstanceUpdate(BaseModel):
|
|
name: str
|
|
ip: str
|
|
port: int = 4646
|
|
otp_secret_id: int
|
|
|
|
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()
|
|
|
|
# OTP Secret Management
|
|
@app.get("/api/otp-secrets", dependencies=[Depends(is_authenticated)])
|
|
def get_otp_secrets(request: Request):
|
|
user_id = request.session.get("user_id")
|
|
conn = get_db()
|
|
conn.row_factory = sqlite3.Row
|
|
c = conn.cursor()
|
|
c.execute('SELECT id, name FROM otp_secrets WHERE user_id = ?', (user_id,))
|
|
secrets = [dict(row) for row in c.fetchall()]
|
|
conn.close()
|
|
return secrets
|
|
|
|
@app.post("/api/otp-secrets", dependencies=[Depends(is_authenticated)])
|
|
def create_otp_secret(secret_data: OTPSecretCreate, request: Request):
|
|
user_id = request.session.get("user_id")
|
|
cleaned = clean_secret(secret_data.secret)
|
|
encrypted = fernet.encrypt(cleaned.encode()).decode()
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
c.execute('''
|
|
INSERT INTO otp_secrets (name, encrypted_secret, user_id)
|
|
VALUES (?, ?, ?)
|
|
''', (secret_data.name, encrypted, user_id))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"status": "ok"}
|
|
|
|
@app.put("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)])
|
|
def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request):
|
|
user_id = request.session.get("user_id")
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
|
|
# Verify ownership
|
|
c.execute('SELECT id FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
|
|
if not c.fetchone():
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="OTP Secret not found")
|
|
|
|
if secret_data.secret:
|
|
cleaned = clean_secret(secret_data.secret)
|
|
encrypted = fernet.encrypt(cleaned.encode()).decode()
|
|
c.execute('''
|
|
UPDATE otp_secrets SET name=?, encrypted_secret=?
|
|
WHERE id=? AND user_id=?
|
|
''', (secret_data.name, encrypted, id, user_id))
|
|
else:
|
|
c.execute('''
|
|
UPDATE otp_secrets SET name=?
|
|
WHERE id=? AND user_id=?
|
|
''', (secret_data.name, id, user_id))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
return {"status": "ok"}
|
|
|
|
@app.delete("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)])
|
|
def delete_otp_secret(id: int, request: Request):
|
|
user_id = request.session.get("user_id")
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
|
|
# Check if used by any instances
|
|
c.execute('SELECT COUNT(*) FROM instances WHERE otp_secret_id=?', (id,))
|
|
if c.fetchone()[0] > 0:
|
|
conn.close()
|
|
raise HTTPException(status_code=400, detail="Cannot delete secret because it is in use by one or more instances")
|
|
|
|
c.execute('DELETE FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"status": "ok"}
|
|
|
|
async def poll_instances():
|
|
while True:
|
|
try:
|
|
conn = get_db()
|
|
conn.row_factory = sqlite3.Row
|
|
c = conn.cursor()
|
|
c.execute('''
|
|
SELECT i.*, s.encrypted_secret
|
|
FROM instances i
|
|
JOIN otp_secrets s ON i.otp_secret_id = s.id
|
|
''')
|
|
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 = get_db()
|
|
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", dependencies=[Depends(is_authenticated)])
|
|
def get_instances(request: Request):
|
|
user_id = request.session.get("user_id")
|
|
conn = get_db()
|
|
conn.row_factory = sqlite3.Row
|
|
c = conn.cursor()
|
|
c.execute('''
|
|
SELECT i.id, i.name, i.ip, i.port, i.last_status, i.last_checked, i.otp_secret_id, s.name as otp_secret_name
|
|
FROM instances i
|
|
LEFT JOIN otp_secrets s ON i.otp_secret_id = s.id
|
|
WHERE i.user_id = ?
|
|
''', (user_id,))
|
|
instances = [dict(row) for row in c.fetchall()]
|
|
conn.close()
|
|
return instances
|
|
|
|
@app.post("/api/instances", dependencies=[Depends(is_authenticated)])
|
|
def create_instance(inst: InstanceCreate, request: Request):
|
|
user_id = request.session.get("user_id")
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
c.execute('''
|
|
INSERT INTO instances (name, ip, port, otp_secret_id, user_id, encrypted_secret)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, user_id, ""))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"status": "ok"}
|
|
|
|
@app.put("/api/instances/{id}", dependencies=[Depends(is_authenticated)])
|
|
def update_instance(id: int, inst: InstanceUpdate, request: Request):
|
|
user_id = request.session.get("user_id")
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
|
|
# Verify ownership
|
|
c.execute('SELECT id FROM instances WHERE id=? AND user_id=?', (id, user_id))
|
|
if not c.fetchone():
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Instance not found")
|
|
|
|
c.execute('''
|
|
UPDATE instances SET name=?, ip=?, port=?, otp_secret_id=?
|
|
WHERE id=? AND user_id=?
|
|
''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, id, user_id))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
return {"status": "ok"}
|
|
|
|
@app.get("/api/config", dependencies=[Depends(is_authenticated)])
|
|
def get_config():
|
|
return {"firewall_host_ip": FIREWALL_HOST_IP}
|
|
|
|
@app.delete("/api/instances/{id}", dependencies=[Depends(is_authenticated)])
|
|
def delete_instance(id: int, request: Request):
|
|
user_id = request.session.get("user_id")
|
|
conn = get_db()
|
|
c = conn.cursor()
|
|
c.execute('DELETE FROM instances WHERE id=? AND user_id=?', (id, user_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")
|