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 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

View file

@ -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,
)

View file

@ -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"<svg/>"))
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."""

View file

@ -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)