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

15
Dockerfile Normal file
View file

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

37
README.md Normal file
View file

@ -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.

15
docker-compose.yml Normal file
View file

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

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

6
requirements.txt Normal file
View file

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

117
static/app.js Normal file
View file

@ -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 = '<div class="empty-state">No instances configured yet.</div>';
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 `
<div class="instance-item">
<div class="instance-info">
<h3>${escapeHtml(inst.name)}</h3>
<div class="instance-meta">
<span>${escapeHtml(inst.ip)}:${inst.port}</span>
<span class="status-badge ${statusClass}">${escapeHtml(inst.last_status)}</span>
<span>Last checked: ${timeAgo}</span>
</div>
</div>
<button class="delete-btn" onclick="deleteInstance(${inst.id})" title="Delete">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
`;
}).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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
fetchInstances();
setInterval(fetchInstances, 5000);
});

79
static/index.html Normal file
View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XIVLauncher Remote OTP</title>
<link rel="stylesheet" href="/static/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<header>
<div class="logo">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
<h1>XIVLauncher Remote OTP</h1>
</div>
</header>
<main>
<div class="card add-instance-card">
<h2>Add Instance</h2>
<form id="add-form">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" placeholder="e.g. Steam Deck" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="ip">IP Address</label>
<input type="text" id="ip" placeholder="192.168.1.100" required>
</div>
<div class="form-group">
<label for="port">Port</label>
<input type="number" id="port" value="4646" required>
</div>
</div>
<div class="form-group">
<label for="secret">OTP Secret</label>
<input type="password" id="secret" placeholder="Base32 Secret or otpauth:// URL" required>
</div>
<button type="submit" class="btn btn-primary">Add Instance</button>
</form>
</div>
<div class="card firewall-helper-card">
<h2>Firewall Helper</h2>
<p class="helper-text">Run these commands on the machine running XIVLauncher to allow incoming connections on the specified port.</p>
<div class="code-block">
<span class="code-label">UFW</span>
<code id="ufw-cmd">sudo ufw allow 4646/tcp</code>
</div>
<div class="code-block">
<span class="code-label">iptables</span>
<code id="iptables-cmd">sudo iptables -I INPUT -p tcp --dport 4646 -j ACCEPT</code>
</div>
</div>
<div class="card instances-card">
<div class="instances-header">
<h2>Managed Instances</h2>
<button class="btn btn-icon" id="refresh-btn" title="Refresh">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</button>
</div>
<div id="instances-list" class="instances-list">
<!-- Instances injected via JS -->
</div>
</div>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>

265
static/style.css Normal file
View file

@ -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;
}