From 43bcf3c4e533cafe7b07d15d42b4967ab678620b Mon Sep 17 00:00:00 2001 From: Robert Syrnicki Date: Mon, 24 Aug 2026 13:08:55 +0200 Subject: [PATCH] Add internet.viable manifest field and its catalog-side privileged gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Manifest.internet field (viable: bool, port: int | None), parsed by _parse_internet() following _parse_settings/_parse_requires's existing shape. When viable, port must be one of the manifest's own declared ports — resolves which entry is the exposable web UI for apps declaring several (e.g. a DNS service exposing both a resolver port and an admin UI port). catalog.py's release validation additionally rejects any internet.viable app whose docker-compose.yaml sets privileged: true. Deliberately not a network_mode check: a furtka-gateway reaches a host-networked container's port exactly like a bridge-published one — the real risk is a compromised internet-facing container also having host device/kernel access, which privileged: true grants outright. --- furtka/catalog.py | 19 +++++++++- furtka/manifest.py | 39 ++++++++++++++++++++ tests/test_catalog.py | 80 ++++++++++++++++++++++++++++++++++++++++-- tests/test_manifest.py | 54 ++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/furtka/catalog.py b/furtka/catalog.py index 5dd8b9d..7321c20 100644 --- a/furtka/catalog.py +++ b/furtka/catalog.py @@ -34,6 +34,7 @@ from __future__ import annotations import fcntl import json import os +import re import shutil import time from dataclasses import dataclass @@ -150,6 +151,9 @@ def acquire_lock(): return fh +_PRIVILEGED_RE = re.compile(r"^\s*privileged:\s*true\s*$", re.MULTILINE) + + def _validate_staging(staging: Path, expected_version: str) -> None: """Fail hard if the staging tree isn't a well-formed catalog release.""" version_file = staging / _VERSION_FILE @@ -170,10 +174,23 @@ def _validate_staging(staging: Path, expected_version: str) -> None: if not manifest_path.exists(): raise CatalogError(f"catalog app {entry.name!r} has no manifest.json") try: - load_manifest(manifest_path, expected_name=entry.name) + manifest = load_manifest(manifest_path, expected_name=entry.name) except ManifestError as e: raise CatalogError(f"catalog app {entry.name!r}: invalid manifest: {e}") from e + if manifest.internet.viable: + compose_path = entry / "docker-compose.yaml" + if compose_path.exists() and _PRIVILEGED_RE.search(compose_path.read_text()): + # Not a network-mode check — Traefik reaches a host-networked + # container's port exactly like a bridge-published one. The + # real risk of `internet.viable: true` here is a compromised + # internet-facing container also having host device/kernel + # access, which `privileged: true` grants outright. + raise CatalogError( + f"catalog app {entry.name!r}: internet.viable apps must not run " + "privileged (docker-compose.yaml sets privileged: true)" + ) + def _atomic_swap(staging: Path) -> None: """Move staging → live catalog, keeping the previous tree as .prev until diff --git a/furtka/manifest.py b/furtka/manifest.py index 03a4c73..071cd12 100644 --- a/furtka/manifest.py +++ b/furtka/manifest.py @@ -32,6 +32,21 @@ class Setting: default: str | None +@dataclass(frozen=True) +class Internet: + # Whether this app is safe to expose to the internet through a + # furtka-gateway at all. Only ever a catalog author's self-declaration — + # the gateway independently re-validates the port it's given at publish + # time regardless of what this says (see furtka-gateway's routes.py). + viable: bool + # Which declared `ports` entry is the exposable web UI. Required when + # viable is True — an app declaring several ports (e.g. a DNS service + # exposing both its resolver port and an admin UI port) must say which + # one is actually meant to be reachable from the internet; there is no + # way to infer that from `ports` alone. + port: int | None + + @dataclass(frozen=True) class Requirement: app: str # name of the required app — must resolve in installed/catalog/bundled @@ -62,6 +77,7 @@ class Manifest: # no frontend (CLI-only, background workers) leave this empty. open_url: str = "" requires: tuple[Requirement, ...] = field(default_factory=tuple) + internet: Internet = field(default_factory=lambda: Internet(viable=False, port=None)) def volume_name(self, short: str) -> str: # Namespace volume names so two apps can each declare e.g. "data" @@ -155,6 +171,27 @@ def _parse_requires(raw: object, manifest_path: Path, self_name: str) -> tuple[R return tuple(out) +def _parse_internet(raw: object, manifest_path: Path, ports: list[int]) -> Internet: + if raw is None: + return Internet(viable=False, port=None) + if not isinstance(raw, dict): + raise ManifestError(f"{manifest_path}: internet must be an object") + + viable = bool(raw.get("viable", False)) + port = raw.get("port") + + if not viable: + return Internet(viable=False, port=None) + + if not isinstance(port, int) or isinstance(port, bool): + raise ManifestError(f"{manifest_path}: internet.port must be an integer when viable") + if port not in ports: + raise ManifestError( + f"{manifest_path}: internet.port {port} must be one of the declared ports {ports}" + ) + return Internet(viable=True, port=port) + + def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: """Parse and validate a manifest.json. @@ -190,6 +227,7 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: settings = _parse_settings(raw.get("settings"), path) requires = _parse_requires(raw.get("requires"), path, name) + internet = _parse_internet(raw.get("internet"), path, ports) open_url_raw = raw.get("open_url", "") if not isinstance(open_url_raw, str): @@ -207,4 +245,5 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: settings=settings, open_url=open_url_raw, requires=requires, + internet=internet, ) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 1eccd2f..9a2070a 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -56,23 +56,27 @@ def _make_catalog_tarball( *, apps: list[tuple[str, dict]] | None = None, extra_entries: list[tuple[str, bytes]] | None = None, + compose_overrides: dict[str, bytes] | None = None, ) -> None: """Build a minimal valid catalog tarball. `apps` is a list of (folder_name, manifest_dict). Each app folder gets a `manifest.json` + a stub `docker-compose.yaml` + `icon.svg`. + `compose_overrides` (folder_name -> bytes) replaces that stub content + for specific apps — for testing catalog-side compose checks (e.g. + `internet.viable` + `privileged: true`) without needing a real image. `extra_entries` lets tests inject malformed content (path-traversal, missing VERSION, ...) without rebuilding the helper. """ apps = apps if apps is not None else [("fileshare", _manifest())] + compose_overrides = compose_overrides or {} buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tf: entries: list[tuple[str, bytes]] = [("VERSION", f"{version}\n".encode())] for folder, m in apps: entries.append((f"apps/{folder}/manifest.json", json.dumps(m).encode())) - entries.append( - (f"apps/{folder}/docker-compose.yaml", b"services:\n app:\n image: scratch\n") - ) + compose = compose_overrides.get(folder, b"services:\n app:\n image: scratch\n") + entries.append((f"apps/{folder}/docker-compose.yaml", compose)) entries.append((f"apps/{folder}/icon.svg", b"")) if extra_entries: entries.extend(extra_entries) @@ -266,6 +270,76 @@ def test_sync_catalog_refuses_tarball_with_invalid_manifest(catalog, monkeypatch assert not catalog.catalog_dir().exists() +def test_sync_catalog_refuses_privileged_internet_viable_app(catalog, monkeypatch, tmp_path): + import hashlib + + viable_manifest = dict(_manifest(), internet={"viable": True, "port": 445}) + + tarball_path = tmp_path / "tarball.tar.gz" + _make_catalog_tarball( + tarball_path, + "26.6", + apps=[("fileshare", viable_manifest)], + compose_overrides={ + "fileshare": b"services:\n app:\n image: scratch\n privileged: true\n" + }, + ) + tarball_bytes = tarball_path.read_bytes() + sha = hashlib.sha256(tarball_bytes).hexdigest() + + _stub_forgejo_release(monkeypatch, catalog, tag="26.6") + _stub_download( + monkeypatch, + catalog, + { + "https://forgejo.test.local/t.tar.gz": tarball_bytes, + "https://forgejo.test.local/t.tar.gz.sha256": ( + f"{sha} furtka-apps-26.6.tar.gz\n".encode() + ), + }, + ) + + with pytest.raises(catalog.CatalogError, match="must not run privileged"): + catalog.sync_catalog() + assert not catalog.catalog_dir().exists() + + +def test_sync_catalog_allows_privileged_app_that_is_not_internet_viable( + catalog, monkeypatch, tmp_path +): + """privileged: true is only disqualifying for internet.viable apps — + home-assistant's real catalog entry runs privileged for USB/Bluetooth + access and is simply not marked viable.""" + import hashlib + + tarball_path = tmp_path / "tarball.tar.gz" + _make_catalog_tarball( + tarball_path, + "26.6", + apps=[("fileshare", _manifest())], + compose_overrides={ + "fileshare": b"services:\n app:\n image: scratch\n privileged: true\n" + }, + ) + tarball_bytes = tarball_path.read_bytes() + sha = hashlib.sha256(tarball_bytes).hexdigest() + + _stub_forgejo_release(monkeypatch, catalog, tag="26.6") + _stub_download( + monkeypatch, + catalog, + { + "https://forgejo.test.local/t.tar.gz": tarball_bytes, + "https://forgejo.test.local/t.tar.gz.sha256": ( + f"{sha} furtka-apps-26.6.tar.gz\n".encode() + ), + }, + ) + + catalog.sync_catalog() + assert catalog.catalog_dir().exists() + + def test_sync_catalog_preserves_existing_catalog_on_failure(catalog, monkeypatch, tmp_path): """A failed sync must leave the previous live catalog intact so boxes keep working until the next successful sync.""" diff --git a/tests/test_manifest.py b/tests/test_manifest.py index d089528..3214757 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -292,3 +292,57 @@ def test_requires_rejects_empty_hook_string(tmp_path): path = _write_app(tmp_path, "z2m", payload) with pytest.raises(ManifestError, match="non-empty string"): load_manifest(path) + + +def test_internet_optional_defaults_non_viable(tmp_path): + path = _write_app(tmp_path, "fileshare", VALID_MANIFEST) + m = load_manifest(path) + assert m.internet.viable is False + assert m.internet.port is None + + +def test_internet_viable_parsed(tmp_path): + payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 445}) + path = _write_app(tmp_path, "fileshare", payload) + m = load_manifest(path) + assert m.internet.viable is True + assert m.internet.port == 445 + + +def test_internet_non_viable_ignores_port(tmp_path): + # A non-viable app doesn't need a port at all — and if one is present + # anyway (e.g. a stale field left behind), it's simply ignored rather + # than validated, since it has no effect either way. + payload = dict(VALID_MANIFEST, internet={"viable": False, "port": 9999}) + path = _write_app(tmp_path, "fileshare", payload) + m = load_manifest(path) + assert m.internet.viable is False + assert m.internet.port is None + + +def test_internet_viable_requires_port(tmp_path): + payload = dict(VALID_MANIFEST, internet={"viable": True}) + path = _write_app(tmp_path, "fileshare", payload) + with pytest.raises(ManifestError, match="internet.port must be an integer"): + load_manifest(path) + + +def test_internet_port_must_be_declared(tmp_path): + payload = dict(VALID_MANIFEST, internet={"viable": True, "port": 9999}) + path = _write_app(tmp_path, "fileshare", payload) + with pytest.raises(ManifestError, match="must be one of the declared ports"): + load_manifest(path) + + +def test_internet_non_object_rejected(tmp_path): + payload = dict(VALID_MANIFEST, internet=True) + path = _write_app(tmp_path, "fileshare", payload) + with pytest.raises(ManifestError, match="internet must be an object"): + load_manifest(path) + + +def test_internet_port_must_be_int_not_bool(tmp_path): + payload = dict(VALID_MANIFEST, internet={"viable": True, "port": True}) + path = _write_app(tmp_path, "fileshare", payload) + with pytest.raises(ManifestError, match="internet.port must be an integer"): + load_manifest(path)