diff --git a/furtka/api.py b/furtka/api.py new file mode 100644 index 0000000..304d5dc --- /dev/null +++ b/furtka/api.py @@ -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 = """ + + + +Furtka Apps + + + + +

Furtka Apps

+

Install or remove resource-manager apps on this Furtka box.

+
No authentication on this UI yet. Anyone on your LAN can install or remove apps. Don't expose this to the wider internet.
+ +

Installed

+
+ +

Available to install

+
+ +

Last action

+
(none yet)
+ + + + +""" + + +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() diff --git a/furtka/cli.py b/furtka/cli.py index 764602b..66625af 100644 --- a/furtka/cli.py +++ b/furtka/cli.py @@ -77,6 +77,15 @@ def _cmd_app_remove(args: argparse.Namespace) -> int: 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: actions = reconciler.reconcile(apps_dir(), dry_run=args.dry_run) print(f"Scanned {apps_dir()}: {len(actions)} actions") @@ -126,6 +135,11 @@ def build_parser() -> argparse.ArgumentParser: ) 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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..02e23ed --- /dev/null +++ b/tests/test_api.py @@ -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() diff --git a/tests/test_app.py b/tests/test_app.py index fd88cb2..863093d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -141,9 +141,11 @@ def test_resource_manager_payload_landed_when_present(monkeypatch, tmp_path): assert "tar -xzf - -C /opt/furtka" in joined # `furtka` CLI wrapper lands on the target. 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-api.service" in joined assert "furtka-reconcile.service" in joined + assert "furtka-api.service" in joined # python is pacstrapped so the wrapper has an interpreter. 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"]) 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. assert "/etc/caddy/Caddyfile" in joined diff --git a/webinstaller/app.py b/webinstaller/app.py index 59651c6..658faee 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -146,12 +146,22 @@ def build_disk_config(boot_drive): # --------------------------------------------------------------------------- _CADDYFILE = """\ -# Serves the Furtka landing page + status.json on :80. Static only for now; -# reverse_proxy / TLS / auth come later when Authentik is wired in. +# Serves the Furtka landing page + status.json on :80. Static for the +# 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 { -\troot * /srv/furtka/www -\tfile_server -\tencode gzip +\thandle /api/* { +\t\treverse_proxy localhost:7000 +\t} +\thandle /apps* { +\t\treverse_proxy localhost:7000 +\t} +\thandle { +\t\troot * /srv/furtka/www +\t\tfile_server +\t\tencode gzip +\t} \tlog { \t\toutput stdout \t} @@ -195,8 +205,8 @@ _INDEX_HTML = """\
-

App store

-

Coming soon — one-click installs for Nextcloud, Jellyfin, and friends.

+

Apps

+

Manage installed apps →