dev_gateway #22
4 changed files with 188 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue