diff --git a/CHANGELOG.md b/CHANGELOG.md index f0eb037f4..492beac7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added +- **NTP-gate state exposed on the appliance endpoint** — `GET /api/v1/system/appliance` gains a `time_synced` field returning `"ok"`, `"warning"`, or `null`. Source: `/run/bambuddy/time-synced`, written by the appliance's `ntp-gate.sh` once chronyd reports sync (or after a 3-minute timeout with a `"warning"` marker). The RPi 5 has no battery-backed RTC, so on a fresh boot the system clock is wrong until NTP catches up — JWT expiries and TLS certificate validity windows depend on this being right. New `backend/app/core/local_config.py::read_ntp_gate` is defensive on every failure mode (file absent → `None`, OSError → `None` + warning log, empty / unknown content → `None`, binary garbage survives via `errors="replace"`). The endpoint stays no-auth; the SPA can use the field to render a "time not synced" badge on a fresh appliance before swapping to normal status once `"ok"` comes through. 8 new unit cases for `read_ntp_gate` (absent / ok / warning-suffixed / warning-only / empty / unknown-marker / leading-whitespace / binary-garbage) and 3 new integration cases for the endpoint field (ok / warning / absent). On Docker / manual installs the gate file doesn't exist so this is a no-op (`time_synced` is `null`) — the appliance is the only consumer for now. - **Appliance locale defaults endpoint** — `GET /api/v1/system/appliance` returns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into `/etc/bambuddy/local.toml` during firstboot. New `backend/app/core/local_config.py::read_local_toml` parses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns `{hostname, timezone, locale}` with `null` for any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend, `i18n/index.ts` runs a one-shot `applyApplianceLocale()` hook after init: gated by a `bambuddy_appliance_locale_consumed` localStorage flag so it runs exactly once per appliance, fetches the endpoint, and `i18n.changeLanguage(...)`s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo: `bambuddy-appliance`); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh via `hostnamectl`/`timedatectl` and don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required). ### Security diff --git a/backend/app/api/routes/system.py b/backend/app/api/routes/system.py index fe3a77ab0..670ebe4f2 100644 --- a/backend/app/api/routes/system.py +++ b/backend/app/api/routes/system.py @@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.auth import RequirePermissionIfAuthEnabled from backend.app.core.config import APP_VERSION, settings from backend.app.core.database import get_db -from backend.app.core.local_config import read_local_toml +from backend.app.core.local_config import read_local_toml, read_ntp_gate from backend.app.core.permissions import Permission from backend.app.models.archive import PrintArchive from backend.app.models.filament import Filament @@ -608,16 +608,26 @@ async def get_system_health( @router.get("/appliance") async def get_appliance_defaults(): - """Expose the hostname/timezone/locale the appliance setup wizard collected. + """Expose appliance-set state for the SPA's bootstrap surface. - Read from /etc/bambuddy/local.toml; absent on non-appliance installs, in - which case all fields are null. No auth required — the frontend i18n - bootstrap reads this BEFORE auth might be set up, and the contents are - purely user-set defaults (no secrets). + Two file sources, both optional and silently degraded when absent: + + - ``/etc/bambuddy/local.toml`` — hostname / timezone / locale the + firstboot wizard collected. + - ``/run/bambuddy/time-synced`` — chrony NTP gate state. The RPi 5 has + no battery-backed RTC, so on a fresh boot the clock is wrong until + ntp-gate.sh writes "ok" (or "warning" if 3-minute timeout elapsed). + A warning state means JWT expiries and TLS validity windows may be + misaligned; the UI should surface this. + + No auth required — the frontend bootstrap reads this BEFORE auth might + be set up, and the contents are user-set defaults plus a public sync + flag (no secrets). """ config = read_local_toml() return { "hostname": config.get("hostname"), "timezone": config.get("timezone"), "locale": config.get("locale"), + "time_synced": read_ntp_gate(), } diff --git a/backend/app/core/local_config.py b/backend/app/core/local_config.py index 726189f3a..97adf498d 100644 --- a/backend/app/core/local_config.py +++ b/backend/app/core/local_config.py @@ -1,33 +1,43 @@ """ -Read /etc/bambuddy/local.toml — the file the appliance setup wizard writes -during firstboot to capture the user's hostname, timezone, and locale. +Small readers for appliance-set state files. + +Two distinct surfaces, same shape (defensive, silent on missing files, +side-effect-free): + +- ``read_local_toml`` reads ``/etc/bambuddy/local.toml`` (the file the + appliance setup wizard writes during firstboot with the user's hostname, + timezone, and locale). +- ``read_ntp_gate`` reads ``/run/bambuddy/time-synced`` (the appliance's + ntp-gate.sh signals time-sync state here once chrony reports sync, or + when the 3-minute timeout elapses with a "warning" marker). Universal across install shapes: -- On the Bambuddy Appliance: the wizard writes this file before bambuddy.service - starts; we read it on every startup to surface defaults to the frontend. -- On Docker / manual installs: the file is absent; we degrade silently. An - operator who wants to seed defaults can drop their own local.toml into the - expected path or override via DATA_DIR. +- On the Bambuddy Appliance: both files exist by the time bambuddy.service + starts; we surface their values to the frontend. +- On Docker / manual installs: both files are absent; we degrade silently. -The reader is read-only and side-effect-free. It does NOT call hostnamectl -or timedatectl — that's the appliance's firstboot.sh responsibility (it has -the root privileges to do so and runs before this process exists). What we -do here is expose the values the wizard collected so the frontend i18n -bootstrap can pick the right initial language. +These readers are read-only and side-effect-free. They do NOT call +hostnamectl / timedatectl / chronyc — system-state changes are the +appliance's firstboot.sh responsibility (root, runs before this process +exists). Here we just expose state so the frontend can render accordingly. """ from __future__ import annotations import logging from pathlib import Path -from typing import TypedDict +from typing import Literal, TypedDict import tomllib log = logging.getLogger(__name__) DEFAULT_PATH = Path("/etc/bambuddy/local.toml") +DEFAULT_NTP_GATE_PATH = Path("/run/bambuddy/time-synced") + +# Three states: synced ("ok"), gated-and-timed-out ("warning"), or unknown (None). +TimeSyncState = Literal["ok", "warning"] | None class LocalConfig(TypedDict, total=False): @@ -62,3 +72,31 @@ def read_local_toml(path: Path = DEFAULT_PATH) -> LocalConfig: continue result[key] = value # type: ignore[literal-required] return result + + +def read_ntp_gate(path: Path = DEFAULT_NTP_GATE_PATH) -> TimeSyncState: + """Read the appliance NTP gate file. Returns "ok", "warning", or None. + + Wire contract with bambuddy-appliance/firstboot/ntp-gate.sh: + - File absent: gate hasn't been evaluated yet, or this isn't an appliance + install. Caller should treat as "unknown / don't gate." + - File content starts with "ok": chrony reported sync within 3 minutes. + - File content starts with "warning": 3-minute timeout elapsed without + sync. The user has already waited and the wizard proceeded with a + degraded clock — auth tokens may have incorrect expiry, TLS certs may + fail validation. UI should surface this. + - Anything else: defensive fall-through to None. + """ + try: + body = path.read_text(errors="replace").strip() + except FileNotFoundError: + return None + except OSError as exc: + log.warning("ntp-gate file at %s could not be read: %s", path, exc) + return None + + if body.startswith("ok"): + return "ok" + if body.startswith("warning"): + return "warning" + return None diff --git a/backend/tests/integration/test_system_api.py b/backend/tests/integration/test_system_api.py index 0dac70052..e77b083b6 100644 --- a/backend/tests/integration/test_system_api.py +++ b/backend/tests/integration/test_system_api.py @@ -497,7 +497,7 @@ class TestSystemApplianceAPI: assert response.status_code == 200 body = response.json() - assert body == {"hostname": None, "timezone": None, "locale": None} + assert body == {"hostname": None, "timezone": None, "locale": None, "time_synced": None} @pytest.mark.asyncio @pytest.mark.integration @@ -513,11 +513,12 @@ class TestSystemApplianceAPI: response = await async_client.get("/api/v1/system/appliance") assert response.status_code == 200 - assert response.json() == { - "hostname": "workshop-pi", - "timezone": "Europe/Berlin", - "locale": "de", - } + body = response.json() + assert body["hostname"] == "workshop-pi" + assert body["timezone"] == "Europe/Berlin" + assert body["locale"] == "de" + # time_synced state is host-dependent in this test; just assert the field exists. + assert "time_synced" in body @pytest.mark.asyncio @pytest.mark.integration @@ -548,3 +549,57 @@ class TestSystemApplianceAPI: """ response = await async_client.get("/api/v1/system/appliance") assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_appliance_endpoint_time_synced_ok(self, async_client: AsyncClient, tmp_path, monkeypatch): + """NTP gate written by ntp-gate.sh with 'ok' surfaces as time_synced='ok'.""" + from backend.app.api.routes import system as system_routes + from backend.app.core import local_config + + gate = tmp_path / "time-synced" + gate.write_text("ok\n") + monkeypatch.setattr(system_routes, "read_ntp_gate", lambda: local_config.read_ntp_gate(gate)) + + response = await async_client.get("/api/v1/system/appliance") + assert response.status_code == 200 + assert response.json()["time_synced"] == "ok" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_appliance_endpoint_time_synced_warning( + self, + async_client: AsyncClient, + tmp_path, + monkeypatch, + ): + """3-minute NTP timeout marker surfaces as time_synced='warning'.""" + from backend.app.api.routes import system as system_routes + from backend.app.core import local_config + + gate = tmp_path / "time-synced" + gate.write_text("warning: ntp sync timed out\n") + monkeypatch.setattr(system_routes, "read_ntp_gate", lambda: local_config.read_ntp_gate(gate)) + + response = await async_client.get("/api/v1/system/appliance") + assert response.status_code == 200 + assert response.json()["time_synced"] == "warning" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_appliance_endpoint_time_synced_absent( + self, + async_client: AsyncClient, + tmp_path, + monkeypatch, + ): + """Non-appliance install: no gate file -> time_synced is null.""" + from backend.app.api.routes import system as system_routes + from backend.app.core import local_config + + absent = tmp_path / "no-gate-here" + monkeypatch.setattr(system_routes, "read_ntp_gate", lambda: local_config.read_ntp_gate(absent)) + + response = await async_client.get("/api/v1/system/appliance") + assert response.status_code == 200 + assert response.json()["time_synced"] is None diff --git a/backend/tests/unit/test_local_config.py b/backend/tests/unit/test_local_config.py index 7423b1a21..47879e182 100644 --- a/backend/tests/unit/test_local_config.py +++ b/backend/tests/unit/test_local_config.py @@ -12,7 +12,7 @@ from pathlib import Path import pytest -from backend.app.core.local_config import read_local_toml +from backend.app.core.local_config import read_local_toml, read_ntp_gate def test_missing_file_returns_empty(tmp_path: Path): @@ -90,3 +90,59 @@ def test_escaped_characters_round_trip(tmp_path: Path): path.write_text('hostname = "with\\"quote"\n') result = read_local_toml(path) assert result == {"hostname": 'with"quote'} + + +# --------------------------------------------------------------------------- +# read_ntp_gate +# --------------------------------------------------------------------------- + + +def test_ntp_gate_missing_returns_none(tmp_path: Path): + assert read_ntp_gate(tmp_path / "absent") is None + + +def test_ntp_gate_ok(tmp_path: Path): + path = tmp_path / "time-synced" + path.write_text("ok\n") + assert read_ntp_gate(path) == "ok" + + +def test_ntp_gate_warning(tmp_path: Path): + path = tmp_path / "time-synced" + path.write_text("warning: ntp sync timed out\n") + assert read_ntp_gate(path) == "warning" + + +def test_ntp_gate_warning_no_suffix(tmp_path: Path): + """Just 'warning' on its own is also accepted.""" + path = tmp_path / "time-synced" + path.write_text("warning\n") + assert read_ntp_gate(path) == "warning" + + +def test_ntp_gate_empty_returns_none(tmp_path: Path): + """Empty / surprise content is treated as unknown rather than misclassified.""" + path = tmp_path / "time-synced" + path.write_text("") + assert read_ntp_gate(path) is None + + +def test_ntp_gate_unknown_marker_returns_none(tmp_path: Path): + path = tmp_path / "time-synced" + path.write_text("synced via remote NTP\n") # neither 'ok' nor 'warning' + assert read_ntp_gate(path) is None + + +def test_ntp_gate_strips_whitespace(tmp_path: Path): + """Leading whitespace shouldn't trick a startswith check.""" + path = tmp_path / "time-synced" + path.write_text(" ok\n") + assert read_ntp_gate(path) == "ok" + + +def test_ntp_gate_binary_garbage_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture): + """Defensive read mode survives non-utf8 content without crashing.""" + path = tmp_path / "time-synced" + path.write_bytes(b"\xff\xfe\x00\x01ok\n") + # errors="replace" maps the bytes through but the prefix is no longer 'ok'. + assert read_ntp_gate(path) is None