feat(furtka): web UI + HTTP API for app install/remove
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / validate-json (push) Waiting to run
CI / markdown-links (push) Waiting to run
Build ISO / build-iso (push) Successful in 16m52s

Adds the management UI Daniel asked for end-of-session. Goes beyond
the original MVP scope (plan punted UI to v2) but the architecture
already supports it cleanly: stdlib http.server only, no new deps.

- furtka.api: minimal HTTP server. GET / serves a self-contained
  HTML page (dark-mode card list, vanilla JS, no build step). GET
  /api/apps + /api/bundled return JSON. POST /api/apps/{install,
  remove} accept {"name": "..."} and call the same installer +
  reconciler the CLI uses, so the placeholder-secret refusal and
  per-app reconcile isolation flow through unchanged.
- furtka.cli: new `furtka serve` subcommand. Imports api lazily so
  `furtka app list` / `reconcile` startup stays zero-cost.
- webinstaller: new furtka-api.service (Type=simple, restart on
  failure, after reconcile). Caddyfile gets two new handle blocks
  to reverse-proxy /api and /apps to localhost:7000. Landing page's
  "App store coming soon" tile becomes a real "Manage installed apps
  →" link to /apps.
- Bound to 127.0.0.1 by default; Caddy makes it LAN-reachable. The
  UI shouts a "no auth, anyone on your LAN can install/remove" warning
  at the top — Authentik integration is the proper fix later.

UX wrinkle worth noting: a placeholder-rejected install leaves the
app in /var/lib/furtka/apps/<name>/ (so the user can edit .env in
place). To re-trigger after editing, the Installed list now shows
both Reinstall and Remove buttons.

10 new tests: helper functions (list_installed, list_bundled with
hide-already-installed), install/remove endpoints with the no_docker
fixture, and two real-socket urllib smoke tests that boot the actual
HTTPServer on an ephemeral port and round-trip GET / + POST.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-15 10:23:46 +02:00
parent ff68dd5ae6
commit c6ed7a8159
5 changed files with 477 additions and 12 deletions

262
furtka/api.py Normal file
View file

@ -0,0 +1,262 @@
# 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, no Flask/no third-party deps so we don't
have to pip-install anything on the target. Caddy reverse-proxies /apps and
/api from :80 to here.
Security: NO AUTH. Bound to 127.0.0.1 by default; the Caddy proxy makes it
LAN-reachable. Anyone on the LAN can install/remove apps. The UI shouts this
out at the top. Auth lands when Authentik does.
"""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from furtka import dockerops, installer, reconciler
from furtka.manifest import ManifestError, load_manifest
from furtka.paths import apps_dir, bundled_apps_dir
from furtka.scanner import scan
_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">
<style>
:root { --bg:#0f1115; --fg:#e8eaed; --muted:#9aa0a6; --accent:#6ee7b7; --card:#1a1d24; --warn:#4a3030; --danger:#f08080; }
* { box-sizing:border-box; }
body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; background: var(--bg); color: var(--fg); line-height:1.5; }
h1 { font-size: 2rem; margin: 0; }
h2 { font-size: 1rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); margin: 2rem 0 0.75rem; }
.lede { color: var(--muted); margin: 0.25rem 0 1rem; }
.warn { background: var(--warn); padding: 1rem; border-radius: 8px; margin: 1.5rem 0; color: #fed; font-size: 0.9rem; }
.app { background: var(--card); padding: 1rem; border-radius: 8px; margin: 0.5rem 0; display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.meta { display: flex; flex-direction: column; min-width: 0; }
.name { font-weight: 600; font-size: 1.05rem; }
.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; }
.desc { color: var(--muted); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; }
button { background: var(--accent); border: none; color: var(--bg); font-weight: 600; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; white-space: nowrap; }
button.danger { background: var(--danger); }
button:disabled { opacity: 0.5; cursor: wait; }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
pre { background: var(--card); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; }
</style>
</head>
<body>
<h1>Furtka Apps</h1>
<p class="lede">Install or remove resource-manager apps on this Furtka box.</p>
<div class="warn">No authentication on this UI yet. Anyone on your LAN can install or remove apps. Don't expose this to the wider internet.</div>
<h2>Installed</h2>
<div id="installed"></div>
<h2>Available to install</h2>
<div id="available"></div>
<h2>Last action</h2>
<pre id="log">(none yet)</pre>
<script>
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
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 => `
<div class="app">
<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>
<button data-op="install" 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="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || '')}</span>
</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', () => act(btn.dataset.op, btn.dataset.name, btn));
}
}
async function act(op, name, btn) {
btn.disabled = true;
const original = btn.textContent;
btn.textContent = op === 'install' ? 'Installing…' : 'Removing…';
try {
const r = await fetch(`/api/apps/${op}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
});
const data = await r.json();
document.getElementById('log').textContent =
`[${op} ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
} catch (e) {
document.getElementById('log').textContent = `[${op} ${name}] network error: ${e.message}`;
}
btn.textContent = original;
await refresh();
}
refresh();
</script>
</body>
</html>
"""
def _manifest_summary(m):
return {
"name": m.name,
"display_name": m.display_name,
"version": m.version,
"description": m.description,
"ports": list(m.ports),
"icon": m.icon,
}
def _list_installed():
out = []
for r in scan(apps_dir()):
if r.ok:
d = _manifest_summary(r.manifest)
d["ok"] = True
out.append(d)
else:
out.append({"name": r.path.name, "ok": False, "error": r.error})
return out
def _list_bundled():
installed_names = {r.path.name for r in scan(apps_dir()) if r.ok}
bundled = bundled_apps_dir()
if not bundled.exists():
return []
out = []
for entry in sorted(bundled.iterdir()):
if not entry.is_dir() or entry.name in installed_names:
continue
manifest_path = entry / "manifest.json"
if not manifest_path.exists():
continue
try:
m = load_manifest(manifest_path)
except ManifestError:
continue
out.append(_manifest_summary(m))
return out
def _do_install(name):
try:
src = installer.resolve_source(name)
target = installer.install_from(src)
except installer.InstallError as e:
return 400, {"error": str(e)}
actions = reconciler.reconcile(apps_dir())
payload = {
"installed": str(target),
"actions": [{"kind": a.kind, "target": a.target, "detail": a.detail} for a in actions],
}
# 207 Multi-Status — install copy succeeded but reconcile had per-app errors.
return (207 if reconciler.has_errors(actions) else 200, payload)
def _do_remove(name):
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
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}
class _Handler(BaseHTTPRequestHandler):
def _json(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self, status, body):
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)))
self.end_headers()
self.wfile.write(b)
def do_GET(self): # noqa: N802 — http.server convention
if self.path in ("/", "/apps", "/apps/"):
return self._html(200, _HTML)
if self.path == "/api/apps":
return self._json(200, _list_installed())
if self.path == "/api/bundled":
return self._json(200, _list_bundled())
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"})
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":
status, body = _do_install(name)
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()

View file

@ -77,6 +77,15 @@ def _cmd_app_remove(args: argparse.Namespace) -> int:
return 0 return 0
def _cmd_serve(args: argparse.Namespace) -> int:
# Imported lazily so `furtka` startup stays cheap when the user only runs
# `app list` or `reconcile` (the common case during tests + scripts).
from furtka import api
api.serve(args.host, args.port)
return 0
def _cmd_reconcile(args: argparse.Namespace) -> int: def _cmd_reconcile(args: argparse.Namespace) -> int:
actions = reconciler.reconcile(apps_dir(), dry_run=args.dry_run) actions = reconciler.reconcile(apps_dir(), dry_run=args.dry_run)
print(f"Scanned {apps_dir()}: {len(actions)} actions") print(f"Scanned {apps_dir()}: {len(actions)} actions")
@ -126,6 +135,11 @@ def build_parser() -> argparse.ArgumentParser:
) )
reconcile.set_defaults(func=_cmd_reconcile) reconcile.set_defaults(func=_cmd_reconcile)
serve = sub.add_parser("serve", help="Run the resource-manager HTTP API + UI")
serve.add_argument("--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)")
serve.add_argument("--port", type=int, default=7000, help="Bind port (default 7000)")
serve.set_defaults(func=_cmd_serve)
return p return p

155
tests/test_api.py Normal file
View file

@ -0,0 +1,155 @@
import json
import threading
import urllib.error
import urllib.request
import pytest
from furtka import api, dockerops
VALID_MANIFEST = {
"name": "fileshare",
"display_name": "Network Files",
"version": "0.1.0",
"description": "SMB share",
"volumes": ["files"],
"ports": [445],
"icon": "icon.svg",
}
@pytest.fixture
def fake_dirs(tmp_path, monkeypatch):
apps = tmp_path / "apps"
bundled = tmp_path / "bundled"
apps.mkdir()
bundled.mkdir()
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
return apps, bundled
@pytest.fixture
def no_docker(monkeypatch):
"""Stub docker calls so install/remove can run without a daemon."""
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: True)
monkeypatch.setattr(dockerops, "compose_up", lambda app_dir, project: None)
monkeypatch.setattr(dockerops, "compose_down", lambda app_dir, project: None)
def _write_bundled(bundled, name, manifest=None, env_example=None):
app = bundled / name
app.mkdir()
(app / "manifest.json").write_text(json.dumps(manifest or VALID_MANIFEST))
(app / "docker-compose.yaml").write_text("services: {}\n")
if env_example is not None:
(app / ".env.example").write_text(env_example)
return app
def test_list_installed_empty(fake_dirs):
assert api._list_installed() == []
def test_list_bundled_empty(fake_dirs):
assert api._list_bundled() == []
def test_list_bundled_shows_uninstalled(fake_dirs):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare")
out = api._list_bundled()
assert len(out) == 1
assert out[0]["name"] == "fileshare"
assert "display_name" in out[0]
def test_list_bundled_hides_already_installed(fake_dirs, no_docker):
apps, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
status, _ = api._do_install("fileshare")
assert status == 200
# Now bundled should NOT include fileshare anymore.
assert api._list_bundled() == []
# But installed list should.
installed = api._list_installed()
assert len(installed) == 1 and installed[0]["name"] == "fileshare"
def test_install_endpoint_rejects_placeholder(fake_dirs):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="SMB_PASSWORD=changeme")
status, body = api._do_install("fileshare")
assert status == 400
assert "placeholder" in body["error"]
def test_install_endpoint_rejects_unknown_app(fake_dirs):
status, body = api._do_install("does-not-exist")
assert status == 400
assert "not found" in body["error"]
def test_remove_endpoint_unknown(fake_dirs, no_docker):
status, body = api._do_remove("ghost")
assert status == 404
def test_remove_endpoint_happy_path(fake_dirs, no_docker):
apps, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
api._do_install("fileshare")
assert (apps / "fileshare").exists()
status, body = api._do_remove("fileshare")
assert status == 200
assert body["removed"] == "fileshare"
assert not (apps / "fileshare").exists()
def test_http_get_apps_route(fake_dirs, no_docker):
"""Smoke test the actual HTTP server with a real socket, urllib client."""
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral
port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/apps") as r:
assert r.status == 200
data = json.loads(r.read())
assert data == []
with urllib.request.urlopen(f"http://127.0.0.1:{port}/") as r:
assert r.status == 200
assert b"Furtka Apps" in r.read()
# Unknown route → 404 JSON.
try:
urllib.request.urlopen(f"http://127.0.0.1:{port}/api/nope")
raise AssertionError("expected 404")
except urllib.error.HTTPError as e:
assert e.code == 404
finally:
server.shutdown()
server.server_close()
def test_http_post_install_unknown_app(fake_dirs):
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
req = urllib.request.Request(
f"http://127.0.0.1:{port}/api/apps/install",
data=json.dumps({"name": "ghost"}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
urllib.request.urlopen(req)
raise AssertionError("expected 400")
except urllib.error.HTTPError as e:
assert e.code == 400
body = json.loads(e.read())
assert "not found" in body["error"]
finally:
server.shutdown()
server.server_close()

View file

@ -141,9 +141,11 @@ def test_resource_manager_payload_landed_when_present(monkeypatch, tmp_path):
assert "tar -xzf - -C /opt/furtka" in joined assert "tar -xzf - -C /opt/furtka" in joined
# `furtka` CLI wrapper lands on the target. # `furtka` CLI wrapper lands on the target.
assert "/usr/local/bin/furtka" in joined assert "/usr/local/bin/furtka" in joined
# systemd unit is written and conditionally enabled. # systemd units are written and conditionally enabled.
assert "/etc/systemd/system/furtka-reconcile.service" in joined assert "/etc/systemd/system/furtka-reconcile.service" in joined
assert "/etc/systemd/system/furtka-api.service" in joined
assert "furtka-reconcile.service" in joined assert "furtka-reconcile.service" in joined
assert "furtka-api.service" in joined
# python is pacstrapped so the wrapper has an interpreter. # python is pacstrapped so the wrapper has an interpreter.
assert "python" in cfg["packages"] assert "python" in cfg["packages"]
@ -169,7 +171,9 @@ def test_resource_manager_absent_without_payload(monkeypatch, tmp_path):
joined = "\n".join(cfg["custom_commands"]) joined = "\n".join(cfg["custom_commands"])
assert "tar -xzf - -C /opt/furtka" not in joined assert "tar -xzf - -C /opt/furtka" not in joined
assert "furtka-reconcile.service" in joined # still in the conditional enable line # The conditional enable line still mentions the units (gated by [ -e ]).
assert "furtka-reconcile.service" in joined
assert "furtka-api.service" in joined
# The base system bootstrap (caddy etc) is unaffected. # The base system bootstrap (caddy etc) is unaffected.
assert "/etc/caddy/Caddyfile" in joined assert "/etc/caddy/Caddyfile" in joined

View file

@ -146,12 +146,22 @@ def build_disk_config(boot_drive):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_CADDYFILE = """\ _CADDYFILE = """\
# Serves the Furtka landing page + status.json on :80. Static only for now; # Serves the Furtka landing page + status.json on :80. Static for the
# reverse_proxy / TLS / auth come later when Authentik is wired in. # landing page; /apps and /api are reverse-proxied to the local resource-
# manager API (furtka serve, bound to 127.0.0.1:7000). TLS / auth come
# later when Authentik is wired in.
:80 { :80 {
\troot * /srv/furtka/www \thandle /api/* {
\tfile_server \t\treverse_proxy localhost:7000
\tencode gzip \t}
\thandle /apps* {
\t\treverse_proxy localhost:7000
\t}
\thandle {
\t\troot * /srv/furtka/www
\t\tfile_server
\t\tencode gzip
\t}
\tlog { \tlog {
\t\toutput stdout \t\toutput stdout
\t} \t}
@ -195,8 +205,8 @@ _INDEX_HTML = """\
</section> </section>
<section class="soon"> <section class="soon">
<h2>App store</h2> <h2>Apps</h2>
<p>Coming soon one-click installs for Nextcloud, Jellyfin, and friends.</p> <p><a href="/apps">Manage installed apps </a></p>
</section> </section>
<footer> <footer>
@ -430,6 +440,23 @@ RemainAfterExit=no
WantedBy=multi-user.target WantedBy=multi-user.target
""" """
_FURTKA_API_SERVICE = """\
[Unit]
Description=Furtka resource-manager HTTP API + UI
Requires=docker.service
After=docker.service network-online.target furtka-reconcile.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/furtka serve --host 127.0.0.1 --port 7000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
"""
def _write_file_cmd(path, content, mode=None): def _write_file_cmd(path, content, mode=None):
"""Shell command that recreates `path` with `content` inside the chroot. """Shell command that recreates `path` with `content` inside the chroot.
@ -468,6 +495,7 @@ def _resource_manager_commands():
untar_cmd, untar_cmd,
_write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"), _write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"),
_write_file_cmd("/etc/systemd/system/furtka-reconcile.service", _FURTKA_RECONCILE_SERVICE), _write_file_cmd("/etc/systemd/system/furtka-reconcile.service", _FURTKA_RECONCILE_SERVICE),
_write_file_cmd("/etc/systemd/system/furtka-api.service", _FURTKA_API_SERVICE),
] ]
@ -501,11 +529,13 @@ def _post_install_commands(hostname):
# custom_commands runs, so our own unit files aren't on disk yet at # custom_commands runs, so our own unit files aren't on disk yet at
# that point. Enable them here, after they exist. caddy / # that point. Enable them here, after they exist. caddy /
# avahi-daemon stay in the `services` list — those are packaged # avahi-daemon stay in the `services` list — those are packaged
# units, present right after pacstrap. furtka-reconcile is enabled # units, present right after pacstrap. furtka-reconcile +
# only if the resource manager payload was actually installed above. # furtka-api are enabled only if the resource manager payload was
# actually installed above; the conditional keeps systemctl green
# on dev / payload-less builds.
"systemctl enable furtka-welcome.service furtka-status.timer " "systemctl enable furtka-welcome.service furtka-status.timer "
"$([ -e /etc/systemd/system/furtka-reconcile.service ] " "$([ -e /etc/systemd/system/furtka-reconcile.service ] "
"&& echo furtka-reconcile.service)", "&& echo furtka-reconcile.service furtka-api.service)",
] ]