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
1551 lines
59 KiB
Python
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()
|