furtka/furtka/api.py
Daniel Maksymilian Syrnicki 32ba522d59 fix(catalog): retry the boot-time sync and record failures as an error stage
On dual-stack LANs network-online.target is satisfied by the IPv6 SLAAC
address about a second before DHCPv4 lands, so the first
furtka-catalog-sync run died with 'Network is unreachable' and the next
attempt was the daily timer (OnBootSec=10min + up to 6 h jitter). The
state file stayed on 'checking' and the apps page showed 'checking…'
with nothing running — first hardware bench, 2026-08-25.

- furtka-catalog-sync.service: Restart=on-failure every 20 s, up to 8
  times in 15 min
- sync_catalog() writes stage 'error' + message on CatalogError, then
  re-raises (body moved to _sync_locked())
- apps page shows 'last sync failed: <error>' for that stage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
2026-08-25 16:38:06 +02:00

1551 lines
59 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 time
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, HTTPServer
from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources
from furtka.manifest import 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>
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 = frozenset({"done", "error"})
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.
current_state = install_runner.read_state()
current_stage = current_state.get("stage", "") if isinstance(current_state, dict) else ""
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 {})."""
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
class _Handler(BaseHTTPRequestHandler):
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()
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 do_GET(self): # noqa: N802 — http.server convention
# --- 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")
return self._html(200, _render_login_html(auth.setup_needed()))
# --- 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 "/" in name or not 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 do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", "0"))
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"})
# 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 "/" in name or not 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 "/" in name or not 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 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 = HTTPServer((host, port), _Handler)
print(f"Furtka API listening on http://{host}:{port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()