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