dev_gateway #22

Merged
robert merged 14 commits from dev_gateway into main 2026-10-07 16:34:47 +02:00
4 changed files with 188 additions and 4 deletions
Showing only changes of commit 43bcf3c4e5 - Show all commits

View file

@ -34,6 +34,7 @@ from __future__ import annotations
import fcntl import fcntl
import json import json
import os import os
import re
import shutil import shutil
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -150,6 +151,9 @@ def acquire_lock():
return fh return fh
_PRIVILEGED_RE = re.compile(r"^\s*privileged:\s*true\s*$", re.MULTILINE)
def _validate_staging(staging: Path, expected_version: str) -> None: def _validate_staging(staging: Path, expected_version: str) -> None:
"""Fail hard if the staging tree isn't a well-formed catalog release.""" """Fail hard if the staging tree isn't a well-formed catalog release."""
version_file = staging / _VERSION_FILE version_file = staging / _VERSION_FILE
@ -170,10 +174,23 @@ def _validate_staging(staging: Path, expected_version: str) -> None:
if not manifest_path.exists(): if not manifest_path.exists():
raise CatalogError(f"catalog app {entry.name!r} has no manifest.json") raise CatalogError(f"catalog app {entry.name!r} has no manifest.json")
try: try:
load_manifest(manifest_path, expected_name=entry.name) manifest = load_manifest(manifest_path, expected_name=entry.name)
except ManifestError as e: except ManifestError as e:
raise CatalogError(f"catalog app {entry.name!r}: invalid manifest: {e}") from 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: def _atomic_swap(staging: Path) -> None:
"""Move staging → live catalog, keeping the previous tree as .prev until """Move staging → live catalog, keeping the previous tree as .prev until

View file

@ -32,6 +32,21 @@ class Setting:
default: str | None 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) @dataclass(frozen=True)
class Requirement: class Requirement:
app: str # name of the required app — must resolve in installed/catalog/bundled 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. # no frontend (CLI-only, background workers) leave this empty.
open_url: str = "" open_url: str = ""
requires: tuple[Requirement, ...] = field(default_factory=tuple) 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: def volume_name(self, short: str) -> str:
# Namespace volume names so two apps can each declare e.g. "data" # 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) 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: def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
"""Parse and validate a manifest.json. """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) settings = _parse_settings(raw.get("settings"), path)
requires = _parse_requires(raw.get("requires"), path, name) requires = _parse_requires(raw.get("requires"), path, name)
internet = _parse_internet(raw.get("internet"), path, ports)
open_url_raw = raw.get("open_url", "") open_url_raw = raw.get("open_url", "")
if not isinstance(open_url_raw, str): if not isinstance(open_url_raw, str):
@ -207,4 +245,5 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
settings=settings, settings=settings,
open_url=open_url_raw, open_url=open_url_raw,
requires=requires, requires=requires,
internet=internet,
) )

View file

@ -56,23 +56,27 @@ def _make_catalog_tarball(
*, *,
apps: list[tuple[str, dict]] | None = None, apps: list[tuple[str, dict]] | None = None,
extra_entries: list[tuple[str, bytes]] | None = None, extra_entries: list[tuple[str, bytes]] | None = None,
compose_overrides: dict[str, bytes] | None = None,
) -> None: ) -> None:
"""Build a minimal valid catalog tarball. """Build a minimal valid catalog tarball.
`apps` is a list of (folder_name, manifest_dict). Each app folder gets `apps` is a list of (folder_name, manifest_dict). Each app folder gets
a `manifest.json` + a stub `docker-compose.yaml` + `icon.svg`. 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, `extra_entries` lets tests inject malformed content (path-traversal,
missing VERSION, ...) without rebuilding the helper. missing VERSION, ...) without rebuilding the helper.
""" """
apps = apps if apps is not None else [("fileshare", _manifest())] apps = apps if apps is not None else [("fileshare", _manifest())]
compose_overrides = compose_overrides or {}
buf = io.BytesIO() buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf: with tarfile.open(fileobj=buf, mode="w:gz") as tf:
entries: list[tuple[str, bytes]] = [("VERSION", f"{version}\n".encode())] entries: list[tuple[str, bytes]] = [("VERSION", f"{version}\n".encode())]
for folder, m in apps: for folder, m in apps:
entries.append((f"apps/{folder}/manifest.json", json.dumps(m).encode())) entries.append((f"apps/{folder}/manifest.json", json.dumps(m).encode()))
entries.append( compose = compose_overrides.get(folder, b"services:\n app:\n image: scratch\n")
(f"apps/{folder}/docker-compose.yaml", 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"<svg/>")) entries.append((f"apps/{folder}/icon.svg", b"<svg/>"))
if extra_entries: if extra_entries:
entries.extend(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() 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): 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 """A failed sync must leave the previous live catalog intact so boxes
keep working until the next successful sync.""" keep working until the next successful sync."""

View file

@ -292,3 +292,57 @@ def test_requires_rejects_empty_hook_string(tmp_path):
path = _write_app(tmp_path, "z2m", payload) path = _write_app(tmp_path, "z2m", payload)
with pytest.raises(ManifestError, match="non-empty string"): with pytest.raises(ManifestError, match="non-empty string"):
load_manifest(path) 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)