feat(system): NTP-gate state on /api/v1/system/appliance

Extends the appliance endpoint that landed in the previous commit with a
  time_synced field, sourced from /run/bambuddy/time-synced (the appliance's
  ntp-gate.sh writes this once chronyd reports sync, or with a "warning"
  marker after the 3-minute timeout). 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.
  Exposing the gate lets the SPA render a "time not synced" indicator while
  that's still true and clear it once "ok" comes through.

  backend/app/core/local_config.py

  New read_ntp_gate(path) function alongside read_local_toml. Three states:

    "ok"       chrony reported sync within the 3-minute window
    "warning"  3-minute timeout elapsed without sync; user already waited
               and the wizard proceeded with a degraded clock
    None       file absent (non-appliance install), OSError, empty content,
               unknown marker, or binary garbage -- "unknown / don't gate"

  Defensive read mode (errors="replace") survives non-utf8 content without
  crashing. Module docstring broadened from "local.toml reader" to "small
  readers for appliance-set state files".

  backend/app/api/routes/system.py

  /system/appliance now returns:

    {hostname, timezone, locale, time_synced}

  with the same no-auth posture: bootstrap surfaces (i18n init, time-sync
  banner) read this before auth might be set up, and the contents are
  non-secret (user-set defaults + a public sync flag). The endpoint
  docstring expands to explain the RTC motivation -- otherwise the
  time_synced field reads like a leftover.
This commit is contained in:
maziggy 2026-06-22 14:23:30 +02:00
parent f4a4d6dceb
commit 4dcd37bc87
5 changed files with 186 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

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