diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/BASE_STATION.md b/LifeTrac-v25/DESIGN-CONTROLLER/BASE_STATION.md index 621fe302..8fb5d8f0 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/BASE_STATION.md +++ b/LifeTrac-v25/DESIGN-CONTROLLER/BASE_STATION.md @@ -189,6 +189,126 @@ All topics under `lifetrac/v25/`. Mosquitto runs in a container on the X8 and th - Firewall: allow inbound 80/443 from LAN only; Mosquitto 1883 LAN-only; never expose to public internet - TLS: self-signed cert acceptable for LAN; use Let's Encrypt only if the base station is behind a publicly-routable hostname +## HTTPS / PWA setup + +The operator console ships as a **Progressive Web App (PWA)**: it includes a +web-app manifest (`/manifest.json`) and a service worker (`/sw.js`) that +caches the app shell for fast load and provides a graceful offline page when +the base station is temporarily unreachable. + +**PWA features require a secure origin** (HTTPS or `http://localhost`). On a +LAN the practical options are: + +### Option A — Self-signed certificate + `/setup` page (recommended) + +**1. Generate the cert on the base station** (one-time): + +```sh +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout /etc/lifetrac/key.pem \ + -out /etc/lifetrac/cert.pem \ + -days 3650 \ + -subj "/CN=lifetrac-base" \ + -addext "subjectAltName=IP:192.168.1.42,DNS:lifetrac-base.local,DNS:localhost" +``` + +Replace `192.168.1.42` with the base station's actual LAN IP. Add additional +`IP:` entries if the address can vary. + +**2. Start uvicorn with TLS:** + +```sh +uvicorn web_ui:app --host 0.0.0.0 --port 8443 \ + --ssl-keyfile /etc/lifetrac/key.pem \ + --ssl-certfile /etc/lifetrac/cert.pem \ + --env-var LIFETRAC_TLS_CERT=/etc/lifetrac/cert.pem \ + --env-var LIFETRAC_HTTPS_PORT=8443 +``` + +Or export the env vars separately before launching uvicorn: + +```sh +export LIFETRAC_TLS_CERT=/etc/lifetrac/cert.pem +export LIFETRAC_HTTPS_PORT=8443 +uvicorn web_ui:app --host 0.0.0.0 --port 8443 \ + --ssl-keyfile /etc/lifetrac/key.pem \ + --ssl-certfile /etc/lifetrac/cert.pem +``` + +**3. Use the built-in `/setup` page to distribute the cert:** + +Navigate to `http://:8080/setup` on each operator device. +The page: + +- Shows a **Download lifetrac-cert.pem** button that fetches the public cert + directly from the base station over plain HTTP. +- Provides step-by-step install instructions for Android, iPhone/iPad, Windows, + macOS, and Linux. +- Has an **Open HTTPS Site** button that links directly to + `https://:8443/` once the cert is installed. + +> **Security note:** Only the public certificate is served at `/cert.pem`. +> The private key is never exposed through the web UI. + +**4. Install the cert on each phone (one-time per device):** + +- **Android:** tap the downloaded `.pem` → follow the CA certificate installer + → Chrome trusts it immediately. +- **iOS:** open the `.pem` from Files or Mail → Settings → General → VPN & + Device Management → install profile → Settings → About → Certificate Trust + Settings → toggle on. + +After the cert is trusted, browse to `https://:8443/`, then use **Add to +Home Screen** (Android: ⋮ menu; iOS: Share → Add to Home Screen). The app +launches in landscape full-screen mode. + +### Option B — `mkcert` (easiest if you can install tools) + +[`mkcert`](https://github.com/FiloSottile/mkcert) creates a local CA that is +automatically trusted by the OS cert store on the machine where it runs, and +the CA cert can be imported to phones with one step: + +```sh +# On the base-station device: +apt install mkcert # or brew install mkcert +mkcert -install # installs local CA into the system store +mkcert 192.168.1.42 lifetrac-base.local localhost + +# Use the generated cert with uvicorn: +uvicorn web_ui:app --host 0.0.0.0 --port 8443 \ + --ssl-keyfile 192.168.1.42+2-key.pem \ + --ssl-certfile 192.168.1.42+2.pem +``` + +Distribute `~/.local/share/mkcert/rootCA.pem` (Linux path) or +`$(mkcert -CAROOT)/rootCA.pem` to each phone using the same steps as Option A, +step 4. Phones that trust the `mkcert` root CA will trust *all* certs +generated by it, so you only distribute the root CA once. + +### Option C — Reverse proxy (nginx or Caddy) + +Place an HTTPS proxy in front of the FastAPI server on port 8080. Caddy with +`tls internal` issues a locally-trusted cert automatically if the `mkcert` +root CA has been installed on client devices. + +### Option D — localhost only + +If the operator tablet *is* the base-station device, access the UI at +`http://localhost:8080`. Browsers treat `localhost` as a secure origin and +the service worker will register normally without any certificate setup. + +### What works without HTTPS + +The full operator UI — joystick control, live telemetry, video stream — works +over plain HTTP. Only the PWA-specific features are gated on a secure origin: + +| Feature | Plain HTTP | HTTPS / localhost | +|--------------------------|:----------:|:-----------------:| +| Operator console | ✅ | ✅ | +| Manifest (install prompt)| ✅ link only| ✅ with install | +| Service worker / offline | ❌ | ✅ | +| Add to Home Screen | ❌ | ✅ | + ## Link-loss behavior When the LoRa link to the tractor is lost (no telemetry for 30 s), the base UI shows a banner, stops sending joystick `ControlFrame`s, and leaves E-stop available. There is no Cat-M1 or MQTT-over-cellular fallback in v25; recovery is LoRa link restoration or a local hardware/service intervention. diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_web_ui_pwa.py b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_web_ui_pwa.py new file mode 100644 index 00000000..d4e2b9bc --- /dev/null +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_web_ui_pwa.py @@ -0,0 +1,399 @@ +"""Tests for the PWA service-worker and manifest routes. + +Covers the /sw.js and /manifest.json root-level endpoints added to +enable Progressive Web App features on the base-station operator console. +""" + +from __future__ import annotations + +import importlib +import json +import os +import sys +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +try: + import paho.mqtt.client # noqa: F401 + import fastapi # noqa: F401 + from fastapi.testclient import TestClient # noqa: F401 +except ImportError: + raise unittest.SkipTest("paho-mqtt + fastapi required for web_ui PWA tests") + + +class PwaRoutesTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + os.environ["LIFETRAC_PIN"] = "424242" + with mock.patch("paho.mqtt.client.Client") as mqtt_class: + instance = mqtt_class.return_value + instance.connect = mock.MagicMock() + instance.loop_start = mock.MagicMock() + instance.subscribe = mock.MagicMock() + instance.publish = mock.MagicMock() + import web_ui + importlib.reload(web_ui) + cls.client = TestClient(web_ui.app) + + # ---- service worker ------------------------------------------------ + + def test_sw_js_returns_200(self): + r = self.client.get("/sw.js") + self.assertEqual(r.status_code, 200) + + def test_sw_js_content_type_is_javascript(self): + r = self.client.get("/sw.js") + self.assertIn("javascript", r.headers.get("content-type", "")) + + def test_sw_js_service_worker_allowed_header_is_root(self): + """Service-Worker-Allowed must be '/' so the SW can control all pages.""" + r = self.client.get("/sw.js") + self.assertEqual(r.headers.get("service-worker-allowed"), "/") + + def test_sw_js_cache_control_prevents_caching(self): + """The SW file itself must never be cached by the browser.""" + r = self.client.get("/sw.js") + cc = r.headers.get("cache-control", "") + self.assertIn("no-cache", cc) + + def test_sw_js_body_contains_cache_name(self): + r = self.client.get("/sw.js") + self.assertIn("CACHE_NAME", r.text) + + def test_sw_js_never_intercepts_api_or_ws(self): + """The SW source must explicitly skip /api/ and /ws/ paths.""" + r = self.client.get("/sw.js") + self.assertIn("/api/", r.text) + self.assertIn("/ws/", r.text) + + # ---- manifest ------------------------------------------------------- + + def test_manifest_returns_200(self): + r = self.client.get("/manifest.json") + self.assertEqual(r.status_code, 200) + + def test_manifest_content_type(self): + r = self.client.get("/manifest.json") + ct = r.headers.get("content-type", "") + self.assertIn("manifest+json", ct) + + def test_manifest_required_fields(self): + r = self.client.get("/manifest.json") + m = r.json() + self.assertIn("name", m) + self.assertIn("short_name", m) + self.assertIn("start_url", m) + self.assertIn("display", m) + self.assertIn("icons", m) + + def test_manifest_start_url_is_root(self): + r = self.client.get("/manifest.json") + self.assertEqual(r.json()["start_url"], "/") + + def test_manifest_icons_reference_static_path(self): + r = self.client.get("/manifest.json") + icons = r.json().get("icons", []) + self.assertTrue(len(icons) >= 1, "manifest must declare at least one icon") + for icon in icons: + self.assertTrue( + icon["src"].startswith("/static/"), + f"icon src should be under /static/: {icon['src']}", + ) + + def test_manifest_display_is_standalone_or_fullscreen(self): + r = self.client.get("/manifest.json") + self.assertIn(r.json()["display"], ("standalone", "fullscreen", "minimal-ui")) + + # ---- offline page via static mount ---------------------------------- + + def test_offline_html_accessible(self): + """The offline fallback page must be reachable without a session cookie.""" + r = self.client.get("/static/offline.html") + self.assertEqual(r.status_code, 200) + self.assertIn("text/html", r.headers.get("content-type", "")) + + def test_offline_html_has_reload_button(self): + r = self.client.get("/static/offline.html") + self.assertIn("reload", r.text.lower()) + + # ---- icon accessible ----------------------------------------------- + + def test_icon_svg_accessible(self): + r = self.client.get("/static/icons/icon.svg") + self.assertEqual(r.status_code, 200) + self.assertIn("svg", r.headers.get("content-type", "").lower()) + + # ---- /cert.pem download endpoint ------------------------------------ + + def test_cert_pem_returns_404_when_no_cert(self): + """Returns 404 with a hint when no TLS cert has been generated.""" + import web_ui as _wu + orig = _wu.CERT_PATH + try: + _wu.CERT_PATH = Path("/nonexistent/path/cert.pem") + r = self.client.get("/cert.pem") + self.assertEqual(r.status_code, 404) + finally: + _wu.CERT_PATH = orig + + def test_cert_pem_returns_pem_file_when_cert_exists(self): + """Returns the certificate file with correct headers when present.""" + import tempfile + import web_ui as _wu + orig = _wu.CERT_PATH + try: + with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: + f.write(b"-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n") + tmp_cert = Path(f.name) + _wu.CERT_PATH = tmp_cert + r = self.client.get("/cert.pem") + self.assertEqual(r.status_code, 200) + self.assertIn("pem", r.headers.get("content-type", "").lower()) + self.assertIn("attachment", r.headers.get("content-disposition", "").lower()) + self.assertIn("lifetrac-cert.pem", r.headers.get("content-disposition", "")) + self.assertIn(b"BEGIN CERTIFICATE", r.content) + finally: + _wu.CERT_PATH = orig + tmp_cert.unlink(missing_ok=True) + + def test_cert_pem_never_caches(self): + """Cache-Control must be no-store so stale certs are never used.""" + import tempfile + import web_ui as _wu + orig = _wu.CERT_PATH + try: + with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: + f.write(b"-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n") + tmp_cert = Path(f.name) + _wu.CERT_PATH = tmp_cert + r = self.client.get("/cert.pem") + self.assertIn("no-store", r.headers.get("cache-control", "")) + finally: + _wu.CERT_PATH = orig + tmp_cert.unlink(missing_ok=True) + + # ---- /setup page ---------------------------------------------------- + + def test_setup_page_accessible_without_session(self): + """/setup must be reachable without a login cookie.""" + r = self.client.get("/setup") + self.assertEqual(r.status_code, 200) + self.assertIn("text/html", r.headers.get("content-type", "")) + + def test_setup_page_injects_https_port(self): + """The server must inject the HTTPS port into the page.""" + import web_ui as _wu + orig = _wu.HTTPS_PORT + try: + _wu.HTTPS_PORT = 9443 + r = self.client.get("/setup") + self.assertIn("9443", r.text) + finally: + _wu.HTTPS_PORT = orig + + def test_setup_page_cert_available_true_when_cert_exists(self): + """cert_available flag is 'true' when the cert file is present.""" + import tempfile + import web_ui as _wu + orig = _wu.CERT_PATH + try: + with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: + f.write(b"-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n") + tmp_cert = Path(f.name) + _wu.CERT_PATH = tmp_cert + r = self.client.get("/setup") + self.assertIn("true", r.text) + finally: + _wu.CERT_PATH = orig + tmp_cert.unlink(missing_ok=True) + + def test_setup_page_cert_available_false_when_no_cert(self): + """cert_available flag is 'false' when no cert has been generated.""" + import web_ui as _wu + orig = _wu.CERT_PATH + try: + _wu.CERT_PATH = Path("/nonexistent/path/cert.pem") + r = self.client.get("/setup") + self.assertIn("false", r.text) + finally: + _wu.CERT_PATH = orig + + def test_setup_page_not_cached(self): + """Setup page must not be cached (cert state may change).""" + r = self.client.get("/setup") + self.assertIn("no-store", r.headers.get("cache-control", "")) + + def test_setup_page_contains_cert_download_link(self): + r = self.client.get("/setup") + self.assertIn("/cert.pem", r.text) + + def test_setup_page_contains_android_ios_instructions(self): + r = self.client.get("/setup") + self.assertIn("Android", r.text) + self.assertIn("iPhone", r.text) + + +class AutoCertTests(unittest.TestCase): + """Tests for _ensure_self_signed_cert() first-boot auto-generation.""" + + def _import_wu(self): + import web_ui as _wu + return _wu + + def test_no_op_when_cert_exists(self): + """Does nothing when CERT_PATH already points to an existing file.""" + import tempfile + _wu = self._import_wu() + orig_cert = _wu.CERT_PATH + orig_key = _wu.KEY_PATH + try: + with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: + f.write(b"-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n") + tmp_cert_path = Path(f.name) + _wu.CERT_PATH = tmp_cert_path + _wu.KEY_PATH = Path("/nonexistent/key.pem") + with mock.patch("subprocess.run") as mock_run: + _wu._ensure_self_signed_cert() + mock_run.assert_not_called() + finally: + _wu.CERT_PATH = orig_cert + _wu.KEY_PATH = orig_key + tmp_cert_path.unlink(missing_ok=True) + + def test_no_op_when_key_exists(self): + """Does nothing when KEY_PATH already points to an existing file.""" + import tempfile + _wu = self._import_wu() + orig_cert = _wu.CERT_PATH + orig_key = _wu.KEY_PATH + try: + with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as f: + f.write(b"-----BEGIN RSA PRIVATE KEY-----\nFAKE\n-----END RSA PRIVATE KEY-----\n") + tmp_key_path = Path(f.name) + _wu.CERT_PATH = Path("/nonexistent/cert.pem") + _wu.KEY_PATH = tmp_key_path + with mock.patch("subprocess.run") as mock_run: + _wu._ensure_self_signed_cert() + mock_run.assert_not_called() + finally: + _wu.CERT_PATH = orig_cert + _wu.KEY_PATH = orig_key + tmp_key_path.unlink(missing_ok=True) + + def test_calls_openssl_when_no_cert_or_key(self): + """Calls openssl when neither cert nor key exists.""" + import tempfile + _wu = self._import_wu() + orig_cert = _wu.CERT_PATH + orig_key = _wu.KEY_PATH + try: + with tempfile.TemporaryDirectory() as tmp_dir: + _wu.CERT_PATH = Path(tmp_dir) / "cert.pem" + _wu.KEY_PATH = Path(tmp_dir) / "key.pem" + mock_result = mock.MagicMock() + mock_result.returncode = 0 + with mock.patch("subprocess.run", return_value=mock_result) as mock_run: + _wu._ensure_self_signed_cert() + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + self.assertEqual(cmd[0], "openssl") + self.assertIn("-x509", cmd) + self.assertIn(str(_wu.CERT_PATH), cmd) + self.assertIn(str(_wu.KEY_PATH), cmd) + finally: + _wu.CERT_PATH = orig_cert + _wu.KEY_PATH = orig_key + + def test_openssl_subj_uses_hostname(self): + """The -subj CN must match the current hostname.""" + import socket + import tempfile + _wu = self._import_wu() + orig_cert = _wu.CERT_PATH + orig_key = _wu.KEY_PATH + try: + with tempfile.TemporaryDirectory() as tmp_dir: + _wu.CERT_PATH = Path(tmp_dir) / "cert.pem" + _wu.KEY_PATH = Path(tmp_dir) / "key.pem" + mock_result = mock.MagicMock() + mock_result.returncode = 0 + with mock.patch("subprocess.run", return_value=mock_result) as mock_run: + _wu._ensure_self_signed_cert() + cmd = mock_run.call_args[0][0] + subj_idx = cmd.index("-subj") + 1 + self.assertIn(socket.gethostname(), cmd[subj_idx]) + finally: + _wu.CERT_PATH = orig_cert + _wu.KEY_PATH = orig_key + + def test_graceful_when_openssl_missing(self): + """Logs a warning and returns without raising when openssl is absent.""" + import tempfile + _wu = self._import_wu() + orig_cert = _wu.CERT_PATH + orig_key = _wu.KEY_PATH + try: + with tempfile.TemporaryDirectory() as tmp_dir: + _wu.CERT_PATH = Path(tmp_dir) / "cert.pem" + _wu.KEY_PATH = Path(tmp_dir) / "key.pem" + with mock.patch("subprocess.run", side_effect=FileNotFoundError("openssl")): + # Must not raise + _wu._ensure_self_signed_cert() + finally: + _wu.CERT_PATH = orig_cert + _wu.KEY_PATH = orig_key + + def test_graceful_on_subprocess_error(self): + """Logs a warning and returns without raising on unexpected subprocess errors.""" + import tempfile + _wu = self._import_wu() + orig_cert = _wu.CERT_PATH + orig_key = _wu.KEY_PATH + try: + with tempfile.TemporaryDirectory() as tmp_dir: + _wu.CERT_PATH = Path(tmp_dir) / "cert.pem" + _wu.KEY_PATH = Path(tmp_dir) / "key.pem" + with mock.patch("subprocess.run", side_effect=OSError("permission denied")): + _wu._ensure_self_signed_cert() + finally: + _wu.CERT_PATH = orig_cert + _wu.KEY_PATH = orig_key + + def test_key_permissions_set_to_600_on_success(self): + """Private key gets chmod 600 after generation.""" + import tempfile + import stat + _wu = self._import_wu() + orig_cert = _wu.CERT_PATH + orig_key = _wu.KEY_PATH + try: + with tempfile.TemporaryDirectory() as tmp_dir: + cert_path = Path(tmp_dir) / "cert.pem" + key_path = Path(tmp_dir) / "key.pem" + _wu.CERT_PATH = cert_path + _wu.KEY_PATH = key_path + + def _fake_openssl(cmd, **_kw): + # Create the files openssl would normally create + cert_path.write_text("FAKE CERT\n") + key_path.write_text("FAKE KEY\n") + r = mock.MagicMock() + r.returncode = 0 + return r + + with mock.patch("subprocess.run", side_effect=_fake_openssl): + _wu._ensure_self_signed_cert() + + mode = oct(stat.S_IMODE(os.stat(key_path).st_mode)) + self.assertEqual(mode, oct(0o600)) + finally: + _wu.CERT_PATH = orig_cert + _wu.KEY_PATH = orig_key + + +if __name__ == "__main__": + unittest.main() diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/icons/icon.svg b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/icons/icon.svg new file mode 100644 index 00000000..d7b09806 --- /dev/null +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/icons/icon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/index.html b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/index.html index 46c320cf..8eb7916e 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/index.html +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/index.html @@ -12,6 +12,13 @@ LifeTrac v25 — Base Station + + + + + + + + + +
+
📡
+

Base Station Unreachable

+

The LifeTrac operator console could not be reached.
+ The base station may still be starting up, or the LAN + connection may have dropped.

+
    +
  • Check that the base-station device is powered on.
  • +
  • Verify your device is on the same LAN (Wi-Fi or Ethernet).
  • +
  • Wait a few seconds — the server may still be starting.
  • +
+ +
+ + diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/setup.html b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/setup.html new file mode 100644 index 00000000..34925c52 --- /dev/null +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/setup.html @@ -0,0 +1,251 @@ + + + + + + + LifeTrac v25 — HTTPS Setup + + + +
+

🔒 HTTPS / PWA Setup

+

Follow these steps to enable full-screen PWA install on your phone.

+ + +
+
+ 1 +

Download the certificate + not found +

+
+

+ The base station uses a self-signed TLS certificate. Download it here, + then install it as a trusted CA on every device that will + access the HTTPS site. This is the public certificate — the private key + is never exposed. +

+ ⬇ Download lifetrac-cert.pem +
openssl req -x509 -newkey rsa:2048 -nodes \
+  -keyout /etc/lifetrac/key.pem \
+  -out /etc/lifetrac/cert.pem \
+  -days 3650 \
+  -subj "/CN=lifetrac-base" \
+  -addext "subjectAltName=IP:$(hostname -I | awk '{print $1}'),DNS:localhost"
+ Then restart uvicorn with --ssl-certfile /etc/lifetrac/cert.pem --ssl-keyfile /etc/lifetrac/key.pem. +

+
+ + +
+
+ 2 +

Install the certificate on this device

+
+
+ + + + + +
+ +
+
    +
  1. Tap the downloaded lifetrac-cert.pem file in your notifications or Files app.
  2. +
  3. Android will open a certificate installer — set Certificate name to LifeTrac Base.
  4. +
  5. Under Used for choose Wi-Fi (Android 11+: choose CA certificate).
  6. +
  7. Tap OK / Install.
  8. +
  9. On Android 14+ go to Settings → Security → Encryption & credentials → Trusted credentials → User to verify it appears.
  10. +
+
+ Chrome on Android trusts user-installed CAs for HTTPS pages automatically. + Firefox for Android requires Settings → About Firefox → scroll to Certificates → enable Use third-party CAs. +
+
+ +
+
    +
  1. Tap the downloaded cert file — iOS will say "Profile Downloaded".
  2. +
  3. Go to Settings → General → VPN & Device Management.
  4. +
  5. Tap the LifeTrac Base profile → Install → enter your passcode → Install again.
  6. +
  7. Go to Settings → General → About → Certificate Trust Settings.
  8. +
  9. Under Enable Full Trust For Root Certificates, toggle on LifeTrac Base.
  10. +
+
+ Both Safari and Chrome on iOS use the system trust store, so one install covers all browsers. +
+
+ +
+
    +
  1. Double-click lifetrac-cert.pemOpen.
  2. +
  3. Click Install Certificate…Local MachineNext.
  4. +
  5. Choose Place all certificates in the following storeBrowseTrusted Root Certification AuthoritiesOKNextFinish.
  6. +
  7. Restart Chrome / Edge.
  8. +
+
+ +
+
    +
  1. Double-click lifetrac-cert.pem — Keychain Access opens.
  2. +
  3. Add it to the System keychain.
  4. +
  5. Find lifetrac-base in the list, double-click it.
  6. +
  7. Expand Trust → set When using this certificate to Always Trust.
  8. +
  9. Close and enter your password to save.
  10. +
+
+ +
+
    +
  1. Copy the cert to the system CA directory:
    + sudo cp lifetrac-cert.pem /usr/local/share/ca-certificates/lifetrac-cert.crt
  2. +
  3. Rebuild the CA bundle:
    + sudo update-ca-certificates
  4. +
  5. Restart your browser (Chrome/Chromium also needs a full quit).
  6. +
+
+ On Fedora/RHEL use /etc/pki/ca-trust/source/anchors/ and sudo update-ca-trust. +
+
+
+ + +
+
+ 3 +

Open the HTTPS site & install as PWA

+
+

https://

+ + Open HTTPS Site → + +
+ After the cert is trusted, the browser should load the console without + any security warning. To install as a full-screen app: +
    +
  • Android (Chrome): tap ⋮ menu → Add to Home ScreenInstall
  • +
  • iPhone / iPad: tap Share (□↑) → Add to Home Screen
  • +
+ The app will open in landscape full-screen mode with no browser chrome. +
+
+
+ + + + diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/sw.js b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/sw.js new file mode 100644 index 00000000..6fc25d99 --- /dev/null +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web/sw.js @@ -0,0 +1,110 @@ +/* + * sw.js — LifeTrac v25 base-station service worker. + * + * Enables PWA offline-shell caching so the operator UI loads instantly + * on a LAN device (Portenta X8) even while the server is initialising, + * and shows a graceful offline page when the base station is unreachable. + * + * HTTPS note + * ---------- + * Service workers require a secure origin (HTTPS or localhost). + * On a LAN the simplest options are: + * a) Access the UI via http://localhost:8080 when sitting at the + * base-station device itself. + * b) Generate a self-signed cert and run uvicorn with --ssl-keyfile / + * --ssl-certfile (see BASE_STATION.md § HTTPS / PWA setup). + * c) Put an HTTPS reverse proxy (nginx, Caddy) in front of this server. + * + * Caching strategy + * ---------------- + * • Install: pre-cache the login page and offline fallback. + * • Navigation: network-first — the server always validates the session + * cookie; fall back to the offline page on network failure. + * • Static JS/CSS/images: cache-first + background revalidation (stale- + * while-revalidate) so subsequent page loads are instant. + * • /api/* and /ws/*: never intercepted — always go to the network. + */ + +const CACHE_NAME = 'lifetrac-v25-shell-v1'; + +// Pre-cached app shell. Only URLs that are publicly reachable without a +// session cookie are listed here so a failed addAll() doesn't abort install. +const SHELL_URLS = [ + '/login', + '/static/offline.html', + '/manifest.json', + '/static/icons/icon.svg', +]; + +// ---- install: pre-cache the app shell ---------------------------------- +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)), + ); + // Activate immediately without waiting for existing tabs to close. + self.skipWaiting(); +}); + +// ---- activate: prune stale caches from previous SW versions ------------ +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k !== CACHE_NAME) + .map((k) => { + console.log('[SW] deleting old cache:', k); + return caches.delete(k); + }), + ), + ), + ); + // Take control of all open clients immediately (no need to reload). + self.clients.claim(); +}); + +// ---- fetch: intercept GET requests from our origin --------------------- +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Only handle same-origin GET requests; pass everything else straight + // through (POST, cross-origin, etc.). + if (request.method !== 'GET' || url.origin !== self.location.origin) { + return; + } + + // Never intercept real-time API or WebSocket upgrade requests. + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws/')) { + return; + } + + // Navigation requests (full-page loads): network-first so the server + // can enforce session auth and redirect to /login as needed. + // Fall back to the offline page when the network is unreachable. + if (request.mode === 'navigate') { + event.respondWith( + fetch(request).catch(() => + caches.match('/static/offline.html').then( + (r) => r || new Response('Offline', { status: 503 }), + ), + ), + ); + return; + } + + // Static assets (/static/*): cache-first. On a cache miss the asset + // is fetched from the network and stored for the next visit. + event.respondWith( + caches.match(request).then((cached) => { + const networkFetch = fetch(request).then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }); + return cached || networkFetch; + }), + ); +}); diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py index 671fed80..fe671a52 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py @@ -7,8 +7,40 @@ through MQTT (`lifetrac/v25/cmd/control`) at 20 Hz, and streams telemetry to the page over a WebSocket. -Run: +Run (plain HTTP — LAN only): uvicorn web_ui:app --host 0.0.0.0 --port 8080 + +HTTPS / PWA setup +----------------- +Progressive Web App features (service worker, Add-to-Home-Screen, offline +caching) require a secure origin — HTTPS or http://localhost. On a LAN +the practical options are: + +1. Self-signed certificate (recommended for a private base station): + + # Generate a self-signed cert valid for 365 days: + openssl req -x509 -newkey rsa:2048 -nodes \\ + -keyout key.pem -out cert.pem -days 365 \\ + -subj "/CN=lifetrac-base" \\ + -addext "subjectAltName=IP:,DNS:localhost" + + # Run uvicorn with TLS: + uvicorn web_ui:app --host 0.0.0.0 --port 8443 \\ + --ssl-keyfile key.pem --ssl-certfile cert.pem + + Browsers will warn about the self-signed cert; add an exception once. + Android / iOS require installing the cert as a trusted CA to dismiss + the warning permanently. + +2. Reverse-proxy with TLS (nginx, Caddy, Traefik): + Put the proxy in front on port 443; the proxy forwards to this server + on port 8080. Caddy with ``tls internal`` issues a locally-trusted + cert automatically if the `mkcert` root CA is installed on client devices. + +3. localhost access only: + If the operator tablet is the base-station device itself, access via + http://localhost:8080 — browsers treat localhost as a secure origin and + will register the service worker even over plain HTTP. """ from __future__ import annotations @@ -19,6 +51,7 @@ import logging import os import secrets +import socket import subprocess import threading import time @@ -28,7 +61,7 @@ import paho.mqtt.client as mqtt from fastapi import Cookie, Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect, status -from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator @@ -135,6 +168,86 @@ def _check_keys(cls, v: dict[str, Any]) -> dict[str, Any]: WEB_DIR = Path(__file__).parent / "web" +# ---- HTTPS bootstrap config ---------------------------------------------- +# Path to the TLS public certificate so the /cert.pem endpoint can serve it +# to client devices for installation. Only the *public* cert is ever served; +# the private key is never exposed. Set LIFETRAC_TLS_CERT to override the +# default path. The server itself does not manage TLS — TLS termination is +# done by uvicorn's --ssl-certfile argument. +CERT_PATH = Path( + os.environ.get( + "LIFETRAC_TLS_CERT", + str(Path("/etc/lifetrac/cert.pem")), + ) +) +# Path to the TLS private key. Never served over the network. Used only by +# the auto-generation logic on first boot. Set LIFETRAC_TLS_KEY to override. +KEY_PATH = Path( + os.environ.get( + "LIFETRAC_TLS_KEY", + str(Path("/etc/lifetrac/key.pem")), + ) +) +# Port on which uvicorn is listening with TLS. Used only for the /setup +# page to build the "Open HTTPS site" link. +HTTPS_PORT = int(os.environ.get("LIFETRAC_HTTPS_PORT", "8443")) +# Validity period for the auto-generated self-signed certificate (days). +CERT_VALIDITY_DAYS = 3650 # ~10 years; long-lived for embedded device use + + +def _ensure_self_signed_cert() -> None: + """Generate a self-signed TLS cert+key on first boot if neither exists. + + Writes to CERT_PATH / KEY_PATH (defaulting to /etc/lifetrac/). Does + nothing when either file already exists so operator-supplied certs are + never overwritten. Logs a warning and returns silently when ``openssl`` + is not available so the server still starts without TLS. + """ + if CERT_PATH.is_file() or KEY_PATH.is_file(): + return + try: + hostname = socket.gethostname() + except Exception: + hostname = "lifetrac-base" + try: + CERT_PATH.parent.mkdir(parents=True, exist_ok=True) + KEY_PATH.parent.mkdir(parents=True, exist_ok=True) + result = subprocess.run( + [ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", str(KEY_PATH), + "-out", str(CERT_PATH), + "-days", str(CERT_VALIDITY_DAYS), + "-subj", f"/CN={hostname}", + "-addext", f"subjectAltName=DNS:{hostname},DNS:localhost", + ], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + try: + os.chmod(KEY_PATH, 0o600) + except OSError: + pass # best-effort; FAT or non-POSIX filesystem + logging.info( + "auto-generated self-signed TLS cert at %s (CN=%s)", + CERT_PATH, hostname, + ) + else: + logging.warning( + "openssl cert generation failed (rc=%d): %s", + result.returncode, result.stderr.strip(), + ) + except FileNotFoundError: + logging.warning( + "openssl not found — skipping auto-cert generation. " + "Install openssl or generate %s / %s manually.", + CERT_PATH, KEY_PATH, + ) + except Exception as exc: + logging.warning("auto-cert generation error: %s", exc) + # ---- PIN auth (MASTER_PLAN.md §8.5 / DECISIONS.md D-E1) ----------------- # Single shared PIN, LAN-only, plain HTTP for v25. Telemetry views are public; # control/E-stop/camera-select require the PIN. The hardware E-stop on the @@ -743,6 +856,97 @@ def _maybe_capture_params(topic: str, data: Any) -> None: @app.on_event("startup") async def _startup(): app.state.loop = asyncio.get_event_loop() + # Auto-generate a self-signed TLS cert on first boot if none exists. + # Runs in a thread so it doesn't block the event loop during openssl. + loop = app.state.loop + await loop.run_in_executor(None, _ensure_self_signed_cert) + + +# ---- PWA: service worker and manifest at the root scope ---------------- +# The service worker MUST be served from the root path so that its scope +# covers all pages (/, /login, /map, etc.). Serving from /static/sw.js +# would limit scope to /static/* which is useless for navigation caching. +# The ``Service-Worker-Allowed`` response header explicitly grants the +# full scope so browsers accept the registration even though the SW file +# itself lives under /static/ conceptually. + +@app.get("/sw.js") +async def service_worker(): + """PWA service worker — served at root so scope covers all pages.""" + content = (WEB_DIR / "sw.js").read_text(encoding="utf-8") + return Response( + content=content, + media_type="application/javascript", + headers={ + # Allow the browser to register this SW for the full site scope. + "Service-Worker-Allowed": "/", + # Never cache the SW file itself; browsers need to see updates. + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + ) + + +@app.get("/manifest.json") +async def manifest(): + """PWA web app manifest.""" + content = (WEB_DIR / "manifest.json").read_text(encoding="utf-8") + return Response( + content=content, + media_type="application/manifest+json", + headers={"Cache-Control": "max-age=86400"}, + ) + + +@app.get("/cert.pem") +async def download_cert(): + """Serve the TLS public certificate for installation on client devices. + + Intended for the HTTP bootstrap path: operator visits /setup over plain + HTTP, downloads cert.pem, installs it as a trusted CA, then navigates to + the HTTPS site. Only the *public* certificate is served — the private key + (key.pem) is never exposed through this endpoint. + + Returns 404 when no certificate has been generated yet, with a hint + directing the operator to run the openssl command documented in + BASE_STATION.md. + """ + if not CERT_PATH.is_file(): + raise HTTPException( + status_code=404, + detail=( + "TLS certificate not found. " + "Generate one with openssl (see BASE_STATION.md § HTTPS / PWA setup) " + "then set LIFETRAC_TLS_CERT or place the file at " + f"{CERT_PATH}." + ), + ) + content = CERT_PATH.read_bytes() + return Response( + content=content, + media_type="application/x-pem-file", + headers={ + "Content-Disposition": 'attachment; filename="lifetrac-cert.pem"', + # Never cache — the cert may be regenerated at any time. + "Cache-Control": "no-store", + }, + ) + + +@app.get("/setup", response_class=HTMLResponse) +async def setup_page(): + """HTTPS / cert bootstrap page — no session required. + + Accessible over plain HTTP so an operator can download the TLS cert before + switching to the HTTPS site. The page injects the HTTPS port (from + LIFETRAC_HTTPS_PORT) and cert availability flag so the client-side JS can + build the correct HTTPS URL and enable/disable the download button. + """ + html = (WEB_DIR / "setup.html").read_text(encoding="utf-8") + cert_available = "true" if CERT_PATH.is_file() else "false" + # Inject server-side config into the JS placeholders before serving. + html = html.replace("__HTTPS_PORT__", str(HTTPS_PORT)) + html = html.replace("__CERT_AVAILABLE__", cert_available) + return HTMLResponse(content=html, headers={"Cache-Control": "no-store"}) @app.get("/", response_class=HTMLResponse)