furtka/furtka/api.py
Daniel Maksymilian Syrnicki 9f9e627678 fix(install,catalog): recover from interrupted installs, don't strand sync on network/disk errors
- an unfinished install-state.json whose runner is gone (free lock, >60 s
  old — reboot, OOM kill, runner never started) is marked 'error' instead
  of blocking every later install with 409 until someone fixes it over SSH
- read_state in install_runner/updater tolerates unreadable and non-object
  files
- Forgejo API + release downloads wrap OSError and HTTPException (socket
  timeouts, resets, disk full) — the update/catalog check endpoints used to
  drop the connection on a flaky network
- catalog sync records any failure as the 'error' stage, not just
  CatalogError

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
2026-09-28 13:15:10 +02:00

1644 lines
63 KiB
Python

# ruff: noqa: E501 — _HTML below is a literal HTML/CSS/JS payload; wrapping
# its lines hurts readability and the rendered output is what matters here.
"""Tiny HTTP API + management UI for the Furtka resource manager.
Single stdlib http.server process, served behind Caddy (reverse-proxies
/apps, /api, /login and /logout from :80 to here).
Security: single-admin password login, cookie-session, werkzeug-hashed
password stored at /var/lib/furtka/users.json (0600). Sessions live in
memory — `systemctl restart furtka-api` invalidates everyone. Fresh
installs pre-populate users.json from the webinstaller step-1 password;
upgrades from pre-auth releases fall into a first-run setup form at
/login where the admin password is created from the browser. Authentik
integration remains the long-term plan; this is the pragmatic alpha
stopgap.
"""
import json
import re
import threading
import time
import traceback
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources
from furtka.manifest import APP_NAME_RE, ManifestError, load_manifest
from furtka.paths import apps_dir, static_www_dir
from furtka.scanner import scan
_ICON_MAX_BYTES = 16 * 1024
_UNSAFE_SVG_PATTERNS = (
re.compile(r"<script", re.IGNORECASE),
re.compile(r"javascript:", re.IGNORECASE),
re.compile(r"\bon[a-z]+\s*=", re.IGNORECASE),
)
def _read_icon_svg(app_dir, icon_name):
"""Return an SVG string safe to inline into the /apps response, or None.
Inlined rather than served via a separate endpoint so the /apps page
renders in one round-trip (Doherty Threshold). The trust model: icons
come from bundled apps we ship in the ISO or from apps the operator
installed via the (auth-less-by-design) API — so the realistic threat
is a malformed file, not an attacker. Filter the obvious script /
event-handler vectors for defense in depth and let the browser render
the rest.
"""
if not app_dir or not icon_name:
return None
path = app_dir / icon_name
try:
if not path.is_file() or path.stat().st_size > _ICON_MAX_BYTES:
return None
data = path.read_text(encoding="utf-8")
except OSError:
return None
data = data.strip()
if data.startswith("<?xml"):
end = data.find("?>")
if end == -1:
return None
data = data[end + 2 :].lstrip()
if not data.startswith("<svg"):
return None
if any(p.search(data) for p in _UNSAFE_SVG_PATTERNS):
return None
return data
_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Furtka Apps</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main class="wrap">
<nav class="nav">
<a class="brand" href="/">Furtka</a>
<div class="nav-links">
<a href="/">Home</a>
<a href="/apps" aria-current="page">Apps</a>
<a href="/settings/">Settings</a>
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
</div>
</nav>
<h1>Furtka Apps</h1>
<p class="lede">Install or remove resource-manager apps on this Furtka box.</p>
<h2>Installed</h2>
<div id="installed"></div>
<h2>Available to install</h2>
<div class="catalog-row">
<p class="catalog-state">Catalog version <span id="catalog-current">—</span> · last sync <span id="catalog-last-sync">never</span> <span id="catalog-stage" class="catalog-stage"></span></p>
<button type="button" class="secondary" id="catalog-sync-btn">Sync apps catalog</button>
</div>
<div id="available"></div>
<details class="log-details">
<summary>Last action</summary>
<pre id="log">(none yet)</pre>
</details>
</main>
<div id="modal-backdrop" class="modal-backdrop" role="dialog" aria-modal="true">
<div class="modal">
<h3 id="modal-title"></h3>
<div id="modal-long" class="long"></div>
<div id="modal-error" class="error"></div>
<form id="modal-form" onsubmit="return false;"></form>
<div class="modal-actions">
<button type="button" class="secondary" id="modal-cancel">Cancel</button>
<button type="button" id="modal-submit">Install</button>
</div>
</div>
</div>
<script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
async function doLogout(ev) {
ev.preventDefault();
try {
await fetch('/logout', { method: 'POST', credentials: 'same-origin' });
} catch (e) { /* best-effort — server may already be down */ }
window.location.href = '/login';
return false;
}
// Fallback when an app doesn't ship a parseable icon.svg. Simple
// stroked folder — currentColor so the tile's accent tint applies.
const FALLBACK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
function appIcon(a) {
// `a.icon_svg` is already sanitized server-side (see _read_icon_svg).
return `<div class="app-icon">${a.icon_svg || FALLBACK_ICON}</div>`;
}
const modal = {
backdrop: document.getElementById('modal-backdrop'),
title: document.getElementById('modal-title'),
long: document.getElementById('modal-long'),
form: document.getElementById('modal-form'),
error: document.getElementById('modal-error'),
submit: document.getElementById('modal-submit'),
cancel: document.getElementById('modal-cancel'),
// current: { name, action: 'install' | 'edit', confirmDeps?: bool }
// confirmDeps == true means the dependency-confirm step was already passed,
// so submitModal sends confirm_dependencies:true to the API.
current: null,
};
modal.cancel.addEventListener('click', () => closeModal());
modal.backdrop.addEventListener('click', (e) => { if (e.target === modal.backdrop) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
function closeModal() {
modal.backdrop.classList.remove('open');
modal.form.innerHTML = '';
modal.error.classList.remove('show');
modal.error.textContent = '';
modal.current = null;
}
async function openSettingsDialog(name, action, opts = {}) {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/settings`);
if (!r.ok) {
document.getElementById('log').textContent =
`[settings ${name}] HTTP ${r.status}\\n` + await r.text();
return;
}
const data = await r.json();
modal.current = { name, action, confirmDeps: !!opts.confirmDeps };
modal.title.textContent = data.display_name || data.name;
modal.long.textContent = data.description_long || data.description || '';
modal.long.style.display = modal.long.textContent ? '' : 'none';
modal.submit.textContent = action === 'install' ? 'Install' : 'Save and restart';
if (!data.settings.length) {
// No form fields — treat as simple confirm.
modal.form.innerHTML = '<p class="hint">No settings to configure.</p>';
} else {
modal.form.innerHTML = data.settings.map(s => {
const id = `field-${esc(s.name)}`;
const value = action === 'edit' && s.type === 'password' ? '' : esc(s.value || '');
const placeholder = action === 'edit' && s.type === 'password' ? 'Leave blank to keep current'
: s.type === 'path' ? '/mnt/…'
: '';
return `
<div class="field">
<label for="${id}">${esc(s.label)}${s.required ? '<span class="req">*</span>' : ''}</label>
${s.description ? `<div class="hint">${esc(s.description)}</div>` : ''}
<input
id="${id}"
name="${esc(s.name)}"
type="${s.type === 'password' ? 'password' : s.type === 'number' ? 'number' : 'text'}"
value="${value}"
placeholder="${esc(placeholder)}"
${s.required && action === 'install' ? 'required' : ''}
autocomplete="off"
spellcheck="false">
</div>`;
}).join('');
}
modal.backdrop.classList.add('open');
const first = modal.form.querySelector('input');
if (first) first.focus();
}
modal.submit.addEventListener('click', submitModal);
// Install progress phases written by the background job's state file.
// Mirrors furtka/install_runner.py stage strings. Unknown stages fall
// back to a neutral "Installing…" so a future phase rename doesn't
// leave the modal button blank.
const INSTALL_STAGE_LABELS = {
'pulling_image': 'Image wird heruntergeladen…',
'creating_volumes': 'Speicherbereiche werden erstellt…',
'running_hooks': 'Verknüpfungen werden eingerichtet…',
'starting_container': 'Container wird gestartet…',
'done': 'Fertig',
};
// Confirm dialog for transitive dependencies: "Installing X also installs
// Y, Z — proceed?". Reuses the existing modal CSS/structure; the submit
// button on confirm reopens openSettingsDialog with confirmDeps=true.
async function openDependencyConfirmDialog(name, plan) {
modal.current = { name, action: 'install-confirm-deps' };
modal.title.textContent = `Install ${name}: dependencies required`;
const transitive = plan.summaries.filter(s => s.name !== name && !s.installed);
const long = transitive.length === 1
? `Installing ${name} requires ${transitive[0].display_name || transitive[0].name}. It will be installed and configured automatically.`
: `Installing ${name} requires ${transitive.length} additional apps. They will be installed and configured automatically.`;
modal.long.textContent = long;
modal.long.style.display = '';
modal.form.innerHTML = '<ul class="dep-list">'
+ transitive.map(s => `<li><strong>${esc(s.display_name || s.name)}</strong>${s.description ? ' — ' + esc(s.description) : ''}</li>`).join('')
+ '</ul>';
modal.submit.textContent = 'Install all and continue';
modal.backdrop.classList.add('open');
}
async function pollInstallStatus(original) {
// Two-minute ceiling: Jellyfin over a slow DSL line can take ~90s
// just on the image pull. Beyond that something's stuck — the
// background job is still running in systemd, but the UI gives up
// on the modal and lets the user close it.
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
await new Promise(res => setTimeout(res, 1500));
let s = {};
try {
s = await fetch('/api/apps/install/status').then(r => r.json());
} catch (e) { /* transient; keep polling */ }
const stage = s.stage || '';
modal.submit.textContent = INSTALL_STAGE_LABELS[stage] || 'Installing…';
if (stage === 'done') {
closeModal();
await refresh();
return;
}
if (stage === 'error') {
modal.error.textContent = s.error || 'Install failed';
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
return;
}
}
// Timed out waiting for a terminal state — don't lie to the user.
modal.error.textContent = 'Installation is taking longer than expected. Check /settings for the background job status.';
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
}
async function submitModal() {
if (!modal.current) return;
const { name, action, confirmDeps } = modal.current;
// Two-step dance: when this is the dep-confirm step, transition straight
// into the settings dialog with confirmDeps flagged so the actual install
// POST carries `confirm_dependencies: true`.
if (action === 'install-confirm-deps') {
closeModal();
await openSettingsDialog(name, 'install', { confirmDeps: true });
return;
}
const values = {};
for (const input of modal.form.querySelectorAll('input')) {
// In edit mode, skip password fields left blank — server keeps existing.
if (action === 'edit' && input.type === 'password' && input.value === '') continue;
values[input.name] = input.value;
}
modal.submit.disabled = true;
const original = modal.submit.textContent;
modal.submit.textContent = action === 'install' ? 'Installing…' : 'Saving…';
modal.error.classList.remove('show');
try {
const url = action === 'install'
? '/api/apps/install'
: `/api/apps/${encodeURIComponent(name)}/settings`;
const body = action === 'install'
? { name, settings: values, ...(confirmDeps ? { confirm_dependencies: true } : {}) }
: { settings: values };
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await r.json();
document.getElementById('log').textContent =
`[${action} ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
if (!r.ok) {
modal.error.textContent = data.error || `HTTP ${r.status}`;
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
return;
}
// Install dispatched a background job — poll until terminal. The
// edit path stays synchronous (settings updates are fast: env write
// + reconcile, no image pull).
if (action === 'install' && r.status === 202) {
await pollInstallStatus(original);
return;
}
closeModal();
await refresh();
} catch (e) {
modal.error.textContent = `Network error: ${e.message}`;
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
}
}
async function refresh() {
const [installed, available] = await Promise.all([
fetch('/api/apps').then(r => r.json()),
fetch('/api/bundled').then(r => r.json()),
]);
document.getElementById('installed').innerHTML = installed.length
? installed.map(a => {
const hasSettings = a.has_settings;
const openHref = a.open_url ? a.open_url.replace('{host}', location.hostname) : '';
// Plain <a> rendered as a button so it behaves like a real link
// (middle-click, right-click "copy link", screen readers) instead
// of a JS onclick. Most installed apps will want this — fileshare
// deep-links to smb://, Kuma to http://host:3001/.
const openBtn = openHref
? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>`
: '';
return `
<div class="app">
<div class="left">
${appIcon(a)}
<div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || a.error || '')}</span>
</div>
</div>
<div class="buttons">
${openBtn}
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
<button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button>
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
</div>
</div>`;
}).join('')
: '<div class="empty">No apps installed yet.</div>';
document.getElementById('available').innerHTML = available.length
? available.map(a => `
<div class="app">
<div class="left">
${appIcon(a)}
<div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || '')}</span>
</div>
</div>
<button data-op="install" data-name="${esc(a.name)}">Install</button>
</div>`).join('')
: '<div class="empty">No bundled apps left to install.</div>';
for (const btn of document.querySelectorAll('button[data-op]')) {
btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn));
}
}
async function handleButton(op, name, btn) {
if (op === 'install') {
// Check dependencies before opening the settings form. If the target
// has transitive providers we need to install, show the confirm modal
// first; the user then proceeds to the regular settings form, which
// POSTs with confirm_dependencies:true.
try {
const r = await fetch('/api/apps/install/plan', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
});
const plan = await r.json();
if (!r.ok) {
document.getElementById('log').textContent =
`[install ${name}] HTTP ${r.status}\\n` + JSON.stringify(plan, null, 2);
return;
}
const transitive = (plan.to_install || []).filter(n => n !== name);
if (transitive.length > 0) {
openDependencyConfirmDialog(name, plan);
return;
}
} catch (e) {
// Network blip — fall through to the legacy single-app path; the API
// would reject again if deps actually were required.
}
openSettingsDialog(name, 'install');
return;
}
if (op === 'edit') {
openSettingsDialog(name, 'edit');
return;
}
// Reinstall + update + remove are direct actions, no form.
btn.disabled = true;
const original = btn.textContent;
const labels = { reinstall: 'Reinstalling…', update: 'Checking…', remove: 'Removing…' };
btn.textContent = labels[op] || 'Working…';
try {
const urls = {
reinstall: '/api/apps/install',
remove: '/api/apps/remove',
update: `/api/apps/${encodeURIComponent(name)}/update`,
};
const r = await fetch(urls[op], {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
});
const data = await r.json();
let header = `[${op} ${name}] HTTP ${r.status}`;
if (op === 'update' && r.ok) {
header += data.updated
? ` — updated ${data.services.length} service(s)`
: ' — already up to date';
}
if (op === 'remove' && r.status === 409 && data.dependents) {
header += ` — blocked by: ${data.dependents.join(', ')}`;
}
document.getElementById('log').textContent = header + '\\n' + JSON.stringify(data, null, 2);
// Reinstall dispatches an async install the same way the modal does
// — follow the background job on the button label until terminal.
if (op === 'reinstall' && r.status === 202) {
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
await new Promise(res => setTimeout(res, 1500));
let s = {};
try { s = await fetch('/api/apps/install/status').then(r => r.json()); } catch (e) {}
const stage = s.stage || '';
btn.textContent = INSTALL_STAGE_LABELS[stage] || 'Reinstalling…';
if (stage === 'done' || stage === 'error') break;
}
}
} catch (e) {
document.getElementById('log').textContent = `[${op} ${name}] network error: ${e.message}`;
}
btn.textContent = original;
btn.disabled = false;
await refresh();
}
async function refreshCatalog() {
let status;
try {
status = await fetch('/api/catalog/status').then(r => r.json());
} catch (e) {
return;
}
const cur = status.current || 'never synced';
document.getElementById('catalog-current').textContent = cur;
const stage = (status.state || {}).stage || '';
const updatedAt = (status.state || {}).updated_at || '';
document.getElementById('catalog-last-sync').textContent = updatedAt || 'never';
const stageEl = document.getElementById('catalog-stage');
if (stage === 'error') {
const err = (status.state || {}).error || 'unknown error';
stageEl.textContent = '· last sync failed: ' + err;
stageEl.classList.add('pending');
} else if (stage && stage !== 'done') {
stageEl.textContent = '· ' + stage + '…';
stageEl.classList.add('pending');
} else {
stageEl.textContent = '';
stageEl.classList.remove('pending');
}
}
const catalogBtn = document.getElementById('catalog-sync-btn');
catalogBtn.addEventListener('click', async () => {
catalogBtn.disabled = true;
const original = catalogBtn.textContent;
catalogBtn.textContent = 'Syncing…';
try {
const r = await fetch('/api/catalog/sync/apply', {method: 'POST'});
const data = await r.json();
document.getElementById('log').textContent = `[catalog sync] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
// Poll for completion — sync is fast (KB-range tarball) so 30 s is plenty.
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
await new Promise(res => setTimeout(res, 1500));
const s = await fetch('/api/catalog/status').then(r => r.json()).catch(() => null);
const stage = (s && s.state && s.state.stage) || '';
if (stage === 'done' || stage === 'error') break;
}
await refreshCatalog();
await refresh();
} catch (e) {
document.getElementById('log').textContent = `[catalog sync] network error: ${e.message}`;
}
catalogBtn.disabled = false;
catalogBtn.textContent = original;
});
refresh();
refreshCatalog();
</script>
</body>
</html>
"""
# Login / first-run setup page. Rendered standalone (no main-UI chrome) so
# an unauthenticated visitor never gets a glimpse of the app list. Reuses
# /style.css for the look — the page is just a form + optional error line.
# The template has a {{ SETUP }} marker the server flips on/off depending
# on whether users.json exists yet (first-run vs. normal login).
_HTML_LOGIN = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Furtka · {{ TITLE }}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main class="wrap login-wrap">
<h1>{{ HEADING }}</h1>
<p class="lede">{{ LEDE }}</p>
<form id="login-form" onsubmit="return doLogin(event)">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required value="{{ DEFAULT_USERNAME }}" autofocus>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="{{ PWD_AUTOCOMPLETE }}" required minlength="8">
</div>
{{ PASSWORD2_FIELD }}
<div id="login-error" class="error"></div>
<div class="actions">
<button type="submit" id="login-submit">{{ SUBMIT_LABEL }}</button>
</div>
</form>
</main>
<script>
const SETUP = {{ SETUP_JSON }};
const errBox = document.getElementById('login-error');
async function doLogin(ev) {
ev.preventDefault();
errBox.classList.remove('show');
errBox.textContent = '';
const btn = document.getElementById('login-submit');
btn.disabled = true;
const body = {
username: document.getElementById('username').value,
password: document.getElementById('password').value,
};
if (SETUP) body.password2 = document.getElementById('password2').value;
try {
const r = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (r.ok) {
window.location.href = '/apps';
return false;
}
const data = await r.json().catch(() => ({error: 'HTTP ' + r.status}));
errBox.textContent = data.error || 'Login failed';
errBox.classList.add('show');
} catch (e) {
errBox.textContent = 'Network error — is the box reachable?';
errBox.classList.add('show');
} finally {
btn.disabled = false;
}
return false;
}
</script>
</body>
</html>
"""
def _render_login_html(setup: bool, default_username: str = "") -> str:
if setup:
password2_field = (
'<div class="field"><label for="password2">Repeat password</label>'
'<input id="password2" name="password2" type="password" '
'autocomplete="new-password" required minlength="8"></div>'
)
subs = {
"TITLE": "First-run setup",
"HEADING": "Set admin password",
"LEDE": "No admin account exists yet on this box. Pick a username and password — you'll use them to sign in to the Furtka UI.",
"PWD_AUTOCOMPLETE": "new-password",
"PASSWORD2_FIELD": password2_field,
"SUBMIT_LABEL": "Create admin",
"DEFAULT_USERNAME": "admin",
"SETUP_JSON": "true",
}
else:
subs = {
"TITLE": "Login",
"HEADING": "Furtka login",
"LEDE": "Sign in with the admin credentials you set during install.",
"PWD_AUTOCOMPLETE": "current-password",
"PASSWORD2_FIELD": "",
"SUBMIT_LABEL": "Log in",
"DEFAULT_USERNAME": default_username,
"SETUP_JSON": "false",
}
out = _HTML_LOGIN
for key, val in subs.items():
out = out.replace("{{ " + key + " }}", val)
return out
# Minimum password length enforced server-side (browser also enforces it
# via the input's minlength, but don't rely on client-side only).
_MIN_PASSWORD_LEN = 8
def _manifest_summary(m, app_dir=None):
return {
"name": m.name,
"display_name": m.display_name,
"version": m.version,
"description": m.description,
"description_long": m.description_long,
"ports": list(m.ports),
"icon": m.icon,
"icon_svg": _read_icon_svg(app_dir, m.icon),
"has_settings": bool(m.settings),
# Optional template URL with `{host}` placeholder; frontend
# substitutes against location.hostname at render time.
"open_url": m.open_url,
"requires": [
{"app": req.app, "on_install": req.on_install, "on_start": req.on_start}
for req in m.requires
],
}
def _list_installed():
out = []
for r in scan(apps_dir()):
if r.ok:
d = _manifest_summary(r.manifest, r.path)
d["ok"] = True
out.append(d)
else:
out.append({"name": r.path.name, "ok": False, "error": r.error})
return out
def _list_available():
"""Apps available to install — catalog union bundled, catalog wins on collision.
Each entry carries a `"source"` field (`"catalog"` | `"bundled"`) so the
UI can visually differentiate later. Already-installed apps are filtered
out so the UI shows them only in the installed list.
"""
installed_names = {r.path.name for r in scan(apps_dir()) if r.ok}
out = []
for app_source in sources.list_available():
if app_source.path.name in installed_names:
continue
manifest_path = app_source.path / "manifest.json"
try:
m = load_manifest(manifest_path)
except ManifestError:
continue
summary = _manifest_summary(m, app_source.path)
summary["source"] = app_source.origin
out.append(summary)
return out
def _load_manifest_for(name):
"""Return (manifest, env_values, installed_bool) for an installed or bundled/catalog app.
Returns (None, None, False) if the name doesn't resolve anywhere.
"""
target = apps_dir() / name
if target.exists() and (target / "manifest.json").exists():
try:
m = load_manifest(target / "manifest.json")
except ManifestError:
return None, None, False
values = installer.read_env_values(target / ".env")
return m, values, True
resolved = sources.resolve_app_name(name)
if resolved is not None:
try:
m = load_manifest(resolved.path / "manifest.json")
except ManifestError:
return None, None, False
env_example = resolved.path / ".env.example"
values = installer.read_env_values(env_example) if env_example.exists() else {}
return m, values, False
return None, None, False
def _do_get_settings(name):
m, values, installed = _load_manifest_for(name)
if m is None:
return 404, {"error": f"{name!r} not found"}
settings_out = []
for s in m.settings:
# Never return password values back to the client — user either keeps
# the current value (blank input means "don't change") or types a new one.
if s.type == "password":
current = ""
else:
current = values.get(s.name, s.default if s.default is not None else "")
settings_out.append(
{
"name": s.name,
"label": s.label,
"description": s.description,
"type": s.type,
"required": s.required,
"default": s.default,
"value": current,
}
)
return 200, {
"name": m.name,
"display_name": m.display_name,
"description": m.description,
"description_long": m.description_long,
"installed": installed,
"settings": settings_out,
}
_INSTALL_TERMINAL_STAGES = install_runner.TERMINAL_STAGES
def _do_install_plan(name):
"""Compute a dependency plan for installing `name`.
Read-only — no FS mutation, doesn't take the install lock. The UI calls
this before opening the settings dialog so it can show a "this will also
install X, Y" confirm step when the target has transitive deps.
"""
try:
plan = deps.plan_install(name)
except deps.DependencyError as e:
return 400, {"error": str(e)}
summaries: list[dict] = []
for app_name in plan.install_order:
m, _values, installed = _load_manifest_for(app_name)
if m is None:
return 400, {"error": f"could not load manifest for {app_name!r}"}
# Per-entry source folder for icon resolution — installed wins.
if installed:
src_dir = apps_dir() / app_name
else:
resolved = sources.resolve_app_name(app_name)
src_dir = resolved.path if resolved is not None else None
summary = _manifest_summary(m, src_dir)
summary["installed"] = installed
summaries.append(summary)
return 200, {
"target": plan.target,
"install_order": list(plan.install_order),
"already_installed": sorted(plan.already_installed),
"to_install": list(plan.to_install),
"summaries": summaries,
}
def _do_install(name, settings=None, confirm_dependencies=False):
"""Kick off an app install. Synchronous sync-phase + async docker-phase.
Fast parts run inline so validation failures come back as immediate
4xx (bad path, placeholder secret, unknown app, etc.). The slow
`docker compose pull` then `compose up` are dispatched as a
background systemd-run unit that writes phase transitions to
/var/lib/furtka/install-state.json for the UI to poll.
"""
import subprocess
# Reject if the state file reports a non-terminal install. The
# fcntl lock below catches the same race, but only *after* the API
# releases it to let the systemd-run child grab it — a competing
# POST can sneak in during that tiny window. Reading the state
# first closes that gap: as long as a previous install hasn't
# written "done" or "error", we refuse.
install_runner.clear_stale_state()
current_state = install_runner.read_state()
current_stage = current_state.get("stage", "")
if current_stage and current_stage not in _INSTALL_TERMINAL_STAGES:
return 409, {
"error": (
f"another install is in progress ({current_state.get('app', '?')}"
f" at {current_stage})"
)
}
# Resolve dependencies before taking the lock so a 4xx (cycle, missing
# provider) comes back fast without ever touching install state.
try:
plan = deps.plan_install(name)
except deps.DependencyError as e:
return 400, {"error": str(e)}
# If installing `name` pulls in transitive providers, require an explicit
# `confirm_dependencies: true` so the UI gets a chance to show the user
# what's about to land. The 409 body carries the plan so the UI can
# render the confirm modal without a second `/plan` round-trip.
transitive = [n for n in plan.to_install if n != name]
if transitive and not confirm_dependencies:
status, body = _do_install_plan(name)
if status != 200:
return status, body
body["error"] = "additional apps required — set confirm_dependencies: true"
return 409, body
# Fast-fail if another install is already in flight. Lock lives under
# /run/ so a previous reboot clears it automatically.
try:
fh = install_runner.acquire_lock()
except install_runner.InstallRunnerError as e:
return 409, {"error": str(e)}
try:
try:
if plan.to_install:
targets = installer.install_plan(plan, settings_target=settings)
target = targets[-1]
else:
# Reinstall of an already-installed target with no new
# transitive providers — re-run the single-app path so an
# edited `.env` lands correctly.
src = installer.resolve_source(name)
target = installer.install_from(src, settings=settings)
except installer.InstallError as e:
return 400, {"error": str(e)}
# Write the plan file the background runner reads. Even single-app
# installs go through it so install_runner has one code path.
install_runner.plan_path().parent.mkdir(parents=True, exist_ok=True)
install_runner.plan_path().write_text(
json.dumps(
{
"target": name,
"to_install": list(plan.to_install) if plan.to_install else [name],
}
)
)
# Initial state so the UI has something to show between this
# response and the background job's first write.
first_app = plan.to_install[0] if plan.to_install else name
install_runner.write_state("pulling_image", app=first_app, target=name)
finally:
# Release the lock so the background job can re-acquire it.
fh.close()
unit = f"furtka-install-{name}"
try:
subprocess.run(
[
"systemd-run",
f"--unit={unit}",
"--no-block",
"--collect",
"/usr/local/bin/furtka",
"app",
"install-bg",
name,
],
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError:
install_runner.write_state("error", app=name, error="systemd-run not available")
return 502, {"error": "systemd-run not available"}
except subprocess.CalledProcessError as e:
err = (e.stderr or e.stdout or "").strip()
install_runner.write_state("error", app=name, error=f"dispatch failed: {err}")
return 502, {"error": f"systemd-run failed: {err}"}
return 202, {"status": "dispatched", "unit": unit, "installed": str(target)}
def _do_install_status():
"""Return the current install-state.json contents (or {})."""
install_runner.clear_stale_state()
return 200, install_runner.read_state()
def _do_update_settings(name, settings):
"""Write settings into an installed app's .env and kick off a reinstall.
Only works for already-installed apps — use /api/apps/install for fresh
installs (since bundled-app folders under /opt/... are read-only).
"""
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
try:
installer.update_env(name, settings)
except installer.InstallError as e:
return 400, {"error": str(e)}
actions = reconciler.reconcile(apps_dir())
return (
207 if reconciler.has_errors(actions) else 200,
{
"updated": name,
"actions": [{"kind": a.kind, "target": a.target, "detail": a.detail} for a in actions],
},
)
def _do_remove(name):
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
dependents = deps.dependents_of(name)
if dependents:
return 409, {
"error": (f"{name!r} is required by: {', '.join(dependents)}. Remove those first."),
"dependents": list(dependents),
}
compose_warning = None
try:
dockerops.compose_down(target, name)
except dockerops.DockerError as e:
compose_warning = str(e)
try:
installer.remove(name)
except installer.InstallError as e:
return 500, {"error": str(e)}
return 200, {"removed": name, "compose_warning": compose_warning}
def _do_furtka_check():
"""Thin wrapper around updater.check_update for the /api/furtka/update/check endpoint."""
from furtka import updater
try:
check = updater.check_update()
except updater.UpdateError as e:
return 502, {"error": str(e)}
return 200, {
"current": check.current,
"latest": check.latest,
"update_available": check.update_available,
}
def _do_furtka_apply():
"""Kick off a Furtka self-update detached from this process.
The API handler itself is about to be restarted (furtka-api.service is
part of what gets replaced), so we can't block the response on the
update completing. systemd-run starts the update as its own transient
unit — lifecycle independent of furtka-api, so the restart it triggers
mid-flight doesn't kill the updater.
The client polls /update-state.json (served by Caddy, not the API) to
watch progress. Returns 202 on successful dispatch; 409 if an update
is already running; 502 on systemd-run failure.
"""
import subprocess
from furtka import updater
# Check the lockfile ahead of time. systemd-run would succeed even if a
# concurrent update is running — the child process would then fail loudly
# at acquire_lock(), but the 202 would already be out. Better to 409 up
# front.
try:
fh = updater.acquire_lock()
except updater.UpdateError as e:
return 409, {"error": str(e)}
# Release the lock so the detached child can acquire it. The race between
# our release and the child's acquire is harmless — worst case the child
# logs "already in progress" and we've already 202'd.
fh.close()
try:
subprocess.run(
[
"systemd-run",
"--unit=furtka-update",
"--no-block",
"--collect",
"/usr/local/bin/furtka",
"update",
],
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError:
return 502, {"error": "systemd-run not available"}
except subprocess.CalledProcessError as e:
return 502, {
"error": f"systemd-run failed: {(e.stderr or e.stdout or '').strip()}",
}
return 202, {"status": "dispatched", "unit": "furtka-update"}
def _do_https_status():
"""Return CA fingerprint + force-redirect state for /api/furtka/https/status."""
from furtka import https
return 200, https.status()
def _do_https_force(payload):
"""Toggle HTTP→HTTPS redirect for /api/furtka/https/force."""
from furtka import https
enabled = payload.get("enabled")
if not isinstance(enabled, bool):
return 400, {"error": "'enabled' must be a boolean"}
try:
result = https.set_force_https(enabled)
except https.HttpsError as e:
return 500, {"error": str(e)}
return 200, {"force_https": result}
def _do_furtka_status():
"""Return the latest update-state.json written by the updater.
Mirrors what Caddy serves at /update-state.json — duplicated here so the
API has a consistent endpoint surface. Returns {} if no update has ever
been attempted.
"""
from furtka import updater
return 200, updater.read_state()
def _do_catalog_check():
"""Check Forgejo for a newer apps-catalog release.
Parallels _do_furtka_check: returns current/latest/update_available.
"""
from furtka import catalog
try:
check = catalog.check_catalog()
except catalog.CatalogError as e:
return 502, {"error": str(e)}
return 200, {
"current": check.current,
"latest": check.latest,
"update_available": check.update_available,
}
def _do_catalog_apply():
"""Kick off a catalog sync detached from this process.
Catalog sync doesn't restart furtka-api, so the lifecycle constraint that
forces the Furtka self-update to detach doesn't strictly apply here — but
using the same systemd-run pattern keeps the two UI flows symmetric and
means a slow network can't tie up the API thread. Client polls
/api/catalog/status the same way it polls /update-state.json.
"""
import subprocess
from furtka import catalog
try:
fh = catalog.acquire_lock()
except catalog.CatalogError as e:
return 409, {"error": str(e)}
fh.close()
try:
subprocess.run(
[
"systemd-run",
"--unit=furtka-catalog-sync-api",
"--no-block",
"--collect",
"/usr/local/bin/furtka",
"catalog",
"sync",
],
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError:
return 502, {"error": "systemd-run not available"}
except subprocess.CalledProcessError as e:
return 502, {
"error": f"systemd-run failed: {(e.stderr or e.stdout or '').strip()}",
}
return 202, {"status": "dispatched", "unit": "furtka-catalog-sync-api"}
def _do_catalog_status():
"""Return {current, state} for the apps catalog.
`current` is the catalog's on-disk VERSION; `state` is whatever was last
written by sync_catalog to catalog-state.json. UI uses both: show the
version next to a last-sync timestamp plus a stage indicator.
"""
from furtka import catalog
return 200, {
"current": catalog.read_current_catalog_version(),
"state": catalog.read_state(),
}
_POWER_ACTIONS = {
"reboot": "reboot",
"poweroff": "poweroff",
}
def _do_power(payload):
"""Schedule a reboot or poweroff with a short delay.
`systemd-run --on-active=3s` kicks a transient timer that fires
`systemctl {reboot|poweroff}` a few seconds after the API returns —
long enough for the HTTP response to reach the browser + the UI to
swap to a "Going down…" state before the kernel loses network.
The `--no-block` flag makes the systemd-run call itself return
immediately; `--collect` GCs the transient unit once it fires.
No auth: same posture as the install/remove endpoints. Anyone on the
LAN can reboot the box. The /settings banner warns about this;
Authentik will lock it down.
"""
import subprocess
action = payload.get("action")
systemctl_verb = _POWER_ACTIONS.get(action)
if systemctl_verb is None:
return 400, {"error": f"'action' must be one of {sorted(_POWER_ACTIONS)}"}
try:
subprocess.run(
[
"systemd-run",
"--on-active=3s",
"--no-block",
"--collect",
"systemctl",
systemctl_verb,
],
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError:
return 502, {"error": "systemd-run not available"}
except subprocess.CalledProcessError as e:
return 502, {
"error": f"systemd-run failed: {(e.stderr or e.stdout or '').strip()}",
}
return 202, {"action": action, "scheduled_in_seconds": 3}
def _do_update(name):
"""Pull newer container images for an installed app; restart if any changed.
Behaviour:
- 404 if the app isn't installed.
- Always runs `docker compose pull` first (cheap no-op when nothing to
fetch, touches the network).
- For each service, compares the container's running image ID against
the post-pull local image ID. If a service's image advanced, runs
`docker compose up -d` so compose recreates the affected containers
in place.
- Returns {updated: bool, services: [{service, from, to}], ...}.
"""
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
try:
dockerops.compose_pull(target, name)
tags = dockerops.compose_image_tags(target, name)
changes = []
for service, tag in tags.items():
running = dockerops.running_container_image_id(target, name, service)
local = dockerops.local_image_id(tag)
if running and local and running != local:
changes.append({"service": service, "from": running, "to": local, "tag": tag})
if changes:
dockerops.compose_up(target, name)
except dockerops.DockerError as e:
return 502, {"error": str(e)}
return 200, {"app": name, "updated": bool(changes), "services": changes}
def _parse_settings_body(payload):
"""Extract and coerce the settings dict from a JSON body. Returns dict or None."""
s = payload.get("settings")
if s is None:
return None
if not isinstance(s, dict):
return False # sentinel — caller should reject
out = {}
for k, v in s.items():
if not isinstance(k, str):
return False
out[k] = "" if v is None else str(v)
return out
# Largest request body we read. Every legitimate body is a small JSON
# object; without a cap a LAN client could make us buffer gigabytes.
MAX_BODY_BYTES = 1024 * 1024
# The server is threaded so status polls and page loads keep answering while
# a slow request (image pull, reconcile) runs. Requests that change state
# still run one at a time, as they did on the single-threaded server.
_MUTATION_LOCK = threading.Lock()
# First-run admin creation is check-then-write; two setup POSTs racing on
# the threaded server must not both create an admin.
_SETUP_LOCK = threading.Lock()
_USERS_FILE_BROKEN = (
"The login file on this box (/var/lib/furtka/users.json) is damaged. "
"Log in over SSH, delete that file, then reload this page to set a new admin password."
)
def _valid_app_name(name) -> bool:
return isinstance(name, str) and APP_NAME_RE.fullmatch(name) is not None
class _Handler(BaseHTTPRequestHandler):
# Socket read timeout: a client that stops sending mid-request gets
# dropped instead of holding a worker forever.
timeout = 30
def _json(self, status, payload, extra_headers=None):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
for name, value in extra_headers or []:
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def _html(self, status, body, extra_headers=None):
b = body.encode()
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(b)))
for name, value in extra_headers or []:
self.send_header(name, value)
self.end_headers()
self.wfile.write(b)
def _serve_static_www(self, relative_path: str):
"""Read an HTML asset from assets/www/ and serve it as 200.
Only reached after the do_GET auth-guard — so the caller is
already authed. Relative_path is hard-coded at the call site
(``index.html`` or ``settings/index.html``), not user-supplied,
so there's no path-traversal surface here — but we still clamp
the resolved path to static_www_dir() as a defensive check in
case a future refactor wires a dynamic path through.
"""
root = static_www_dir().resolve()
target = (root / relative_path).resolve()
if root not in target.parents and target != root:
return self._html(500, "<h1>internal error</h1>")
try:
body = target.read_text(encoding="utf-8")
except (FileNotFoundError, OSError):
return self._html(404, "<h1>not found</h1>")
return self._html(200, body)
def _redirect(self, location, extra_headers=None):
self.send_response(302)
self.send_header("Location", location)
self.send_header("Content-Length", "0")
for name, value in extra_headers or []:
self.send_header(name, value)
self.end_headers()
# ---- Auth helpers -------------------------------------------------
def _request_cookies(self) -> SimpleCookie:
cookies = SimpleCookie()
header = self.headers.get("Cookie")
if header:
try:
cookies.load(header)
except Exception:
# Malformed Cookie header — treat as no cookies rather
# than 500ing. Same posture as browsers.
return SimpleCookie()
return cookies
def _current_session(self):
cookies = self._request_cookies()
morsel = cookies.get(auth.COOKIE_NAME)
if morsel is None:
return None
return auth.SESSIONS.lookup(morsel.value)
def _session_cookie_header(self, token: str, max_age: int) -> tuple[str, str]:
secure = self.headers.get("X-Forwarded-Proto", "").lower() == "https"
parts = [
f"{auth.COOKIE_NAME}={token}",
"HttpOnly",
"SameSite=Strict",
"Path=/",
f"Max-Age={max_age}",
]
if secure:
parts.append("Secure")
return ("Set-Cookie", "; ".join(parts))
def _clear_cookie_header(self) -> tuple[str, str]:
# Max-Age=0 with an empty value tells the browser to drop it.
return (
"Set-Cookie",
f"{auth.COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
)
def _client_ip(self) -> str:
# Caddy's reverse_proxy appends the real TCP peer to X-Forwarded-For;
# the rightmost entry is the one Caddy added, so it's trustworthy
# even if a client spoofed an XFF of their own. Caddy is the edge —
# no upstream proxy in front of it.
xff = self.headers.get("X-Forwarded-For")
if xff:
return xff.rsplit(",", 1)[-1].strip()
return self.client_address[0]
def _handle_login(self, payload):
username = payload.get("username") if isinstance(payload, dict) else None
password = payload.get("password") if isinstance(payload, dict) else None
if not isinstance(username, str) or not username.strip():
return self._json(400, {"error": "username is required"})
if not isinstance(password, str) or not password:
return self._json(400, {"error": "password is required"})
username = username.strip()
try:
with _SETUP_LOCK:
return self._login_or_setup(payload, username, password)
except auth.UsersFileError:
traceback.print_exc()
return self._json(503, {"error": _USERS_FILE_BROKEN})
def _login_or_setup(self, payload, username, password):
if auth.setup_needed():
# First-run setup path — create the admin account, then log
# in. Require password2 so a typo doesn't lock the user out
# of their own box.
password2 = payload.get("password2")
if password2 != password:
return self._json(400, {"error": "passwords do not match"})
if len(password) < _MIN_PASSWORD_LEN:
return self._json(
400,
{"error": f"password must be at least {_MIN_PASSWORD_LEN} characters"},
)
auth.create_admin(username, password)
else:
# Tuple-keyed lockout: a flood from one IP can't lock the
# admin out from a different IP. When locked we return the
# same 429 regardless of whether the password is correct —
# no oracle, no timing leak via "would have worked."
lockout_key = (username, self._client_ip())
retry = auth.LOCKOUT.retry_after_seconds(lockout_key)
if retry > 0:
return self._json(
429,
{"error": "too many failed attempts, try again later"},
extra_headers=[("Retry-After", str(retry))],
)
if not auth.authenticate(username, password):
# Register before the sleep so concurrent threads see a
# consistent count; keep the sleep so timing can't
# distinguish "locked" from "wrong password."
auth.LOCKOUT.register_failure(lockout_key)
time.sleep(0.5)
return self._json(401, {"error": "invalid username or password"})
auth.LOCKOUT.clear(lockout_key)
session = auth.SESSIONS.create(username)
cookie = self._session_cookie_header(session.token, auth.COOKIE_TTL_SECONDS)
return self._json(200, {"ok": True, "username": username}, extra_headers=[cookie])
def _handle_logout(self):
cookies = self._request_cookies()
morsel = cookies.get(auth.COOKIE_NAME)
if morsel is not None:
auth.SESSIONS.revoke(morsel.value)
return self._json(200, {"ok": True}, extra_headers=[self._clear_cookie_header()])
def _internal_error(self):
# Without this, an unexpected exception makes socketserver drop the
# connection and the UI shows "Failed to fetch" with nothing to go on.
traceback.print_exc()
try:
self._json(500, {"error": "internal error — see `journalctl -u furtka-api`"})
except OSError:
pass
def do_GET(self): # noqa: N802 — http.server convention
try:
self._route_get()
except Exception:
self._internal_error()
def do_POST(self): # noqa: N802
try:
self._route_post()
except Exception:
self._internal_error()
def _route_get(self):
# --- Public routes: login page + its assets ------------------
if self.path in ("/login", "/login/"):
# Already authed? Skip straight to the app list.
if self._current_session() is not None:
return self._redirect("/apps")
try:
setup = auth.setup_needed()
except auth.UsersFileError:
traceback.print_exc()
return self._html(503, f"<h1>Login unavailable</h1><p>{_USERS_FILE_BROKEN}</p>")
return self._html(200, _render_login_html(setup))
# --- Auth guard for everything below -------------------------
session = self._current_session()
if session is None:
# API paths get a 401 JSON so fetch() callers see a clean
# error. HTML paths get a redirect to /login so the browser
# naturally ends up on the login form.
if self.path.startswith("/api/"):
return self._json(401, {"error": "not authenticated"})
return self._redirect("/login")
if self.path in ("/apps", "/apps/"):
return self._html(200, _HTML)
# Landing page + settings page used to be served directly by
# Caddy as static HTML, which silently bypassed this auth
# guard (26.11-era regression that shipped and nobody noticed
# until the 26.13 SSH test session — LAN visitors could read
# the box version, IP and fire pre-authed clicks at the
# update/reboot/https-toggle buttons even though the API calls
# themselves would 401). Python reads the static HTML from
# assets/www/ and serves it behind the session check; Caddy
# now proxies / and /settings* here (see Caddyfile).
if self.path == "/":
return self._serve_static_www("index.html")
if self.path in ("/settings", "/settings/"):
return self._serve_static_www("settings/index.html")
if self.path == "/api/apps":
return self._json(200, _list_installed())
# /api/bundled is the pre-26.6 name for this list; kept as an alias
# so any external tooling survives the rename to /api/apps/available.
if self.path in ("/api/bundled", "/api/apps/available"):
return self._json(200, _list_available())
if self.path == "/api/furtka/update/status":
status, body = _do_furtka_status()
return self._json(status, body)
if self.path == "/api/furtka/https/status":
status, body = _do_https_status()
return self._json(status, body)
if self.path == "/api/catalog/status":
status, body = _do_catalog_status()
return self._json(status, body)
if self.path == "/api/apps/install/status":
status, body = _do_install_status()
return self._json(status, body)
# /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name)
return self._json(status, body)
self._json(404, {"error": "not found"})
def _route_post(self):
# Browsers tag every request with where it came from. Only accept
# state changes from our own pages — an app on another port of the
# same host counts as "same-site" and would pass SameSite=Strict.
# Old browsers that don't send the header fall back to the cookie.
fetch_site = self.headers.get("Sec-Fetch-Site")
if fetch_site is not None and fetch_site not in ("same-origin", "none"):
return self._json(403, {"error": "cross-site request refused"})
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
return self._json(400, {"error": "invalid Content-Length"})
if length < 0 or length > MAX_BODY_BYTES:
return self._json(413, {"error": "request body too large"})
raw = self.rfile.read(length) if length else b""
try:
payload = json.loads(raw.decode()) if raw else {}
except (UnicodeDecodeError, json.JSONDecodeError):
return self._json(400, {"error": "invalid JSON body"})
if not isinstance(payload, dict):
return self._json(400, {"error": "body must be a JSON object"})
# --- Public routes: login + logout ----------------------------
if self.path in ("/login", "/login/"):
return self._handle_login(payload)
if self.path in ("/logout", "/logout/"):
return self._handle_logout()
# --- Auth guard for every other POST --------------------------
if self._current_session() is None:
return self._json(401, {"error": "not authenticated"})
with _MUTATION_LOCK:
return self._route_authed_post(payload)
def _route_authed_post(self, payload):
# Per-app settings update: /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"})
settings = _parse_settings_body(payload)
if settings is False or settings is None:
return self._json(400, {"error": "missing or invalid 'settings' object"})
status, body = _do_update_settings(name, settings)
return self._json(status, body)
# Per-app image update: /api/apps/<name>/update
if self.path.startswith("/api/apps/") and self.path.endswith("/update"):
name = self.path[len("/api/apps/") : -len("/update")]
if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"})
status, body = _do_update(name)
return self._json(status, body)
# Furtka itself: check + apply endpoints (Phase 2 self-update).
if self.path == "/api/furtka/update/check":
status, body = _do_furtka_check()
return self._json(status, body)
if self.path == "/api/furtka/update/apply":
status, body = _do_furtka_apply()
return self._json(status, body)
if self.path == "/api/furtka/https/force":
status, body = _do_https_force(payload)
return self._json(status, body)
# Apps catalog: check + apply (daily timer + manual UI button).
if self.path == "/api/catalog/sync/check":
status, body = _do_catalog_check()
return self._json(status, body)
if self.path == "/api/catalog/sync/apply":
status, body = _do_catalog_apply()
return self._json(status, body)
# System power: /settings Reboot / Shut down buttons.
if self.path == "/api/furtka/power":
status, body = _do_power(payload)
return self._json(status, body)
name = payload.get("name")
if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"})
if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"})
if self.path == "/api/apps/install/plan":
status, body = _do_install_plan(name)
elif self.path == "/api/apps/install":
settings = _parse_settings_body(payload)
if settings is False:
return self._json(400, {"error": "'settings' must be an object"})
confirm = bool(payload.get("confirm_dependencies", False))
status, body = _do_install(name, settings=settings, confirm_dependencies=confirm)
elif self.path == "/api/apps/remove":
status, body = _do_remove(name)
else:
status, body = 404, {"error": "not found"}
self._json(status, body)
def log_message(self, fmt, *args): # noqa: A003
# Quiet — systemd journal already records the access via Caddy's log.
pass
def serve(host: str = "127.0.0.1", port: int = 7000) -> None:
"""Run the API server. Blocks forever; exits on SIGINT/SIGTERM."""
server = ThreadingHTTPServer((host, port), _Handler)
server.daemon_threads = True
print(f"Furtka API listening on http://{host}:{port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()