diff --git a/compose.yaml b/compose.yaml index f83b329..6e367ea 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,6 +9,9 @@ services: - "${ROBOROCK_SERVER_HTTPS_PORT:-555}:${ROBOROCK_SERVER_HTTPS_PORT:-555}" - "${ROBOROCK_SERVER_MQTT_TLS_PORT:-8881}:${ROBOROCK_SERVER_MQTT_TLS_PORT:-8881}" volumes: + # Remove this line if you're generating config.toml from ROBOROCK_SERVER_* + # env vars instead of running `configure` first — see the "Environment + # Variables Only" section in docs/installation.md. - ./config.toml:/app/config.toml:ro - ./data:/data - ./secrets:/run/secrets:ro diff --git a/docs/installation.md b/docs/installation.md index b933197..2db4f74 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -92,11 +92,27 @@ If your model already has certificate notes on the tested-vacuums page, follow t uv sync ``` -3. Run the setup wizard: +3. Run the setup wizard. - ```bash - uv run roborock-local-server configure - ``` + You have two options here, and they write the same `config.toml`: + + - **In your browser (no `uv`/Python needed on the host):** skip straight to step 6 and start the container + with no `config.toml` present. It boots into a setup wizard, served in plain HTTP at `/admin` on your + configured HTTPS port (`http://:555/admin` by default), that asks for the same fields as the + CLI wizard below. Submitting it writes `config.toml`, and the container restarts itself into the full + HTTPS/MQTT stack - reload the page after a few seconds and you're at the real (now HTTPS) admin login. + - **On the command line:** + + ```bash + uv run roborock-local-server configure + ``` + + You can also combine the two: if `ROBOROCK_SERVER_*` env vars generate `config.toml`'s network/broker/TLS + settings but the admin ones (`ROBOROCK_SERVER_ADMIN_PASSWORD`, `ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL`, + `ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN`) are left unset, the container still boots into `/admin`, but the + wizard notices the rest is already configured and only asks for admin credentials. Useful for a + reverse-proxy or ACME setup you'd rather express as env vars/IaC, while still picking the admin password + in a browser instead of a compose file. The wizard asks for: @@ -111,7 +127,7 @@ If your model already has certificate notes on the tested-vacuums page, follow t It then writes `config.toml`, generates `admin.password_hash` and `admin.session_secret`, and if you chose Cloudflare it also writes `secrets/cloudflare_token`. If you also chose `acme_server = actalis`, it writes `secrets/acme_eab_kid` and `secrets/acme_eab_hmac_key`. -4. If you chose external MQTT, fill in `broker.host` in `config.toml` before starting the stack. See [Custom MQTT](custom_mqtt.md). +4. If you chose external MQTT with the CLI wizard, fill in `broker.host` in `config.toml` before starting the stack (the browser wizard asks for the broker host directly, so this step doesn't apply there). See [Custom MQTT](custom_mqtt.md). 5. If you skipped Cloudflare, put your certificate files in `data/certs/fullchain.pem` and `data/certs/privkey.pem` (relative to the repository root on the host, which maps to `/data/certs/` inside the container). This is the path to use when your vacuum works better with a certificate chain you manage yourself. See [Custom certificate management](custom_cert_management.md). @@ -139,6 +155,54 @@ If your model already has certificate notes on the tested-vacuums page, follow t For reverse proxy setups, keep `network.https_port` and `network.mqtt_tls_port` set to the backend listener ports and use `network.advertised_https_port` / `network.advertised_mqtt_tls_port` for the public ports. +### Alternative: Environment Variables Only (No `uv` Toolchain) + +If you don't want to install `uv`/Python on the host just to run `configure`, you can set `ROBOROCK_SERVER_*` environment variables instead. On first boot, if `/data/config.toml` doesn't already exist, the container generates one from these variables and starts normally. If `config.toml` already exists (mounted or previously generated), it always wins and the env vars are ignored — this never overwrites a config you've already set up. + +Required variables: + +- `ROBOROCK_SERVER_STACK_FQDN` +- Either `ROBOROCK_SERVER_CERT_FILE` + `ROBOROCK_SERVER_KEY_FILE` (bring your own certificate), or `ROBOROCK_SERVER_TLS_MODE=cloudflare_acme` + `ROBOROCK_SERVER_TLS_BASE_DOMAIN` + `ROBOROCK_SERVER_TLS_EMAIL` + `ROBOROCK_SERVER_CLOUDFLARE_TOKEN` (or `_CLOUDFLARE_TOKEN_FILE` for a Docker secret) + +The admin credentials are optional as a group: + +- Set all three of `ROBOROCK_SERVER_ADMIN_PASSWORD`, `ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL`, and + `ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN` (6 digits) for a fully headless boot straight into the running stack. +- Leave all three unset, and the container still generates `config.toml`'s network/broker/TLS settings from + the env vars above, then boots into the setup wizard at `/admin` - which notices the rest is already + configured and asks only for admin credentials. Handy if you're driving the network/TLS/reverse-proxy side + from env vars or IaC, but would still rather pick the admin password in a browser. +- Setting some but not all three is rejected with a clear error, to avoid a half-set credential. + +Commonly-set optional variables: `ROBOROCK_SERVER_HTTPS_PORT`, `ROBOROCK_SERVER_MQTT_TLS_PORT`, `ROBOROCK_SERVER_ADVERTISED_HTTPS_PORT`, `ROBOROCK_SERVER_ADVERTISED_MQTT_TLS_PORT`, `ROBOROCK_SERVER_LISTENER_MODE`, `ROBOROCK_SERVER_BROKER_MODE` + `ROBOROCK_SERVER_BROKER_HOST`. See `env_config.py` for the full list. + +**Important:** if you go this route, remove (or comment out) the `./config.toml:/app/config.toml:ro` line from `compose.yaml`. Docker will otherwise bind-mount a nonexistent host path as an empty file, which the container treats as an existing (but invalid) config and never falls through to the env vars. Keep the `./data:/data` and `./secrets:/run/secrets:ro` mounts. + +Fully headless (no browser step at all): + +```bash +export ROBOROCK_SERVER_STACK_FQDN=api-roborock.example.com +export ROBOROCK_SERVER_ADMIN_PASSWORD=super-secret-password +export ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL=user@example.com +export ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN=123456 +export ROBOROCK_SERVER_TLS_MODE=cloudflare_acme +export ROBOROCK_SERVER_TLS_BASE_DOMAIN=example.com +export ROBOROCK_SERVER_TLS_EMAIL=acme@example.com +export ROBOROCK_SERVER_CLOUDFLARE_TOKEN=your-cloudflare-api-token +docker compose up -d --build +``` + +Hybrid (network/TLS from env vars, admin credentials in the browser wizard at `/admin`): + +```bash +export ROBOROCK_SERVER_STACK_FQDN=api-roborock.example.com +export ROBOROCK_SERVER_TLS_MODE=cloudflare_acme +export ROBOROCK_SERVER_TLS_BASE_DOMAIN=example.com +export ROBOROCK_SERVER_TLS_EMAIL=acme@example.com +export ROBOROCK_SERVER_CLOUDFLARE_TOKEN=your-cloudflare-api-token +docker compose up -d --build +``` + ## Method 2: Home Assistant Add-on Use [Home Assistant](home_assistant.md) as the installation guide if you want to run the stack as a Home Assistant add-on instead of Docker Compose. @@ -159,6 +223,8 @@ Use [Home Assistant](home_assistant.md) as the installation guide if you want to 4. For any routines that use zones, re-save them so the server stores the zone data correctly. In the Roborock app, open each routine that has zones, open the zone, tap **Edit**, open any **Zone Cleaning** entry, then tap **Save**. Repeat for each zone in the routine. +5. The dashboard's **Activity** panel shows recent HTTP/MQTT traffic between your vacuum(s)/app/Home Assistant and this server - useful for confirming a device is actually talking to the local stack. It only shows redacted metadata (method/path/topic/RPC method name, sizes, timestamps) by default, since the underlying logs can contain device keys and decrypted command payloads. Set `ROBOROCK_SERVER_ACTIVITY_RAW=1` if you need full unredacted entries for debugging. + ## Next Steps - [Onboarding](onboarding.md) for pairing a new vacuum diff --git a/docs/reverse_proxy.md b/docs/reverse_proxy.md index 4f42c6b..19abe17 100644 --- a/docs/reverse_proxy.md +++ b/docs/reverse_proxy.md @@ -1,6 +1,8 @@ # Reverse Proxy -Reverse proxy support is mainly useful when public or LAN clients reach the stack on different ports than the backend listeners, or when you already run a proxy (Caddy, Traefik, nginx) that owns your TLS certificates. I do not use a reverse proxy for my own setup, so please report any issues. +Reverse proxy support is mainly useful when public or LAN clients reach the stack on different ports than the backend listeners, or when you already run a proxy (Caddy, Traefik, nginx, SWAG) that owns your TLS certificates. I do not use a reverse proxy for my own setup, so please report any issues. + +> **Neither the `configure` CLI wizard nor the browser Setup Wizard (`/admin` on first boot) can generate `listener_mode`, `advertised_*_port`, or `trusted_proxies` today** — they only produce the simple `local_tls` case. If your setup needs any of the settings on this page (which it will, for a proxy like SWAG that terminates TLS itself), generate `config.toml` from `ROBOROCK_SERVER_*` environment variables instead (see [Installation](installation.md)) - that path covers everything below directly, with no manual `config.toml` edits needed. The TOML shown on this page is what those env vars produce; it's included so you can see (or hand-edit) the result either way. Whatever endpoint a vacuum or the Roborock app connects to **must present a valid, trusted TLS certificate** — vacuums refuse to connect otherwise. That endpoint can be the server itself or the proxy in front of it; the rest of this page is about choosing which one terminates TLS. @@ -29,6 +31,8 @@ With that config the server listens on `*:555` / `*:8881`, but responses adverti - `https://api-roborock.example.com` - `ssl://api-roborock.example.com:8883` +Equivalent env vars: `ROBOROCK_SERVER_ADVERTISED_HTTPS_PORT=443`, `ROBOROCK_SERVER_ADVERTISED_MQTT_TLS_PORT=8883`. + ## TLS Termination Modes ### `local_tls` (default) — the server terminates TLS @@ -60,6 +64,8 @@ listener_mode = "external_tls" mode = "provided" ``` +Equivalent env vars: `ROBOROCK_SERVER_LISTENER_MODE=external_tls`, `ROBOROCK_SERVER_TLS_MODE=provided`. + Requirements: - HTTPS: the proxy terminates TLS, preserves the original `Host` header, and forwards plain HTTP to `https_port`. @@ -133,6 +139,8 @@ List every proxy or SNAT address that forwards traffic to the server in `trusted trusted_proxies = ["10.42.0.0/16", "10.1.1.10"] ``` +Equivalent env var: `ROBOROCK_SERVER_TRUSTED_PROXIES=10.42.0.0/16,10.1.1.10` (comma-separated). + - **HTTP:** requests from a trusted proxy use the `X-Forwarded-For` header to find the vacuum's real IP. The header is ignored from any other client, so it can't be spoofed. Traefik and Caddy set it by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. - **MQTT:** during an active onboarding session, a new MQTT login is normally accepted only from the IP that completed `/region` and `/nc`. A connection from a trusted proxy is also accepted, because a stream proxy hides the vacuum's address. The session must still have completed `/region` and `/nc`, and the first publish must be on that vacuum's topic. diff --git a/docs/roborock_app.md b/docs/roborock_app.md index 5775efb..5673904 100644 --- a/docs/roborock_app.md +++ b/docs/roborock_app.md @@ -17,6 +17,16 @@ The launcher can auto-load a sync secret from `config.toml` beside `mitm_redirec The launcher now preflights that callback before starting `mitmweb`. If the `--local-api` host cannot be reached, if the TLS certificate does not validate for that host, or if the sync secret is rejected, the script exits immediately instead of letting you proceed into a broken login flow. +### Watching App Traffic In The Dashboard + +Add `--activity-sync` (off by default) to also send a copy of each redirected request/response to your server's admin dashboard, so you can watch what the official app is doing from the **Activity** panel without leaving the browser: + +```bash +uv run mitm_redirect.py --local-api api-roborock.example.com --sync-secret YOUR_ADMIN_SESSION_SECRET --activity-sync +``` + +This uses the same `--sync-secret` as the login sync above - no separate secret to manage. The Activity panel redacts these entries (method/host/path/status only) by default, since this traffic includes real Roborock account tokens; set `ROBOROCK_SERVER_ACTIVITY_RAW=1` on the server if you need full headers/bodies for debugging. Because each captured request blocks briefly on a network call to your server, leave this off for normal daily use and only turn it on for an active debugging session. + ### iPhone 1. Log out of the app on your phone. diff --git a/mitm_redirect.py b/mitm_redirect.py index dec623c..147cbe4 100644 --- a/mitm_redirect.py +++ b/mitm_redirect.py @@ -6,12 +6,18 @@ state comes from your local stack. Usage: - uv run mitm_redirect.py --local-api YOUR_SERVER_HOST [--local-mqtt HOST] [--local-wood HOST] [--sync-secret SECRET] [--mode wireguard] + uv run mitm_redirect.py --local-api YOUR_SERVER_HOST [--local-mqtt HOST] [--local-wood HOST] \ + [--sync-secret SECRET] [--activity-sync] [--mode wireguard] + +--activity-sync (off by default) additionally sends a copy of each redirected +request/response to the server's admin dashboard Activity panel, so you can +watch what the native Roborock app is doing without leaving the browser. +Requires --sync-secret (or a config.toml beside this script). """ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone import json import os import re @@ -38,8 +44,10 @@ LOCAL_WOOD_HOST: str = "" LOCAL_WOOD_PORT: int | None = None LOCAL_SYNC_SECRET: str = "" +ACTIVITY_SYNC_ENABLED: bool = False DEFAULT_LOCAL_API_PORT = 555 DEFAULT_LOCAL_MQTT_PORT = 8881 +MITM_ACTIVITY_SYNC_PATH = "/internal/protocol/mitm-activity" # Domains whose responses are candidates for host rewrite. @@ -239,9 +247,9 @@ def _format_authority(host: str, port: int | None, *, default_port: int | None = return f"{normalized_host}:{port}" -def _sync_callback_url(local_api: str) -> str: +def _sync_callback_url(local_api: str, *, path: str = PROTOCOL_AUTH_SYNC_PATH) -> str: authority = str(local_api or "").strip().strip("/") - return f"https://{authority}{PROTOCOL_AUTH_SYNC_PATH}" + return f"https://{authority}{path}" def _parse_json_object(content: bytes) -> dict[str, object]: @@ -277,9 +285,10 @@ def _post_sync_payload( local_api: str, sync_secret: str, payload: dict[str, object], + path: str = PROTOCOL_AUTH_SYNC_PATH, timeout: float = 5.0, ) -> tuple[str, int, bytes]: - sync_url = _sync_callback_url(local_api) + sync_url = _sync_callback_url(local_api, path=path) request = Request( sync_url, data=json.dumps(payload, separators=(",", ":")).encode("utf-8"), @@ -348,7 +357,7 @@ def load(loader) -> None: global LOCAL_API, LOCAL_API_HOST, LOCAL_API_PORT global LOCAL_MQTT, LOCAL_MQTT_HOST, LOCAL_MQTT_PORT global LOCAL_WOOD, LOCAL_WOOD_HOST, LOCAL_WOOD_PORT - global LOCAL_SYNC_SECRET + global LOCAL_SYNC_SECRET, ACTIVITY_SYNC_ENABLED LOCAL_API_HOST, LOCAL_API_PORT = _parse_endpoint( os.environ["MITM_LOCAL_API"], fallback_port=DEFAULT_LOCAL_API_PORT, @@ -367,12 +376,25 @@ def load(loader) -> None: ) LOCAL_WOOD = _format_authority(LOCAL_WOOD_HOST, LOCAL_WOOD_PORT, default_port=443) LOCAL_SYNC_SECRET = str(os.environ.get("MITM_LOCAL_SYNC_SECRET") or "").strip() + ACTIVITY_SYNC_ENABLED = str(os.environ.get("MITM_ACTIVITY_SYNC") or "").strip().lower() in { + "1", + "true", + "yes", + "on", + } _init_log_dir() ctx.log.info(f"[CONFIG] LOCAL_API={LOCAL_API} LOCAL_MQTT={LOCAL_MQTT} LOCAL_WOOD={LOCAL_WOOD}") if LOCAL_SYNC_SECRET: ctx.log.info(f"[SYNC] protocol auth session sync enabled via {_sync_callback_url(LOCAL_API)}") else: ctx.log.warn("[SYNC] protocol auth session sync disabled: no sync secret configured") + if ACTIVITY_SYNC_ENABLED and LOCAL_SYNC_SECRET: + ctx.log.info( + f"[ACTIVITY] sending redirected-traffic summaries to the admin Activity panel via " + f"{_sync_callback_url(LOCAL_API, path=MITM_ACTIVITY_SYNC_PATH)}" + ) + elif ACTIVITY_SYNC_ENABLED: + ctx.log.warn("[ACTIVITY] --activity-sync was set but no sync secret is configured; activity sync disabled") def _safe_body(content: bytes, content_type: str) -> str: @@ -540,6 +562,49 @@ def _sync_protocol_user_data(user_data: dict[str, object]) -> None: ) +def _activity_entry(flow: http.HTTPFlow, *, rewritten: bool, rewrites: list[str] | None) -> dict[str, object]: + entry: dict[str, object] = { + "time": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), + "host": flow.request.pretty_host, + "method": flow.request.method, + "path": flow.request.path, + "rewritten": bool(rewritten), + } + if rewrites: + entry["rewrites"] = list(rewrites) + entry["request_headers"] = dict(flow.request.headers) + entry["request_body"] = _safe_body(flow.request.content, flow.request.headers.get("content-type", "")) + if flow.response: + entry["status"] = flow.response.status_code + entry["response_headers"] = dict(flow.response.headers) + entry["response_body"] = _safe_body(flow.response.content, flow.response.headers.get("content-type", "")) + return entry + + +def _sync_activity_entry(flow: http.HTTPFlow, *, rewritten: bool, rewrites: list[str] | None = None) -> None: + """Best-effort: send a copy of this flow to the admin Activity panel. + + Only called for REWRITE_HOSTS (Roborock's own domains), never for + unrelated traffic that happens to pass through the same proxy. Never + lets a sync failure interrupt the actual MITM redirect/rewrite - this is + telemetry, not part of the redirect's own correctness. + """ + if not ACTIVITY_SYNC_ENABLED or not LOCAL_SYNC_SECRET: + return + try: + entry = _activity_entry(flow, rewritten=rewritten, rewrites=rewrites) + sync_url, status, body = _post_sync_payload( + local_api=LOCAL_API, + sync_secret=LOCAL_SYNC_SECRET, + payload={"entries": [entry]}, + path=MITM_ACTIVITY_SYNC_PATH, + ) + if not 200 <= status < 300: + ctx.log.warn(f"[ACTIVITY] sync failed: {sync_url} - {_describe_sync_http_result(status, body)}") + except Exception as exc: # noqa: BLE001 + ctx.log.warn(f"[ACTIVITY] sync error: {exc}") + + def response(flow: http.HTTPFlow) -> None: """Rewrite cloud endpoint references in JSON payloads.""" host = flow.request.pretty_host @@ -551,6 +616,7 @@ def response(flow: http.HTTPFlow) -> None: if not flow.response or not flow.response.content: _log_flow(flow, rewritten=False) + _sync_activity_entry(flow, rewritten=False) return content_type = flow.response.headers.get("content-type", "") @@ -567,9 +633,11 @@ def response(flow: http.HTTPFlow) -> None: ctx.log.error(f"[SYNC] blocking login response: {exc}") _write_sync_failure_response(flow, exc) _log_flow(flow, rewritten=False) + _sync_activity_entry(flow, rewritten=False) return if _rewrite_json(body, rewrites): _log_flow(flow, rewritten=True, rewrites=rewrites) + _sync_activity_entry(flow, rewritten=True, rewrites=rewrites) flow.response.content = json.dumps(body).encode("utf-8") ctx.log.info(f"[REWRITE] {host}{flow.request.path} - {len(rewrites)} substitutions") return @@ -577,6 +645,7 @@ def response(flow: http.HTTPFlow) -> None: pass _log_flow(flow, rewritten=False) + _sync_activity_entry(flow, rewritten=False) def _looks_like_json(content: bytes) -> bool: @@ -687,6 +756,16 @@ def _rewrite_value(text: str) -> str: default=None, help="Optional admin.session_secret for protocol auth sync. Defaults to ./config.toml beside this script when available; pass explicitly when the server uses a different active config.", ) + parser.add_argument( + "--activity-sync", + action="store_true", + help=( + "Send a copy of each redirected Roborock request/response to the admin dashboard's " + "Activity panel (off by default). Requires --sync-secret / config.toml admin.session_secret. " + "The Activity panel redacts these by default; set ROBOROCK_SERVER_ACTIVITY_RAW=1 on the " + "server for full detail." + ), + ) parser.add_argument("--mode", default="wireguard", help="mitmweb proxy mode (default: wireguard)") parser.add_argument("--listen-port", default=None, help="mitmweb listen port") @@ -733,6 +812,7 @@ def _rewrite_value(text: str) -> str: env["MITM_LOCAL_MQTT"] = local_mqtt env["MITM_LOCAL_WOOD"] = local_wood env["MITM_LOCAL_SYNC_SECRET"] = local_sync_secret + env["MITM_ACTIVITY_SYNC"] = "1" if args.activity_sync else "" if local_sync_secret: try: @@ -744,6 +824,16 @@ def _rewrite_value(text: str) -> str: else: print("[SYNC] protocol auth session sync disabled: no sync secret configured") + if args.activity_sync and not local_sync_secret: + print( + "[ACTIVITY] --activity-sync was set but no sync secret is configured " + "(pass --sync-secret or run beside a config.toml with admin.session_secret); " + "activity sync will stay disabled.", + file=sys.stderr, + ) + elif args.activity_sync: + print(f"[ACTIVITY] sending redirected-traffic summaries via {_sync_callback_url(local_api_http, path=MITM_ACTIVITY_SYNC_PATH)}") + cmd = [ "uvx", "--from", diff --git a/src/roborock_local_server/activity_log.py b/src/roborock_local_server/activity_log.py new file mode 100644 index 0000000..9c61de4 --- /dev/null +++ b/src/roborock_local_server/activity_log.py @@ -0,0 +1,186 @@ +"""Redacted summary view over the HTTP/MQTT/MITM protocol jsonl logs, for +the admin dashboard's Activity panel. + +decompiled_http.jsonl / decompiled_mqtt.jsonl / mitm_activity.jsonl (see +AppPaths in config.py) have no rotation or size cap, and can contain +sensitive data: HTTP request headers and onboarding-adjacent bodies can +carry localKey/passwords, MQTT entries carry fully-decrypted device RPC +payloads, and mitm_activity.jsonl (populated by mitm_redirect.py's optional +--activity-sync, capturing traffic between the native Roborock app and +Roborock's real cloud) can carry actual Roborock account tokens. So by +default this module only ever returns safe metadata (method/path/topic/RPC +method name, sizes, timestamps) - never raw headers, bodies, or payloads. + +Set ROBOROCK_SERVER_ACTIVITY_RAW=1 to return complete, unredacted entries +instead. This is an explicit, deployment-time operator opt-in (matching how +rethink exposes its own traffic view), not a per-view toggle in the UI. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Collection, Mapping + +ENV_ACTIVITY_RAW = "ROBOROCK_SERVER_ACTIVITY_RAW" +DEFAULT_LIMIT = 200 +MAX_LIMIT = 500 + +_CHUNK_SIZE = 65536 +_MAX_TAIL_BYTES = 8_000_000 + + +def activity_raw_enabled(env: Mapping[str, str]) -> bool: + return str(env.get(ENV_ACTIVITY_RAW, "") or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _tail_lines(path: Path, max_lines: int) -> list[str]: + """The last `max_lines` lines of `path`, without reading the whole file. + + These jsonl files grow unbounded, so a plain read-then-split is out. + """ + if max_lines <= 0 or not path.exists(): + return [] + with path.open("rb") as handle: + handle.seek(0, 2) + position = handle.tell() + buffer = b"" + bytes_read = 0 + while position > 0 and buffer.count(b"\n") <= max_lines and bytes_read < _MAX_TAIL_BYTES: + read_size = min(_CHUNK_SIZE, position) + position -= read_size + handle.seek(position) + buffer = handle.read(read_size) + buffer + bytes_read += read_size + lines = buffer.split(b"\n") + if position > 0 and lines: + # The first entry is a partial line, unless we reached the start of the file. + lines = lines[1:] + text_lines = [line.decode("utf-8", errors="replace") for line in lines if line.strip()] + return text_lines[-max_lines:] + + +def _parse_jsonl_lines(lines: list[str]) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for line in lines: + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + entries.append(parsed) + return entries + + +def _summarize_http_entry(raw: dict[str, Any]) -> dict[str, Any]: + return { + "time": raw.get("time"), + "source": "http", + "method": raw.get("method"), + "path": raw.get("clean_path") or raw.get("raw_path"), + "route": raw.get("route"), + "did": raw.get("did"), + "pid": raw.get("pid"), + "remote": raw.get("remote"), + "body_len": raw.get("body_len"), + } + + +def _summarize_mqtt_entry(raw: dict[str, Any]) -> dict[str, Any]: + decoded_messages = raw.get("decoded_messages") + rpc_methods: list[str] = [] + if isinstance(decoded_messages, list): + for decoded in decoded_messages: + if not isinstance(decoded, dict): + continue + rpc = decoded.get("rpc") + if isinstance(rpc, dict) and rpc.get("method"): + rpc_methods.append(str(rpc.get("method"))) + payload_hex = raw.get("payload_hex") + payload_len = len(payload_hex) // 2 if isinstance(payload_hex, str) else None + return { + "time": raw.get("time"), + "source": "mqtt", + "direction": raw.get("direction"), + "topic": raw.get("topic"), + "rpc_methods": rpc_methods, + "decoded": bool(decoded_messages), + "payload_len": payload_len, + } + + +def _summarize_mitm_entry(raw: dict[str, Any]) -> dict[str, Any]: + return { + "time": raw.get("time"), + "source": "mitm", + "host": raw.get("host"), + "method": raw.get("method"), + "path": raw.get("path"), + "status": raw.get("status"), + "rewritten": bool(raw.get("rewritten")), + } + + +# A device-filtered view reads a deeper tail, since most of a busy log belongs +# to other devices and the newest `limit` lines may hold none of this one's. +_DEVICE_FILTER_TAIL_FACTOR = 10 + + +def _path_segments(value: Any) -> set[str]: + return {part for part in str(value or "").split("/") if part} + + +def _entry_mentions_device(entry: dict[str, Any], device_ids: frozenset[str]) -> bool: + """True when the entry's did, path or MQTT topic names one of the device's ids. + + Matches whole path/topic segments, never substrings, so a short id can't + pick up unrelated traffic. + """ + if str(entry.get("did") or "") in device_ids: + return True + for key in ("clean_path", "raw_path", "path", "topic"): + if _path_segments(entry.get(key)) & device_ids: + return True + return False + + +def read_recent_activity( + *, + http_jsonl_path: Path, + mqtt_jsonl_path: Path, + mitm_jsonl_path: Path | None = None, + limit: int = DEFAULT_LIMIT, + raw: bool = False, + device_ids: Collection[str] | None = None, +) -> list[dict[str, Any]]: + """Most recent `limit` HTTP+MQTT(+MITM) activity entries, newest first. + + `raw=True` returns complete, unredacted entries (see module docstring); + the default returns metadata-only summaries with no header/body/payload + content at all. `mitm_jsonl_path` is optional since not every deployment + runs mitm_redirect.py with --activity-sync. + + `device_ids` keeps only entries that name one of those ids (a vacuum's + did, duid or MQTT user). An empty collection matches nothing. + """ + wanted = frozenset(str(item) for item in device_ids if str(item)) if device_ids is not None else None + tail = limit * _DEVICE_FILTER_TAIL_FACTOR if device_ids is not None else limit + http_entries = _parse_jsonl_lines(_tail_lines(http_jsonl_path, tail)) + mqtt_entries = _parse_jsonl_lines(_tail_lines(mqtt_jsonl_path, tail)) + mitm_entries = _parse_jsonl_lines(_tail_lines(mitm_jsonl_path, tail)) if mitm_jsonl_path else [] + if wanted is not None: + http_entries = [entry for entry in http_entries if _entry_mentions_device(entry, wanted)] + mqtt_entries = [entry for entry in mqtt_entries if _entry_mentions_device(entry, wanted)] + mitm_entries = [entry for entry in mitm_entries if _entry_mentions_device(entry, wanted)] + + if raw: + combined = [{"source": "http", **entry} for entry in http_entries] + combined += [{"source": "mqtt", **entry} for entry in mqtt_entries] + combined += [{"source": "mitm", **entry} for entry in mitm_entries] + else: + combined = [_summarize_http_entry(entry) for entry in http_entries] + combined += [_summarize_mqtt_entry(entry) for entry in mqtt_entries] + combined += [_summarize_mitm_entry(entry) for entry in mitm_entries] + + combined.sort(key=lambda entry: str(entry.get("time") or ""), reverse=True) + return combined[:limit] diff --git a/src/roborock_local_server/config.py b/src/roborock_local_server/config.py index 37a2cb6..3b4e52c 100644 --- a/src/roborock_local_server/config.py +++ b/src/roborock_local_server/config.py @@ -113,6 +113,7 @@ class AppPaths: device_key_state_path: Path http_jsonl_path: Path mqtt_jsonl_path: Path + mitm_activity_jsonl_path: Path cloudflare_token_file: Path acme_eab_kid_file: Path acme_eab_hmac_key_file: Path @@ -232,17 +233,13 @@ def _as_bool(value: object, default: bool) -> bool: return bool(value) -def load_config(path: str | Path) -> AppConfig: - config_path = Path(path).resolve() - parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) - +def _load_network_broker_storage_tls( + parsed: dict[str, object], +) -> tuple[NetworkConfig, BrokerConfig, StorageConfig, TlsConfig]: network = _get_section(parsed, "network") broker = _get_section(parsed, "broker") storage = _get_section(parsed, "storage") tls = _get_section(parsed, "tls") - admin = _get_section(parsed, "admin") - turn = _get_section(parsed, "turn") - turn_mode = str(turn.get("mode", "external" if _as_bool(turn.get("enabled"), False) else "disabled")).strip().lower() broker_mode = str(broker.get("mode", "embedded")).strip().lower() if broker_mode not in {"embedded", "external"}: raise ValueError("broker.mode must be 'embedded' or 'external'") @@ -263,144 +260,192 @@ def load_config(path: str | Path) -> AppConfig: https_port = _as_port(network.get("https_port"), "network.https_port", 555) mqtt_tls_port = _as_port(network.get("mqtt_tls_port"), "network.mqtt_tls_port", 8881) - config = AppConfig( - network=NetworkConfig( - stack_fqdn=_require_stack_fqdn(network.get("stack_fqdn"), "network.stack_fqdn"), - listener_mode=listener_mode, - bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0", - https_port=https_port, - mqtt_tls_port=mqtt_tls_port, - advertised_https_port=_as_port( - network.get("advertised_https_port"), - "network.advertised_https_port", - https_port, - ), - advertised_mqtt_tls_port=_as_port( - network.get("advertised_mqtt_tls_port"), - "network.advertised_mqtt_tls_port", - mqtt_tls_port, - ), - region=str(network.get("region", "us")).strip().lower() or "us", - localkey=str(network.get("localkey", "")).strip(), - duid=str(network.get("duid", "")).strip(), - mqtt_username=str(network.get("mqtt_username", "")).strip(), - mqtt_password=str(network.get("mqtt_password", "")).strip(), - mqtt_client_id=str(network.get("mqtt_client_id", "")).strip(), - trusted_proxies=_as_trusted_proxies( - network.get("trusted_proxies"), - "network.trusted_proxies", - NetworkConfig.trusted_proxies, - ), - timezone=_as_timezone( - network.get("timezone", "") or parsed.get("timezone", ""), - "network.timezone", - ), + network_config = NetworkConfig( + stack_fqdn=_require_stack_fqdn(network.get("stack_fqdn"), "network.stack_fqdn"), + listener_mode=listener_mode, + bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0", + https_port=https_port, + mqtt_tls_port=mqtt_tls_port, + advertised_https_port=_as_port( + network.get("advertised_https_port"), + "network.advertised_https_port", + https_port, ), - broker=BrokerConfig( - mode=broker_mode, - host=broker_host, - port=_as_port(broker.get("port"), "broker.port", broker_port_default), - mosquitto_binary=str(broker.get("mosquitto_binary", "mosquitto")).strip() or "mosquitto", - enable_topic_bridge=_as_bool(broker.get("enable_topic_bridge"), True), + advertised_mqtt_tls_port=_as_port( + network.get("advertised_mqtt_tls_port"), + "network.advertised_mqtt_tls_port", + mqtt_tls_port, ), - storage=StorageConfig( - data_dir=str(storage.get("data_dir", "/data")).strip() or "/data", + region=str(network.get("region", "us")).strip().lower() or "us", + localkey=str(network.get("localkey", "")).strip(), + duid=str(network.get("duid", "")).strip(), + mqtt_username=str(network.get("mqtt_username", "")).strip(), + mqtt_password=str(network.get("mqtt_password", "")).strip(), + mqtt_client_id=str(network.get("mqtt_client_id", "")).strip(), + trusted_proxies=_as_trusted_proxies( + network.get("trusted_proxies"), + "network.trusted_proxies", + NetworkConfig.trusted_proxies, ), - tls=TlsConfig( - mode=tls_mode, - base_domain=( - _normalize_hostname(tls.get("base_domain"), "tls.base_domain") - if str(tls.get("base_domain", "")).strip() - else "" - ), - email=str(tls.get("email", "")).strip(), - cloudflare_token_file=str(tls.get("cloudflare_token_file", "")).strip(), - renew_days_before=_as_int(tls.get("renew_days_before"), "tls.renew_days_before", 30), - renew_check_seconds=_as_int(tls.get("renew_check_seconds"), "tls.renew_check_seconds", 43200), - acme_server=_normalize_acme_server(tls.get("acme_server", "zerossl"), "tls.acme_server"), - acme_eab_kid=str(tls.get("acme_eab_kid", "")).strip(), - acme_eab_hmac_key=str(tls.get("acme_eab_hmac_key", "")).strip(), - acme_eab_kid_file=str(tls.get("acme_eab_kid_file", "")).strip(), - acme_eab_hmac_key_file=str(tls.get("acme_eab_hmac_key_file", "")).strip(), - cert_file=str(tls.get("cert_file", "")).strip(), - key_file=str(tls.get("key_file", "")).strip(), + timezone=_as_timezone( + network.get("timezone", "") or parsed.get("timezone", ""), + "network.timezone", ), - admin=AdminConfig( - password_hash=_require_non_empty(admin.get("password_hash"), "admin.password_hash"), - session_secret=_require_non_empty(admin.get("session_secret"), "admin.session_secret"), - session_ttl_seconds=_as_int(admin.get("session_ttl_seconds"), "admin.session_ttl_seconds", 86400), - protocol_auth_enabled=_as_bool(admin.get("protocol_auth_enabled"), True), - new_connections_enabled=_as_bool(admin.get("new_connections_enabled"), True), - protocol_login_email=_require_non_empty(admin.get("protocol_login_email"), "admin.protocol_login_email"), - protocol_login_pin_hash=_require_non_empty( - admin.get("protocol_login_pin_hash"), - "admin.protocol_login_pin_hash", - ), - ), - turn=TurnConfig( - mode=turn_mode, - host=str(turn.get("host", "")).strip(), - port=_as_port(turn.get("port"), "turn.port", 3478), - username=str(turn.get("username", "") or ("roborock" if turn_mode == "provided" else "")).strip(), - password=str(turn.get("password", "")).strip(), - realm=str(turn.get("realm", "") or turn.get("host", "")).strip(), - ttl=_as_int(turn.get("ttl"), "turn.ttl", 86400), + ) + broker_config = BrokerConfig( + mode=broker_mode, + host=broker_host, + port=_as_port(broker.get("port"), "broker.port", broker_port_default), + mosquitto_binary=str(broker.get("mosquitto_binary", "mosquitto")).strip() or "mosquitto", + enable_topic_bridge=_as_bool(broker.get("enable_topic_bridge"), True), + ) + storage_config = StorageConfig( + data_dir=str(storage.get("data_dir", "/data")).strip() or "/data", + ) + tls_config = TlsConfig( + mode=tls_mode, + base_domain=( + _normalize_hostname(tls.get("base_domain"), "tls.base_domain") + if str(tls.get("base_domain", "")).strip() + else "" ), + email=str(tls.get("email", "")).strip(), + cloudflare_token_file=str(tls.get("cloudflare_token_file", "")).strip(), + renew_days_before=_as_int(tls.get("renew_days_before"), "tls.renew_days_before", 30), + renew_check_seconds=_as_int(tls.get("renew_check_seconds"), "tls.renew_check_seconds", 43200), + acme_server=_normalize_acme_server(tls.get("acme_server", "zerossl"), "tls.acme_server"), + acme_eab_kid=str(tls.get("acme_eab_kid", "")).strip(), + acme_eab_hmac_key=str(tls.get("acme_eab_hmac_key", "")).strip(), + acme_eab_kid_file=str(tls.get("acme_eab_kid_file", "")).strip(), + acme_eab_hmac_key_file=str(tls.get("acme_eab_hmac_key_file", "")).strip(), + cert_file=str(tls.get("cert_file", "")).strip(), + key_file=str(tls.get("key_file", "")).strip(), ) - if len(config.admin.session_secret) < 24: - raise ValueError("admin.session_secret must be at least 24 characters") - if "@" not in config.admin.protocol_login_email: - raise ValueError("admin.protocol_login_email must be an email address") - - if config.broker.mode == "external": - _require_non_empty(config.broker.host, "broker.host") - - if config.turn.mode not in {"disabled", "provided", "external"}: - raise ValueError("turn.mode must be 'disabled', 'provided', or 'external'") - if config.turn.mode != "disabled": - _require_non_empty(config.turn.host, "turn.host") - _require_non_empty(config.turn.username, "turn.username") - _require_non_empty(config.turn.password, "turn.password") - _require_non_empty(config.turn.realm, "turn.realm") - if config.turn.ttl <= 0: - raise ValueError("turn.ttl must be greater than 0") - if config.turn.mode == "provided": - for name in ("username", "password", "realm"): - if any(character in getattr(config.turn, name) for character in "\r\n:="): - raise ValueError(f"turn.{name} cannot contain newlines, colons, or equals signs in provided mode") + if broker_config.mode == "external": + _require_non_empty(broker_config.host, "broker.host") # In external_tls the proxy terminates TLS and presents the cert to clients, # so the server itself needs no certificate material. - if config.network.listener_mode == "local_tls": - if config.tls.mode == "cloudflare_acme": - _normalize_hostname(config.tls.base_domain, "tls.base_domain") - _require_non_empty(config.tls.email, "tls.email") - _require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file") - has_kid = bool(config.tls.acme_eab_kid or config.tls.acme_eab_kid_file) - has_hmac = bool(config.tls.acme_eab_hmac_key or config.tls.acme_eab_hmac_key_file) + if network_config.listener_mode == "local_tls": + if tls_config.mode == "cloudflare_acme": + _normalize_hostname(tls_config.base_domain, "tls.base_domain") + _require_non_empty(tls_config.email, "tls.email") + _require_non_empty(tls_config.cloudflare_token_file, "tls.cloudflare_token_file") + has_kid = bool(tls_config.acme_eab_kid or tls_config.acme_eab_kid_file) + has_hmac = bool(tls_config.acme_eab_hmac_key or tls_config.acme_eab_hmac_key_file) if has_kid != has_hmac: raise ValueError( "tls.acme_eab_kid/tls.acme_eab_kid_file and " "tls.acme_eab_hmac_key/tls.acme_eab_hmac_key_file must be set together" ) - if config.tls.acme_server == "actalis": + if tls_config.acme_server == "actalis": if not has_kid: raise ValueError( "Actalis requires tls.acme_eab_kid or tls.acme_eab_kid_file, " "and tls.acme_eab_hmac_key or tls.acme_eab_hmac_key_file" ) else: - _require_non_empty(config.tls.cert_file, "tls.cert_file") - _require_non_empty(config.tls.key_file, "tls.key_file") - elif config.tls.mode == "cloudflare_acme": + _require_non_empty(tls_config.cert_file, "tls.cert_file") + _require_non_empty(tls_config.key_file, "tls.key_file") + elif tls_config.mode == "cloudflare_acme": # external_tls never issues or renews certificates, so cloudflare_acme # would be a silent no-op. Require the proxy-managed 'provided' mode. raise ValueError( "network.listener_mode='external_tls' requires tls.mode='provided' " "(the proxy terminates TLS; the server does not issue certificates)" ) - return config + + return network_config, broker_config, storage_config, tls_config + + +def _load_turn(parsed: dict[str, object]) -> TurnConfig: + turn = _get_section(parsed, "turn") + mode = str(turn.get("mode", "external" if _as_bool(turn.get("enabled"), False) else "disabled")).strip().lower() + turn_config = TurnConfig( + mode=mode, + host=str(turn.get("host", "")).strip(), + port=_as_port(turn.get("port"), "turn.port", 3478), + username=str(turn.get("username", "") or ("roborock" if mode == "provided" else "")).strip(), + password=str(turn.get("password", "")).strip(), + realm=str(turn.get("realm", "") or turn.get("host", "")).strip(), + ttl=_as_int(turn.get("ttl"), "turn.ttl", 86400), + ) + if turn_config.mode not in {"disabled", "provided", "external"}: + raise ValueError("turn.mode must be 'disabled', 'provided', or 'external'") + if turn_config.mode != "disabled": + _require_non_empty(turn_config.host, "turn.host") + _require_non_empty(turn_config.username, "turn.username") + _require_non_empty(turn_config.password, "turn.password") + _require_non_empty(turn_config.realm, "turn.realm") + if turn_config.ttl <= 0: + raise ValueError("turn.ttl must be greater than 0") + if turn_config.mode == "provided": + for name in ("username", "password", "realm"): + if any(character in getattr(turn_config, name) for character in "\r\n:="): + raise ValueError(f"turn.{name} cannot contain newlines, colons, or equals signs in provided mode") + return turn_config + + +def _load_admin(parsed: dict[str, object]) -> AdminConfig: + admin = _get_section(parsed, "admin") + admin_config = AdminConfig( + password_hash=_require_non_empty(admin.get("password_hash"), "admin.password_hash"), + session_secret=_require_non_empty(admin.get("session_secret"), "admin.session_secret"), + session_ttl_seconds=_as_int(admin.get("session_ttl_seconds"), "admin.session_ttl_seconds", 86400), + protocol_auth_enabled=_as_bool(admin.get("protocol_auth_enabled"), True), + new_connections_enabled=_as_bool(admin.get("new_connections_enabled"), True), + protocol_login_email=_require_non_empty(admin.get("protocol_login_email"), "admin.protocol_login_email"), + protocol_login_pin_hash=_require_non_empty( + admin.get("protocol_login_pin_hash"), + "admin.protocol_login_pin_hash", + ), + ) + if len(admin_config.session_secret) < 24: + raise ValueError("admin.session_secret must be at least 24 characters") + if "@" not in admin_config.protocol_login_email: + raise ValueError("admin.protocol_login_email must be an email address") + return admin_config + + +def load_config(path: str | Path) -> AppConfig: + config_path = Path(path).resolve() + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + network_config, broker_config, storage_config, tls_config = _load_network_broker_storage_tls(parsed) + turn_config = _load_turn(parsed) + admin_config = _load_admin(parsed) + return AppConfig( + network=network_config, + broker=broker_config, + storage=storage_config, + tls=tls_config, + admin=admin_config, + turn=turn_config, + ) + + +def diagnose_config(path: str | Path) -> str: + """Classify config.toml's state for the setup wizard. + + Returns "ok" (loads cleanly), "missing_admin" (network/broker/storage/tls + all validate, but [admin] doesn't - e.g. a partial env-var-generated + config still waiting on the wizard for credentials), or "invalid" (file + missing/unreadable, or something other than [admin] is wrong). + """ + try: + parsed = tomllib.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return "invalid" + try: + _load_network_broker_storage_tls(parsed) + _load_turn(parsed) + except ValueError: + return "invalid" + try: + _load_admin(parsed) + except ValueError: + return "missing_admin" + return "ok" def resolve_paths(config_file: str | Path, config: AppConfig) -> AppPaths: @@ -446,6 +491,7 @@ def resolve_paths(config_file: str | Path, config: AppConfig) -> AppPaths: device_key_state_path=state_dir / "device_key_state.json", http_jsonl_path=runtime_dir / "decompiled_http.jsonl", mqtt_jsonl_path=runtime_dir / "decompiled_mqtt.jsonl", + mitm_activity_jsonl_path=runtime_dir / "mitm_activity.jsonl", cloudflare_token_file=cloudflare_token_file, acme_eab_kid_file=acme_eab_kid_file, acme_eab_hmac_key_file=acme_eab_hmac_key_file, diff --git a/src/roborock_local_server/configure.py b/src/roborock_local_server/configure.py index d830acf..7cf4652 100644 --- a/src/roborock_local_server/configure.py +++ b/src/roborock_local_server/configure.py @@ -46,6 +46,7 @@ class ConfigureAnswers: https_port: int mqtt_tls_port: int broker_mode: str + broker_host: str tls_mode: str base_domain: str email: str @@ -227,6 +228,7 @@ def _validated_answers(answers: ConfigureAnswers) -> ConfigureAnswers: https_port=answers.https_port, mqtt_tls_port=answers.mqtt_tls_port, broker_mode=answers.broker_mode, + broker_host=answers.broker_host, tls_mode=answers.tls_mode, base_domain=answers.base_domain, email=answers.email, @@ -282,6 +284,7 @@ def collect_configure_answers() -> ConfigureAnswers: https_port=https_port, mqtt_tls_port=mqtt_tls_port, broker_mode=broker_mode, + broker_host="", tls_mode=tls_mode, base_domain=base_domain, email=email, @@ -322,8 +325,12 @@ def render_config_toml(answers: ConfigureAnswers) -> str: else: lines.extend( [ - "# Fill this in with your existing MQTT broker hostname or IP before starting the stack.", - 'host = ""', + *( + ["# Fill this in with your existing MQTT broker hostname or IP before starting the stack."] + if not answers.broker_host + else [] + ), + f"host = {_toml_string(answers.broker_host)}", "port = 1883", 'mosquitto_binary = "mosquitto"', "enable_topic_bridge = true", @@ -448,7 +455,7 @@ def write_config_setup( cloudflare_token_file=written_token_path, actalis_eab_kid_file=written_actalis_kid_path, actalis_eab_hmac_key_file=written_actalis_hmac_path, - broker_template_needs_edit=answers.broker_mode == "external", + broker_template_needs_edit=answers.broker_mode == "external" and not answers.broker_host, ) diff --git a/src/roborock_local_server/container_entrypoint.py b/src/roborock_local_server/container_entrypoint.py index 3d423ea..b4ade9c 100644 --- a/src/roborock_local_server/container_entrypoint.py +++ b/src/roborock_local_server/container_entrypoint.py @@ -4,7 +4,9 @@ import os from pathlib import Path +import sys +from .env_config import has_env_config, write_config_from_env from .ha_addon import write_config_from_home_assistant_options @@ -32,9 +34,26 @@ def _run_entrypoint(*, compose_config: Path, data_config: Path, addon_options: P _exec_server(data_config) return - raise SystemExit( - "No config file found. Expected /app/config.toml, /data/config.toml, or /data/options.json." - ) + if has_env_config(os.environ): + try: + write_config_from_env(os.environ, config_path=data_config) + except ValueError as exc: + print( + f"ROBOROCK_SERVER_STACK_FQDN is set, but generating config.toml from " + f"ROBOROCK_SERVER_* env vars failed: {exc}. Falling back to the Setup Wizard " + "at /admin to finish configuration - set the remaining env vars instead if you " + "want a fully headless boot.", + file=sys.stderr, + ) + else: + _exec_server(data_config) + return + + # No usable config anywhere yet. `serve` itself now handles this: it + # serves the Setup Wizard at /admin until /data/config.toml exists, then + # a container restart (Docker's restart policy) picks up the full stack + # once the wizard writes it. + _exec_server(data_config) def main() -> int: diff --git a/src/roborock_local_server/env_config.py b/src/roborock_local_server/env_config.py new file mode 100644 index 0000000..12758d3 --- /dev/null +++ b/src/roborock_local_server/env_config.py @@ -0,0 +1,394 @@ +"""Generate config.toml from ROBOROCK_SERVER_* environment variables. + +This lets Docker Compose (and similar) deployments boot straight from +environment variables, without running the host-side `configure` wizard +first. It intentionally covers only the deployment/infrastructure-shaped +settings (network, broker, storage, tls); it is not meant to grow a +variable for every config.toml key. + +The [admin] credentials (password, protocol login email/PIN) are optional +as a group: set none of them and the container boots straight into the +Setup Wizard (/admin) with network/broker/storage/tls already filled in +from these env vars - only the credentials are still asked for. Set all +of them for a fully headless boot with no browser step at all. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import ipaddress +import json +import os +from pathlib import Path +import re +import secrets +import tomllib +from typing import Mapping +from urllib.parse import urlsplit + +from .configure import hash_password + +ENV_PREFIX = "ROBOROCK_SERVER_" + +_HOST_RE = re.compile(r"^[a-z0-9.-]+$") + +DEFAULT_CONFIG_PATH = Path("/data/config.toml") +DEFAULT_CLOUDFLARE_TOKEN_PATH = Path("/run/secrets/cloudflare_token") +DEFAULT_ACME_EAB_KID_PATH = Path("/run/secrets/acme_eab_kid") +DEFAULT_ACME_EAB_HMAC_KEY_PATH = Path("/run/secrets/acme_eab_hmac_key") + + +def _toml_string(value: str) -> str: + return json.dumps(value) + + +def _toml_bool(value: bool) -> str: + return "true" if value else "false" + + +def _get(env: Mapping[str, str], name: str, default: str = "") -> str: + return str(env.get(f"{ENV_PREFIX}{name}", "") or "").strip() or default + + +def _normalize_hostname(raw_value: str, *, field_name: str, require_api_prefix: bool = False) -> str: + text = str(raw_value or "").strip() + if not text: + raise ValueError(f"{field_name} is required") + if "://" in text: + parsed = urlsplit(text) + candidate = parsed.hostname or "" + else: + candidate = text.split("/", 1)[0].strip() + if ":" in candidate: + candidate = candidate.split(":", 1)[0].strip() + normalized = candidate.strip().strip(".").lower() + if normalized.startswith("*."): + normalized = normalized[2:].strip() + if not normalized: + raise ValueError(f"{field_name} is required") + if " " in normalized or not _HOST_RE.fullmatch(normalized): + raise ValueError(f"{field_name} must be a hostname without a scheme or path") + if "." not in normalized: + raise ValueError(f"{field_name} must be a fully qualified domain name") + if require_api_prefix and not normalized.startswith("api-"): + raise ValueError(f"{field_name} must start with api-") + return normalized + + +def _as_port(value: str, *, field_name: str, default: int) -> int: + if not value: + return default + try: + candidate = int(value) + except ValueError as exc: + raise ValueError(f"{field_name} must be an integer") from exc + if not (1 <= candidate <= 65535): + raise ValueError(f"{field_name} must be between 1 and 65535") + return candidate + + +def _as_bool(value: str, *, default: bool) -> bool: + if not value: + return default + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + raise ValueError(f"expected a boolean value, got {value!r}") + + +def _as_trusted_proxies(value: str, *, default: tuple[str, ...]) -> tuple[str, ...]: + if not value: + return default + entries: list[str] = [] + for item in value.split(","): + text = item.strip() + if not text: + continue + try: + ipaddress.ip_network(text, strict=False) + except ValueError as exc: + raise ValueError(f"trusted_proxies entry {text!r} is not a valid IP address or CIDR network") from exc + entries.append(text) + return tuple(entries) if entries else default + + +def _require_non_empty(value: str, *, field_name: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"{ENV_PREFIX}{field_name} is required") + return text + + +def _require_email(value: str, *, field_name: str) -> str: + text = _require_non_empty(value, field_name=field_name) + if "@" not in text: + raise ValueError(f"{ENV_PREFIX}{field_name} must be an email address") + return text + + +def _require_pin(value: str, *, field_name: str) -> str: + text = _require_non_empty(value, field_name=field_name) + if len(text) != 6 or not text.isdigit(): + raise ValueError(f"{ENV_PREFIX}{field_name} must be exactly 6 digits") + return text + + +def _normalize_acme_server(value: str) -> str: + normalized = value.strip().lower() or "zerossl" + if normalized not in {"zerossl", "actalis"}: + raise ValueError(f"{ENV_PREFIX}ACME_SERVER must be 'zerossl' or 'actalis'") + return normalized + + +def _load_existing_admin_session_secret(config_path: Path) -> str: + if not config_path.exists(): + return "" + try: + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return "" + admin = parsed.get("admin") + if not isinstance(admin, dict): + return "" + secret = str(admin.get("session_secret", "") or "").strip() + return secret if len(secret) >= 24 else "" + + +@dataclass(frozen=True) +class SecretPaths: + """Where env-supplied plaintext secrets get written, and the Docker-secret + style *_FILE overrides that skip writing entirely.""" + + cloudflare_token_file: Path = DEFAULT_CLOUDFLARE_TOKEN_PATH + acme_eab_kid_file: Path = DEFAULT_ACME_EAB_KID_PATH + acme_eab_hmac_key_file: Path = DEFAULT_ACME_EAB_HMAC_KEY_PATH + + +def has_env_config(env: Mapping[str, str]) -> bool: + """True when enough ROBOROCK_SERVER_* vars are present to attempt generation.""" + return bool(_get(env, "STACK_FQDN")) + + +def render_config_toml_from_env( + env: Mapping[str, str], + *, + config_path: Path, + secret_paths: SecretPaths = SecretPaths(), +) -> tuple[str, dict[Path, str]]: + stack_fqdn = _normalize_hostname(_get(env, "STACK_FQDN"), field_name="STACK_FQDN", require_api_prefix=True) + listener_mode = _get(env, "LISTENER_MODE", "local_tls").lower() + if listener_mode not in {"local_tls", "external_tls"}: + raise ValueError(f"{ENV_PREFIX}LISTENER_MODE must be 'local_tls' or 'external_tls'") + bind_host = _get(env, "BIND_HOST", "0.0.0.0") + region = _get(env, "REGION", "us").lower() + https_port = _as_port(_get(env, "HTTPS_PORT"), field_name=f"{ENV_PREFIX}HTTPS_PORT", default=555) + mqtt_tls_port = _as_port(_get(env, "MQTT_TLS_PORT"), field_name=f"{ENV_PREFIX}MQTT_TLS_PORT", default=8881) + advertised_https_port = _as_port( + _get(env, "ADVERTISED_HTTPS_PORT"), + field_name=f"{ENV_PREFIX}ADVERTISED_HTTPS_PORT", + default=https_port, + ) + advertised_mqtt_tls_port = _as_port( + _get(env, "ADVERTISED_MQTT_TLS_PORT"), + field_name=f"{ENV_PREFIX}ADVERTISED_MQTT_TLS_PORT", + default=mqtt_tls_port, + ) + trusted_proxies = _as_trusted_proxies(_get(env, "TRUSTED_PROXIES"), default=("127.0.0.1", "::1")) + + broker_mode = _get(env, "BROKER_MODE", "embedded").lower() + if broker_mode not in {"embedded", "external"}: + raise ValueError(f"{ENV_PREFIX}BROKER_MODE must be 'embedded' or 'external'") + if broker_mode == "embedded": + broker_host = _get(env, "BROKER_HOST", "127.0.0.1") + broker_port = _as_port(_get(env, "BROKER_PORT"), field_name=f"{ENV_PREFIX}BROKER_PORT", default=18830) + else: + broker_host = _require_non_empty(_get(env, "BROKER_HOST"), field_name="BROKER_HOST") + broker_port = _as_port(_get(env, "BROKER_PORT"), field_name=f"{ENV_PREFIX}BROKER_PORT", default=1883) + + data_dir = _get(env, "DATA_DIR", "/data") + + tls_mode = _get(env, "TLS_MODE", "cloudflare_acme").lower() + if tls_mode not in {"cloudflare_acme", "provided"}: + raise ValueError(f"{ENV_PREFIX}TLS_MODE must be 'cloudflare_acme' or 'provided'") + if listener_mode == "external_tls" and tls_mode != "provided": + raise ValueError( + f"{ENV_PREFIX}LISTENER_MODE='external_tls' requires {ENV_PREFIX}TLS_MODE='provided' " + "(the proxy terminates TLS; the server does not issue certificates)" + ) + + tls_base_domain = "" + tls_email = "" + acme_server = "zerossl" + cloudflare_token_file = "" + acme_eab_kid_file = "" + acme_eab_hmac_key_file = "" + cert_file = "" + key_file = "" + secrets_to_write: dict[Path, str] = {} + + if listener_mode == "local_tls": + if tls_mode == "cloudflare_acme": + tls_base_domain = _normalize_hostname(_get(env, "TLS_BASE_DOMAIN"), field_name="TLS_BASE_DOMAIN") + tls_email = _require_non_empty(_get(env, "TLS_EMAIL"), field_name="TLS_EMAIL") + acme_server = _normalize_acme_server(_get(env, "ACME_SERVER", "zerossl")) + + cloudflare_token_file_override = _get(env, "CLOUDFLARE_TOKEN_FILE") + cloudflare_token = _get(env, "CLOUDFLARE_TOKEN") + if cloudflare_token_file_override: + cloudflare_token_file = cloudflare_token_file_override + elif cloudflare_token: + cloudflare_token_file = str(secret_paths.cloudflare_token_file) + secrets_to_write[secret_paths.cloudflare_token_file] = cloudflare_token + else: + raise ValueError( + f"{ENV_PREFIX}CLOUDFLARE_TOKEN or {ENV_PREFIX}CLOUDFLARE_TOKEN_FILE is required " + f"when {ENV_PREFIX}TLS_MODE='cloudflare_acme'" + ) + + if acme_server == "actalis": + eab_kid_file_override = _get(env, "ACME_EAB_KID_FILE") + eab_kid = _get(env, "ACME_EAB_KID") + eab_hmac_file_override = _get(env, "ACME_EAB_HMAC_KEY_FILE") + eab_hmac = _get(env, "ACME_EAB_HMAC_KEY") + if not (eab_kid_file_override or eab_kid): + raise ValueError(f"Actalis requires {ENV_PREFIX}ACME_EAB_KID or {ENV_PREFIX}ACME_EAB_KID_FILE") + if not (eab_hmac_file_override or eab_hmac): + raise ValueError( + f"Actalis requires {ENV_PREFIX}ACME_EAB_HMAC_KEY or {ENV_PREFIX}ACME_EAB_HMAC_KEY_FILE" + ) + acme_eab_kid_file = eab_kid_file_override or str(secret_paths.acme_eab_kid_file) + if not eab_kid_file_override: + secrets_to_write[secret_paths.acme_eab_kid_file] = eab_kid + acme_eab_hmac_key_file = eab_hmac_file_override or str(secret_paths.acme_eab_hmac_key_file) + if not eab_hmac_file_override: + secrets_to_write[secret_paths.acme_eab_hmac_key_file] = eab_hmac + else: + cert_file = _require_non_empty(_get(env, "CERT_FILE"), field_name="CERT_FILE") + key_file = _require_non_empty(_get(env, "KEY_FILE"), field_name="KEY_FILE") + + # The [admin] fields are optional as a group: set none of them to leave + # credentials to the Setup Wizard (it fills in just [admin] on top of + # this network/broker/storage/tls config), or set all of them for a + # fully headless, no-browser-step boot. + raw_admin_password = _get(env, "ADMIN_PASSWORD") + raw_protocol_login_email = _get(env, "PROTOCOL_LOGIN_EMAIL") + raw_protocol_login_pin = _get(env, "PROTOCOL_LOGIN_PIN") + admin_fields_present = (bool(raw_admin_password), bool(raw_protocol_login_email), bool(raw_protocol_login_pin)) + if any(admin_fields_present) and not all(admin_fields_present): + raise ValueError( + f"Set all of {ENV_PREFIX}ADMIN_PASSWORD, {ENV_PREFIX}PROTOCOL_LOGIN_EMAIL, and " + f"{ENV_PREFIX}PROTOCOL_LOGIN_PIN together, or none of them (and finish setup in the " + "Setup Wizard at /admin instead)." + ) + include_admin = all(admin_fields_present) + + lines = [ + "[network]", + f"stack_fqdn = {_toml_string(stack_fqdn)}", + f"listener_mode = {_toml_string(listener_mode)}", + f"bind_host = {_toml_string(bind_host)}", + f"https_port = {https_port}", + f"mqtt_tls_port = {mqtt_tls_port}", + f"advertised_https_port = {advertised_https_port}", + f"advertised_mqtt_tls_port = {advertised_mqtt_tls_port}", + f"region = {_toml_string(region)}", + f"trusted_proxies = [{', '.join(_toml_string(p) for p in trusted_proxies)}]", + "", + "[broker]", + f"mode = {_toml_string(broker_mode)}", + f"host = {_toml_string(broker_host)}", + f"port = {broker_port}", + 'mosquitto_binary = "mosquitto"', + "enable_topic_bridge = true", + "", + "[storage]", + f"data_dir = {_toml_string(data_dir)}", + "", + "[tls]", + f"mode = {_toml_string(tls_mode)}", + ] + if listener_mode == "local_tls" and tls_mode == "cloudflare_acme": + lines.extend( + [ + f"base_domain = {_toml_string(tls_base_domain)}", + f"email = {_toml_string(tls_email)}", + f"cloudflare_token_file = {_toml_string(cloudflare_token_file)}", + "renew_days_before = 30", + "renew_check_seconds = 43200", + f"acme_server = {_toml_string(acme_server)}", + 'acme_eab_kid = ""', + 'acme_eab_hmac_key = ""', + f"acme_eab_kid_file = {_toml_string(acme_eab_kid_file)}", + f"acme_eab_hmac_key_file = {_toml_string(acme_eab_hmac_key_file)}", + ] + ) + else: + lines.extend( + [ + 'base_domain = ""', + 'email = ""', + 'cloudflare_token_file = ""', + "renew_days_before = 30", + "renew_check_seconds = 43200", + f"acme_server = {_toml_string(acme_server)}", + 'acme_eab_kid = ""', + 'acme_eab_hmac_key = ""', + 'acme_eab_kid_file = ""', + 'acme_eab_hmac_key_file = ""', + f"cert_file = {_toml_string(cert_file)}", + f"key_file = {_toml_string(key_file)}", + ] + ) + + if include_admin: + admin_session_secret = ( + _get(env, "ADMIN_SESSION_SECRET") + or _load_existing_admin_session_secret(config_path) + or secrets.token_urlsafe(32) + ) + if len(admin_session_secret) < 24: + raise ValueError(f"{ENV_PREFIX}ADMIN_SESSION_SECRET must be at least 24 characters when set") + new_connections_enabled = _as_bool(_get(env, "NEW_CONNECTIONS_ENABLED"), default=True) + protocol_login_email = _require_email(raw_protocol_login_email, field_name="PROTOCOL_LOGIN_EMAIL") + protocol_login_pin = _require_pin(raw_protocol_login_pin, field_name="PROTOCOL_LOGIN_PIN") + password_hash = hash_password(raw_admin_password) + protocol_login_pin_hash = hash_password(protocol_login_pin) + + lines.extend( + [ + "", + "[admin]", + f"password_hash = {_toml_string(password_hash)}", + f"session_secret = {_toml_string(admin_session_secret)}", + "session_ttl_seconds = 86400", + "protocol_auth_enabled = true", + f"new_connections_enabled = {_toml_bool(new_connections_enabled)}", + f"protocol_login_email = {_toml_string(protocol_login_email)}", + f"protocol_login_pin_hash = {_toml_string(protocol_login_pin_hash)}", + "", + ] + ) + return "\n".join(lines), secrets_to_write + + +def write_config_from_env( + env: Mapping[str, str], + *, + config_path: Path = DEFAULT_CONFIG_PATH, + secret_paths: SecretPaths = SecretPaths(), +) -> Path: + config_text, secrets_to_write = render_config_toml_from_env( + env, + config_path=config_path, + secret_paths=secret_paths, + ) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(config_text, encoding="utf-8") + for path, contents in secrets_to_write.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + if os.name != "nt": + path.chmod(0o600) + return config_path diff --git a/src/roborock_local_server/management_app.py b/src/roborock_local_server/management_app.py new file mode 100644 index 0000000..2a46e31 --- /dev/null +++ b/src/roborock_local_server/management_app.py @@ -0,0 +1,636 @@ +"""Bootstrap /admin app, served before config.toml exists. + +There's only one admin surface, at /admin. Before setup, this app serves the +Setup Wizard in plain HTTP on the same https_port the real stack will use +once configured (it can't depend on anything that requires a config - TLS +certs, ReleaseSupervisor, admin auth). Once the wizard writes config.toml, +the process restarts and ReleaseSupervisor's own /admin (standalone_admin.py, +with TLS if local_tls) takes over that same port for good. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +import html +import json +import os +from pathlib import Path +import secrets +from textwrap import dedent +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from .config import diagnose_config +from .configure import ( + ConfigureAnswers, + _normalize_acme_server, + _normalize_hostname, + _validate_protocol_login_pin, + hash_password, + write_config_setup, +) +from .env_config import ENV_PREFIX +from .web_theme import HEAD_ASSETS, NAV_HTML, SCRIPT_ASSETS, register_theme_routes + +DEFAULT_BOOTSTRAP_HTTPS_PORT = 555 +_ENV_HTTPS_PORT = "ROBOROCK_SERVER_HTTPS_PORT" + + +def bootstrap_port_from_env(env: Mapping[str, str]) -> int: + """The port the setup wizard listens on, before config.toml sets https_port. + + Reuses ROBOROCK_SERVER_HTTPS_PORT, the same env var already used for the + Docker Compose port mapping, so there's one port/one env var either way. + """ + raw_value = str(env.get(_ENV_HTTPS_PORT, "") or "").strip() + if not raw_value: + return DEFAULT_BOOTSTRAP_HTTPS_PORT + try: + port = int(raw_value) + except ValueError as exc: + raise ValueError(f"{_ENV_HTTPS_PORT} must be an integer") from exc + if not (1 <= port <= 65535): + raise ValueError(f"{_ENV_HTTPS_PORT} must be between 1 and 65535") + return port + + +def _as_port(value: Any, *, field_name: str, default: int) -> int: + if value in (None, ""): + return default + try: + candidate = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field_name} must be an integer") from exc + if not (1 <= candidate <= 65535): + raise ValueError(f"{field_name} must be between 1 and 65535") + return candidate + + +def _answers_from_payload(body: Mapping[str, Any]) -> ConfigureAnswers: + def s(key: str, default: str = "") -> str: + return str(body.get(key, default) or "").strip() + + stack_fqdn = _normalize_hostname(s("stack_fqdn"), field_name="stack_fqdn", require_api_prefix=True) + https_port = _as_port(body.get("https_port"), field_name="https_port", default=555) + mqtt_tls_port = _as_port(body.get("mqtt_tls_port"), field_name="mqtt_tls_port", default=8881) + + broker_mode = s("broker_mode", "embedded").lower() + if broker_mode not in {"embedded", "external"}: + raise ValueError("broker_mode must be 'embedded' or 'external'") + broker_host = s("broker_host") + if broker_mode == "external" and not broker_host: + raise ValueError("broker_host is required when broker_mode is 'external'") + + tls_mode = s("tls_mode", "cloudflare_acme").lower() + if tls_mode not in {"cloudflare_acme", "provided"}: + raise ValueError("tls_mode must be 'cloudflare_acme' or 'provided'") + + base_domain = "" + email = "" + acme_server = "zerossl" + acme_eab_kid = "" + acme_eab_hmac_key = "" + cloudflare_token = "" + if tls_mode == "cloudflare_acme": + base_domain = _normalize_hostname(s("base_domain"), field_name="base_domain") + email = s("email") + if not email: + raise ValueError("email is required") + acme_server = _normalize_acme_server(s("acme_server", "zerossl")) + cloudflare_token = s("cloudflare_token") + if not cloudflare_token: + raise ValueError("cloudflare_token is required") + if acme_server == "actalis": + acme_eab_kid = s("acme_eab_kid") + acme_eab_hmac_key = s("acme_eab_hmac_key") + if not acme_eab_kid or not acme_eab_hmac_key: + raise ValueError("Actalis requires both acme_eab_kid and acme_eab_hmac_key") + + admin_password = s("admin_password") + if not admin_password: + raise ValueError("admin_password is required") + protocol_login_email = s("protocol_login_email") + if "@" not in protocol_login_email: + raise ValueError("protocol_login_email must be an email address") + protocol_login_pin = _validate_protocol_login_pin(s("protocol_login_pin")) + + return ConfigureAnswers( + stack_fqdn=stack_fqdn, + https_port=https_port, + mqtt_tls_port=mqtt_tls_port, + broker_mode=broker_mode, + broker_host=broker_host, + tls_mode=tls_mode, + base_domain=base_domain, + email=email, + acme_server=acme_server, + acme_eab_kid=acme_eab_kid, + acme_eab_hmac_key=acme_eab_hmac_key, + cloudflare_token=cloudflare_token, + password_hash=hash_password(admin_password), + session_secret=secrets.token_urlsafe(32), + protocol_login_email=protocol_login_email, + protocol_login_pin_hash=hash_password(protocol_login_pin), + ) + + +def _toml_string(value: str) -> str: + return json.dumps(value) + + +def _admin_fields_from_payload(body: Mapping[str, Any]) -> tuple[str, str, str]: + def s(key: str, default: str = "") -> str: + return str(body.get(key, default) or "").strip() + + admin_password = s("admin_password") + if not admin_password: + raise ValueError("admin_password is required") + protocol_login_email = s("protocol_login_email") + if "@" not in protocol_login_email: + raise ValueError("protocol_login_email must be an email address") + protocol_login_pin = _validate_protocol_login_pin(s("protocol_login_pin")) + return admin_password, protocol_login_email, protocol_login_pin + + +def complete_admin_section( + *, + config_file: Path, + admin_password: str, + protocol_login_email: str, + protocol_login_pin: str, +) -> None: + """Append [admin] to a partial config.toml (network/broker/storage/tls + already written, e.g. by env_config.py) that's only missing credentials. + + Appending is safe here because env_config.py never writes an [admin] + section itself when these vars are unset - there's nothing to collide + with or overwrite. + """ + admin_block = "\n".join( + [ + "", + "[admin]", + f"password_hash = {_toml_string(hash_password(admin_password))}", + f"session_secret = {_toml_string(secrets.token_urlsafe(32))}", + "session_ttl_seconds = 86400", + "protocol_auth_enabled = true", + "new_connections_enabled = true", + f"protocol_login_email = {_toml_string(protocol_login_email)}", + f"protocol_login_pin_hash = {_toml_string(hash_password(protocol_login_pin))}", + "", + ] + ) + with config_file.open("a", encoding="utf-8") as handle: + handle.write(admin_block) + + +def _status_html() -> str: + return ( + dedent( + """\ + + Roborock Local Server + """ + ) + + HEAD_ASSETS + + dedent( + """\ + + """ + ) + + NAV_HTML + + dedent( + """\ +
+

Setup is complete

+

The stack is restarting into the full dashboard at this same address - reload in a few + seconds.

+
+ """ + ) + + SCRIPT_ASSETS + + "\n\n" + ) + + +_WIZARD_SCRIPT_HEAD = dedent( + """\ + + """ +) + + +def _wizard_prefill_from_env(env: Mapping[str, str]) -> dict[str, str]: + """Values already set via ROBOROCK_SERVER_* env vars, to pre-fill the wizard with. + + Only covers non-secret fields (never cloudflare_token/admin_password/PIN - + those are exactly what the wizard exists to collect in the browser rather + than a compose file). Used when env-var generation fails partway through + (see container_entrypoint.py) so whatever *was* set doesn't have to be + retyped. + """ + + def get(name: str) -> str: + return str(env.get(f"{ENV_PREFIX}{name}", "") or "").strip() + + prefill = { + "stack_fqdn": get("STACK_FQDN"), + "https_port": get("HTTPS_PORT"), + "mqtt_tls_port": get("MQTT_TLS_PORT"), + "broker_mode": get("BROKER_MODE").lower(), + "broker_host": get("BROKER_HOST"), + "tls_mode": get("TLS_MODE").lower(), + "base_domain": get("TLS_BASE_DOMAIN"), + "email": get("TLS_EMAIL"), + "acme_server": get("ACME_SERVER").lower(), + } + return {key: value for key, value in prefill.items() if value} + + +def _checked(prefill: Mapping[str, str], field: str, value: str, *, default: str) -> str: + return "checked" if prefill.get(field, default) == value else "" + + +def _selected(prefill: Mapping[str, str], field: str, value: str, *, default: str) -> str: + return "selected" if prefill.get(field, default) == value else "" + + +def _hidden_unless(prefill: Mapping[str, str], field: str, value: str, *, default: str) -> str: + return "" if prefill.get(field, default) == value else "hidden" + + +def _setup_wizard_html(prefill: Mapping[str, str] | None = None) -> str: + prefill = prefill or {} + stack_fqdn_value = html.escape(prefill.get("stack_fqdn", "")) + https_port_value = html.escape(prefill.get("https_port", "") or "555") + mqtt_tls_port_value = html.escape(prefill.get("mqtt_tls_port", "") or "8881") + broker_host_value = html.escape(prefill.get("broker_host", "")) + base_domain_value = html.escape(prefill.get("base_domain", "")) + email_value = html.escape(prefill.get("email", "")) + broker_embedded_checked = _checked(prefill, "broker_mode", "embedded", default="embedded") + broker_external_checked = _checked(prefill, "broker_mode", "external", default="embedded") + broker_external_hidden = _hidden_unless(prefill, "broker_mode", "external", default="embedded") + tls_cloudflare_checked = _checked(prefill, "tls_mode", "cloudflare_acme", default="cloudflare_acme") + tls_provided_checked = _checked(prefill, "tls_mode", "provided", default="cloudflare_acme") + tls_cloudflare_hidden = _hidden_unless(prefill, "tls_mode", "cloudflare_acme", default="cloudflare_acme") + tls_provided_hidden = _hidden_unless(prefill, "tls_mode", "provided", default="cloudflare_acme") + acme_zerossl_selected = _selected(prefill, "acme_server", "zerossl", default="zerossl") + acme_actalis_selected = _selected(prefill, "acme_server", "actalis", default="zerossl") + acme_actalis_hidden = _hidden_unless(prefill, "acme_server", "actalis", default="zerossl") + + return ( + dedent( + """\ + + Roborock Local Server Setup + """ + ) + + HEAD_ASSETS + + dedent( + """\ + + """ + ) + + NAV_HTML + + dedent( + f"""\ +
+

This runs once, the first time the stack boots without a config.toml. Fill + this in, submit, and the container will restart into the full HTTPS/MQTT stack. Fields already + set via ROBOROCK_SERVER_* env vars are pre-filled below.

+
+
Network
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
MQTT Broker
+

+ +

+

+ +

+
+
+ + +
+
+
+ +
Certificates
+

+ +

+

+ +

+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+

+ Place your certificate at data/certs/fullchain.pem and key at + data/certs/privkey.pem (relative to the compose file) before starting the stack. +

+
""" + ) + + dedent( + """\ + + +
Admin Access
+
+
+ + +
+
+ + +
+
+
+ +
App / Home Assistant Login
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ """ + ) + + SCRIPT_ASSETS + + "\n" + + _WIZARD_SCRIPT_HEAD + + dedent( + """\ + document.addEventListener("DOMContentLoaded", () => { + M.FormSelect.init(document.querySelectorAll("select")); + M.updateTextFields(); + }); + + function toggle(radioName, mapping) { + for (const radio of document.getElementsByName(radioName)) { + radio.addEventListener("change", () => { + for (const other of document.getElementsByName(radioName)) { + if (other.checked) { + for (const [value, elementId] of Object.entries(mapping)) { + document.getElementById(elementId).classList.toggle("hidden", other.value !== value); + } + } + } + }); + } + } + toggle("broker_mode", {external: "broker_external"}); + toggle("tls_mode", {cloudflare_acme: "tls_cloudflare", provided: "tls_provided"}); + document.getElementById("acme_server").addEventListener("change", (event) => { + document.getElementById("acme_actalis").classList.toggle("hidden", event.target.value !== "actalis"); + }); + + """ + ) + + _WIZARD_SCRIPT_SUBMIT_COMMON + + "\n" + ) + + +def _admin_only_wizard_html() -> str: + return ( + dedent( + """\ + + Roborock Local Server Setup + """ + ) + + HEAD_ASSETS + + dedent( + """\ + + """ + ) + + NAV_HTML + + dedent( + """\ +
+

Network, broker, and certificate settings are already set (from environment + variables). Just add admin credentials to finish setup.

+
+
Admin Access
+
+
+ + +
+
+ + +
+
+
+ +
App / Home Assistant Login
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ """ + ) + + SCRIPT_ASSETS + + "\n" + + _WIZARD_SCRIPT_HEAD + + _WIZARD_SCRIPT_SUBMIT_COMMON + + "\n" + ) + + +def create_management_app( + *, + config_file: Path, + on_configured: Callable[[], None] | None = None, +) -> FastAPI: + app = FastAPI(title="Roborock Local Server Setup", docs_url=None, redoc_url=None, openapi_url=None) + register_theme_routes(app) + + @app.get("/", response_class=HTMLResponse) + @app.get("/admin", response_class=HTMLResponse) + async def index() -> HTMLResponse: + status = diagnose_config(config_file) + if status == "ok": + return HTMLResponse(_status_html()) + if status == "missing_admin": + return HTMLResponse(_admin_only_wizard_html()) + return HTMLResponse(_setup_wizard_html(prefill=_wizard_prefill_from_env(os.environ))) + + @app.get("/admin/api/setup/status") + async def setup_status() -> JSONResponse: + return JSONResponse({"status": diagnose_config(config_file)}) + + @app.post("/admin/api/setup") + async def submit_setup(request: Request) -> JSONResponse: + status = diagnose_config(config_file) + if status == "ok": + return JSONResponse({"error": "Already configured."}, status_code=409) + + try: + raw_body = await request.body() + body = json.loads(raw_body or b"{}") + if not isinstance(body, dict): + raise ValueError("Request body must be a JSON object.") + + if status == "missing_admin": + admin_password, protocol_login_email, protocol_login_pin = _admin_fields_from_payload(body) + complete_admin_section( + config_file=config_file, + admin_password=admin_password, + protocol_login_email=protocol_login_email, + protocol_login_pin=protocol_login_pin, + ) + result_config_file = config_file + else: + answers = _answers_from_payload(body) + result_config_file = write_config_setup(config_file=config_file, answers=answers).config_file + except (ValueError, FileExistsError) as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + if on_configured is not None: + on_configured() + return JSONResponse({"ok": True, "config_file": str(result_config_file)}) + + return app diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py index 28a5765..ce4c2be 100644 --- a/src/roborock_local_server/server.py +++ b/src/roborock_local_server/server.py @@ -9,6 +9,7 @@ import hashlib import json import logging +import os from pathlib import Path import secrets import signal @@ -23,6 +24,7 @@ import uvicorn from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware +from .activity_log import activity_raw_enabled, read_recent_activity from .certs import CertificateManager from .bundled_backend.shared.constants import DEFAULT_PRODUCT_SCHEMA from .bundled_backend.shared.data_helpers import utcnow_iso @@ -30,6 +32,7 @@ from .bundled_backend.shared.runtime_state import ONBOARDING_STEP_LABELS, REQUIRED_ONBOARDING_STEPS from .cloud import CloudImportManager from .config import AppConfig, AppPaths, load_config, resolve_paths +from .management_app import bootstrap_port_from_env, create_management_app from .turn_relay import EmbeddedTurnRelay from .standalone_admin import register_standalone_admin_routes from .backend import ( @@ -67,6 +70,7 @@ ALL_HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") PROTOCOL_AUTH_SYNC_PATH = "/internal/protocol/user-data" PROTOCOL_AUTH_SYNC_SECRET_HEADER = "x-local-sync-secret" +MITM_ACTIVITY_SYNC_PATH = "/internal/protocol/mitm-activity" _REGION_COUNTRY_CODE = { "US": "1", "CN": "86", @@ -848,6 +852,10 @@ def _new_connections_disabled_payload(flow: str) -> tuple[int, dict[str, Any]]: def _is_protocol_sync_path(cls, clean_path: str) -> bool: return cls._normalized_path(clean_path) == PROTOCOL_AUTH_SYNC_PATH + @classmethod + def _is_mitm_activity_sync_path(cls, clean_path: str) -> bool: + return cls._normalized_path(clean_path) == MITM_ACTIVITY_SYNC_PATH + @staticmethod def _protocol_sync_success_payload(*, source: str) -> dict[str, Any]: return { @@ -905,6 +913,56 @@ async def _handle_protocol_sync_route( return "protocol_auth_sync", 200, self._protocol_sync_success_payload(source=source) + async def _handle_mitm_activity_sync_route( + self, + *, + method: str, + clean_path: str, + headers: dict[str, str], + body_params: dict[str, list[str]], + ) -> tuple[str, int, dict[str, Any]] | None: + """Accepts activity entries from mitm_redirect.py's optional --activity-sync. + + Reuses the same admin.session_secret-based auth as the existing + protocol auth sync (_sync_secret_matches) - no new trust mechanism. + Entries are appended as-is (mitm_redirect.py sends full detail, same + as it already does for login-credential sync); activity_log.py is + what decides whether to redact them for display. + """ + if not self._is_mitm_activity_sync_path(clean_path): + return None + if method.upper() != "POST": + return "mitm_activity_sync_method_not_allowed", 405, self._protocol_sync_failure_payload( + reason="method_not_allowed" + ) + if not self._sync_secret_matches(headers): + return "mitm_activity_sync_unauthorized", 401, self._protocol_sync_failure_payload( + reason="invalid_sync_secret" + ) + + payload = _request_json_object(body_params) + entries = payload.get("entries") + if not isinstance(entries, list) or not entries: + return "mitm_activity_sync_invalid_payload", 400, self._protocol_sync_failure_payload( + reason="missing_entries" + ) + + stored = 0 + for candidate in entries: + if not isinstance(candidate, dict): + continue + record = dict(candidate) + record.setdefault("time", utcnow_iso()) + record["source"] = "mitm" + append_jsonl(self.paths.mitm_activity_jsonl_path, record) + stored += 1 + if stored == 0: + return "mitm_activity_sync_invalid_payload", 400, self._protocol_sync_failure_payload( + reason="missing_entries" + ) + + return "mitm_activity_sync", 200, {"code": 200, "msg": "success", "data": {"stored": stored}} + async def _handle_protocol_login_route( self, *, @@ -971,7 +1029,9 @@ async def _handle_roborock_request(self, request: Request) -> Response: query_params = _request_query_params(request) body_text, body_params = await _request_body_params(request, raw_body) body_sha256 = hashlib.sha256(raw_body).hexdigest() - is_protocol_sync_request = self._is_protocol_sync_path(clean_path) + is_protocol_sync_request = self._is_protocol_sync_path(clean_path) or self._is_mitm_activity_sync_path( + clean_path + ) if host: host_authority = host.strip() @@ -1143,6 +1203,43 @@ async def _handle_roborock_request(self, request: Request) -> Response: ) return JSONResponse(response_payload, status_code=status_code) + custom_mitm_activity = await self._handle_mitm_activity_sync_route( + method=request.method, + clean_path=clean_path, + headers=dict(request.headers), + body_params=body_params, + ) + if custom_mitm_activity is not None: + route_name, status_code, response_payload = custom_mitm_activity + entry["route"] = route_name + entry["response_json"] = response_payload + try: + self.runtime_state.record_http_event( + event_time=str(entry["time"]), + route_name=route_name, + clean_path=clean_path, + raw_path=raw_path, + method=request.method, + host=host, + remote=str(entry["remote"]), + did=explicit_did or None, + pid=explicit_pid or None, + model=explicit_model or None, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("runtime_state record_http_event failed: %s", exc) + append_jsonl(self.context.http_jsonl, entry) + logger.info( + "%s %s host=%s route=%s status=%d body_sha256=%s", + request.method, + clean_path, + host or "-", + route_name, + status_code, + body_sha256[:16], + ) + return JSONResponse(response_payload, status_code=status_code) + custom_login = await self._handle_protocol_login_route( clean_path=clean_path, query_params=query_params, @@ -1377,7 +1474,10 @@ async def _handle_roborock_request(self, request: Request) -> Response: # /nc just handed the vacuum its localKey; persist now instead of relying on an # onboarding client still polling the session (it may have timed out or exited). self.persist_active_onboarding_device() - append_jsonl(self.context.http_jsonl, entry) + if route_name != "catchall": + # Truly unmatched requests (favicon probes, health checks, random scans) aren't + # meaningful onboarding/protocol activity - keep them out of the Activity feed. + append_jsonl(self.context.http_jsonl, entry) if key_cache is not None and key_capture_did: try: key_cache.maybe_recover_async(key_capture_did) @@ -1490,6 +1590,44 @@ def _pending_device_mqtt_recovery_payload(self) -> list[dict[str, str]]: ) return devices + def _vacuum_identifiers(self, device_ref: str) -> set[str] | None: + """Every id traffic for this vacuum can carry (duid, did, MQTT user), or None if unknown.""" + reference = device_ref.strip() + if not reference: + return None + for vacuum in self._vacuums_payload()["vacuums"]: + candidates = { + str(vacuum.get(key) or "").strip() + for key in ("duid", "did", "runtime_did", "linked_inventory_duid") + } + candidates.discard("") + if reference not in candidates: + continue + record = self.runtime_credentials.resolve_device( + did=str(vacuum.get("did") or ""), + duid=str(vacuum.get("duid") or ""), + ) + if record: + candidates.update( + str(record.get(key) or "").strip() for key in ("did", "duid", "device_mqtt_usr") + ) + candidates.discard("") + return candidates + return None + + def _activity_payload(self, *, limit: int, device: str = "") -> dict[str, Any]: + raw = activity_raw_enabled(os.environ) + device_ids = self._vacuum_identifiers(device) if device.strip() else None + entries = read_recent_activity( + http_jsonl_path=self.paths.http_jsonl_path, + mqtt_jsonl_path=self.paths.mqtt_jsonl_path, + mitm_jsonl_path=self.paths.mitm_activity_jsonl_path, + limit=limit, + raw=raw, + device_ids=device_ids if device.strip() else None, + ) + return {"raw": raw, "entries": entries, "device": device.strip(), "device_known": device_ids is not None or not device.strip()} + def _auth_payload(self) -> dict[str, Any]: sessions = [ session @@ -2036,8 +2174,59 @@ def request_shutdown() -> None: return 0 +async def _run_setup_wizard_only(*, config_file: Path) -> int: + """Serve only /admin's setup wizard, in plain HTTP, until config.toml exists. + + Listens on the same https_port the real stack will use once configured + (ROBOROCK_SERVER_HTTPS_PORT, same env var as the compose port mapping). + Exits (0) once the wizard writes a config, or on SIGINT/SIGTERM. Either + way, Docker's restart policy is what brings the full stack - and its own + TLS'd /admin - up on the next boot, once load_config() succeeds. + """ + logger = logging.getLogger(__name__) + port = bootstrap_port_from_env(os.environ) + stop_event = asyncio.Event() + configured_event = asyncio.Event() + + app = create_management_app(config_file=config_file, on_configured=configured_event.set) + bootstrap_server = ManagedFastApiServer( + app=app, + bind_host="0.0.0.0", + port=port, + tls_enabled=False, + ) + await bootstrap_server.start() + logger.warning( + "%s not found. Open http://:%d/admin to finish setup.", + config_file, + port, + ) + + def request_shutdown() -> None: + stop_event.set() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, request_shutdown) + except NotImplementedError: + pass + + await asyncio.wait( + {asyncio.create_task(stop_event.wait()), asyncio.create_task(configured_event.wait())}, + return_when=asyncio.FIRST_COMPLETED, + ) + await bootstrap_server.stop() + return 0 + + async def run_server(*, config_file: Path, enable_standalone_admin: bool = True) -> int: - config = load_config(config_file) + try: + config = load_config(config_file) + except (OSError, ValueError) as exc: + logging.getLogger(__name__).warning("Could not load %s: %s", config_file, exc) + return await _run_setup_wizard_only(config_file=config_file) + paths = resolve_paths(config_file, config) supervisor = ReleaseSupervisor( config=config, diff --git a/src/roborock_local_server/standalone_admin.py b/src/roborock_local_server/standalone_admin.py index e1c24dd..d223fe1 100644 --- a/src/roborock_local_server/standalone_admin.py +++ b/src/roborock_local_server/standalone_admin.py @@ -10,33 +10,63 @@ from fastapi.responses import HTMLResponse, JSONResponse from .security import verify_password +from .web_theme import HEAD_ASSETS, NAV_HTML, SCRIPT_ASSETS, register_theme_routes def _admin_login_html() -> str: - return dedent( - """\ - -

Roborock Local Server

-

Sign in to manage the stack.

- - -

-        
-        """
+    return (
+        dedent(
+            """\
+            
+            Roborock Local Server
+            """
+        )
+        + HEAD_ASSETS
+        + dedent(
+            """\
+            
+            """
+        )
+        + NAV_HTML
+        + dedent(
+            """\
+            
+

Sign in to manage the stack.

+
+
+ + +
+
+ +
+
+ """ + ) + + SCRIPT_ASSETS + + dedent( + """\ + + """ + ) ) @@ -44,39 +74,107 @@ def _admin_dashboard_html(project_support: dict[str, Any]) -> str: support_payload = json.dumps(project_support) return dedent( f"""\ - -
-

Roborock Local Server

-
Loading
+ + Roborock Local Server + {HEAD_ASSETS} + + + +
+
Loading vacuums...
+
+ + + + -

Health

-

Vacuums

+
+

Health

+

+        
+
+ {SCRIPT_ASSETS} ' + +NAV_HTML = dedent( + """\ + + """ +) + +STYLE_CSS = dedent( + """\ + :root { + --rls-bg: #f3f5f8; + --rls-surface: #ffffff; + --rls-nav: #12263a; + --rls-accent: #2f6fed; + --rls-accent-dark: #1c4fc4; + --rls-text: #1a1f29; + --rls-text-muted: #5a6572; + --rls-border: #e2e6ea; + --rls-danger-bg: #fdeced; + --rls-danger-text: #8a1620; + --rls-danger-border: #f5c2c7; + --rls-success-bg: #e8f8ee; + --rls-success-text: #0f5a33; + --rls-success-border: #b7e4c7; + --rls-warn-bg: #fff7ed; + --rls-warn-text: #7c2d12; + --rls-warn-border: #fdd8ab; + color-scheme: light; + } + + @media (prefers-color-scheme: dark) { + :root { + --rls-bg: #12161d; + --rls-surface: #1a1f28; + --rls-nav: #0c1520; + --rls-accent: #6d9bff; + --rls-accent-dark: #8fb1ff; + --rls-text: #e6e9ef; + --rls-text-muted: #93a0b3; + --rls-border: #2a303c; + --rls-danger-bg: #3a1a1e; + --rls-danger-text: #ff9aa4; + --rls-danger-border: #5c2830; + --rls-success-bg: #16301f; + --rls-success-text: #7fe0a4; + --rls-success-border: #285c3a; + --rls-warn-bg: #3a2a1a; + --rls-warn-text: #ffb066; + --rls-warn-border: #5c4222; + color-scheme: dark; + } + + .modal, .modal .modal-footer, .dropdown-content { background-color: var(--rls-surface); } + .dropdown-content li:hover, .dropdown-content li.active { background-color: var(--rls-border); } + .input-field input[type="text"]:not(.valid), + .input-field input[type="password"]:not(.valid), + .input-field input[type="email"]:not(.valid), + .input-field input[type="number"]:not(.valid) { + border-bottom-color: var(--rls-border); + color: var(--rls-text); + } + .card, .card-panel { background-color: var(--rls-surface); color: var(--rls-text); } + label { color: var(--rls-text-muted); } + } + + body { + background: var(--rls-bg); + color: var(--rls-text); + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + + /* Materialize's nav is a fixed 56px and never grows for a real title - see rethink's own + style.css for the same fix (verified against Materialize 1.0.0's actual CSS). */ + nav { + background: var(--rls-nav) !important; + box-shadow: 0 2px 8px rgba(18, 38, 58, .25); + height: auto; + min-height: 56px; + } + .nav-wrapper { display: flex; align-items: center; height: auto; min-height: 56px; } + nav .brand-logo { + position: static; left: auto; transform: none; + display: flex; align-items: center; flex-wrap: wrap; gap: .4em; + font-weight: 600; font-size: 1.25rem; letter-spacing: .01em; padding: .6em 0; + } + nav .brand-logo .material-icons { color: var(--rls-accent); } + + .container:not(.nav-wrapper) { + background: var(--rls-surface); + border: 1px solid var(--rls-border); + border-radius: 10px; + padding: 1.25em 1.5em; + margin-top: 1.25em; + margin-bottom: 1.25em; + box-shadow: 0 1px 3px rgba(26, 31, 41, .06); + } + + .header.rls-heading, h4.header { + color: var(--rls-accent-dark) !important; + font-weight: 600; + font-size: 1.2rem; + letter-spacing: .01em; + } + + .btn, .btn-large, .btn-small, .btn-flat { + display: inline-flex; + align-items: center; + gap: .4em; + } + /* Materialize's .right float on an icon inside a button breaks once the + button becomes a flex item (as it is in the nav bar) - floats inside + flex containers are unreliable across browsers. Use flex ordering + instead, which keeps the same "icon at the end" look everywhere. */ + .btn .material-icons.right, .btn-large .material-icons.right, + .btn-small .material-icons.right, .btn-flat .material-icons.right { + float: none; + margin-left: auto; + } + .btn, .btn-large, .btn-small, .btn-flat { + text-transform: none; + font-weight: 500; + } + .btn, .btn-large, .btn-small { + background-color: var(--rls-accent); + border-radius: 7px; + box-shadow: none; + } + .btn:hover, .btn-large:hover, .btn-small:hover { + background-color: var(--rls-accent-dark); + box-shadow: 0 2px 6px rgba(28, 79, 196, .35); + } + .btn:focus-visible, .btn-large:focus-visible, .btn-small:focus-visible, .btn-flat:focus-visible { + outline: 2px solid var(--rls-accent); + outline-offset: 2px; + } + .btn-flat { color: var(--rls-accent-dark); } + + .input-field input:focus + label, + .input-field input.valid:focus { color: var(--rls-accent-dark) !important; } + .input-field input:focus { + border-bottom: 1px solid var(--rls-accent-dark) !important; + box-shadow: 0 1px 0 0 var(--rls-accent-dark) !important; + } + .switch label input[type="checkbox"]:checked + .lever { background-color: rgba(47, 111, 237, .5); } + .switch label input[type="checkbox"]:checked + .lever:after { background-color: var(--rls-accent-dark); } + + /* Plain (non-switch) checkboxes: Materialize draws both the unchecked box + AND the checked checkmark tick using the SAME :before pseudo-element - + :checked repositions/rotates it into an L-shape (transparent top/left, + colored right/bottom). Retint each state's own colors instead of a + blanket border-color, or the checked state loses its transparent sides + and renders as a solid diamond instead of a checkmark. */ + [type="checkbox"]:not(.filled-in) + span:not(.lever) { color: var(--rls-text); } + [type="checkbox"]:not(.filled-in):not(:checked) + span:not(.lever):before { + border-color: var(--rls-text-muted) !important; + } + [type="checkbox"]:checked:not(.filled-in) + span:not(.lever):before { + border-top-color: transparent !important; + border-left-color: transparent !important; + border-right-color: var(--rls-accent) !important; + border-bottom-color: var(--rls-accent) !important; + } + + table.highlight { border-collapse: collapse; } + table.highlight thead th { + color: var(--rls-text-muted); font-size: .78rem; text-transform: uppercase; + letter-spacing: .04em; border-bottom: 2px solid var(--rls-border); + } + table.highlight tbody tr { border-bottom: 1px solid var(--rls-border); } + table.highlight tbody tr:hover { background: rgba(47, 111, 237, .06); } + + .alert-banner { + border-radius: 8px; + padding: .7em .9em; + margin-top: .9em; + font-size: .92em; + line-height: 1.5; + white-space: pre-wrap; + display: none; + } + .alert-banner.shown { display: block; } + .alert-banner.error { color: var(--rls-danger-text); background: var(--rls-danger-bg); border: 1px solid var(--rls-danger-border); } + .alert-banner.success { color: var(--rls-success-text); background: var(--rls-success-bg); border: 1px solid var(--rls-success-border); } + + .rls-muted { color: var(--rls-text-muted); } + .rls-card-empty { color: var(--rls-text-muted); } + .rls-badge { + display: inline-block; text-transform: uppercase; font-size: .68rem; font-weight: 600; + letter-spacing: .03em; border-radius: 4px; padding: .15em .5em; + } + .rls-badge.mqtt { background: #e4ecff; color: #2a4d8f; } + .rls-badge.http { background: #eef0f3; color: #3d4a5c; } + .rls-badge.mitm { background: #fdece0; color: #a2540f; } + @media (prefers-color-scheme: dark) { + .rls-badge.mqtt { background: #1c2a4a; color: #9db8f5; } + .rls-badge.http { background: #262c36; color: #b7c0cc; } + .rls-badge.mitm { background: #3a2a1a; color: #ffb066; } + } + + .rls-activity-list { max-height: 26em; overflow-y: auto; } + .rls-activity-entry { + display: flex; align-items: baseline; gap: .6em; padding: .35em 0; + border-bottom: 1px solid var(--rls-border); font-size: .85em; flex-wrap: wrap; + } + .rls-activity-entry:last-child { border-bottom: none; } + .rls-activity-time { color: var(--rls-text-muted); flex-shrink: 0; font-family: monospace; font-size: .92em; } + .rls-activity-detail { word-break: break-word; } + .rls-activity-raw { width: 100%; margin: .3em 0 0; font-family: monospace; font-size: .85em; white-space: pre-wrap; } + + .rls-pre { + background: var(--rls-bg); border: 1px solid var(--rls-border); border-radius: 8px; + padding: .8em 1em; font-family: monospace; font-size: .85em; white-space: pre-wrap; + word-break: break-word; max-height: 22em; overflow: auto; color: var(--rls-text); + } + + /* Device selector: one chip per vacuum, plus Server for the global settings. */ + .rls-chips { display: flex; flex-wrap: wrap; align-items: center; gap: .5em; } + .rls-chip { + display: inline-flex; align-items: center; gap: .55em; cursor: pointer; font: inherit; + padding: .45em 1em; border-radius: 999px; + border: 1px solid var(--rls-border); background: var(--rls-surface); color: var(--rls-text); + } + .rls-chip:hover { border-color: var(--rls-accent); } + .rls-chip:focus-visible { outline: 2px solid var(--rls-accent); outline-offset: 2px; } + .rls-chip.active { background: var(--rls-accent); border-color: var(--rls-accent); color: #fff; } + .rls-dot { width: .6em; height: .6em; border-radius: 50%; background: var(--rls-text-muted); } + .rls-dot.ok { background: #2e9d5b; } + .rls-dot.wait { background: #d9822b; } + .rls-chip.active .rls-dot { box-shadow: 0 0 0 2px rgba(255, 255, 255, .55); } + + .rls-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .8em 1.5em; } + .rls-fact-label { + color: var(--rls-text-muted); font-size: .74rem; text-transform: uppercase; letter-spacing: .04em; + } + .rls-fact-value { word-break: break-all; } + .rls-details > summary { cursor: pointer; color: var(--rls-text-muted); } + + .hidden { display: none !important; } + + @media (max-width: 480px) { + .container:not(.nav-wrapper) { padding: 1em; margin-top: .75em; margin-bottom: .75em; } + } + """ +) + + +def register_theme_routes(app: FastAPI) -> None: + @app.get(STYLE_CSS_PATH) + async def admin_style_css() -> PlainTextResponse: + return PlainTextResponse(STYLE_CSS, media_type="text/css") diff --git a/tests/test_activity_log.py b/tests/test_activity_log.py new file mode 100644 index 0000000..71ad037 --- /dev/null +++ b/tests/test_activity_log.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from roborock_local_server.activity_log import ( + _tail_lines, + activity_raw_enabled, + read_recent_activity, +) + + +def _write_jsonl(path: Path, entries: list[dict]) -> None: + path.write_text("\n".join(json.dumps(entry) for entry in entries) + "\n", encoding="utf-8") + + +def test_activity_raw_enabled_default_false() -> None: + assert activity_raw_enabled({}) is False + + +def test_activity_raw_enabled_true_values() -> None: + for value in ("1", "true", "TRUE", "yes", "on"): + assert activity_raw_enabled({"ROBOROCK_SERVER_ACTIVITY_RAW": value}) is True + + +def test_activity_raw_enabled_false_values() -> None: + for value in ("0", "false", "no", "off", ""): + assert activity_raw_enabled({"ROBOROCK_SERVER_ACTIVITY_RAW": value}) is False + + +def test_tail_lines_missing_file_returns_empty(tmp_path: Path) -> None: + assert _tail_lines(tmp_path / "missing.jsonl", 10) == [] + + +def test_tail_lines_returns_last_n_in_order(tmp_path: Path) -> None: + path = tmp_path / "log.jsonl" + path.write_text("\n".join(f"line-{i}" for i in range(1, 21)) + "\n", encoding="utf-8") + + assert _tail_lines(path, 3) == ["line-18", "line-19", "line-20"] + + +def test_tail_lines_handles_chunk_boundary(tmp_path: Path) -> None: + # Force multiple internal read chunks by writing a file bigger than _CHUNK_SIZE. + path = tmp_path / "big.jsonl" + lines = [f"line-{i}-{'x' * 200}" for i in range(1, 2000)] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + tailed = _tail_lines(path, 5) + + assert tailed == lines[-5:] + + +def test_tail_lines_zero_or_negative_limit(tmp_path: Path) -> None: + path = tmp_path / "log.jsonl" + path.write_text("line-1\n", encoding="utf-8") + + assert _tail_lines(path, 0) == [] + + +def test_read_recent_activity_redacts_by_default(tmp_path: Path) -> None: + http_path = tmp_path / "http.jsonl" + mqtt_path = tmp_path / "mqtt.jsonl" + _write_jsonl( + http_path, + [ + { + "time": "2026-01-01T00:00:00Z", + "method": "POST", + "clean_path": "/api/v1/user/login", + "headers": {"authorization": "Bearer super-secret"}, + "body_json": {"localkey": "abc123", "password": "hunter2"}, + "body_len": 42, + "route": "login", + "remote": "10.0.0.5:443", + } + ], + ) + _write_jsonl( + mqtt_path, + [ + { + "time": "2026-01-01T00:00:01Z", + "direction": "in", + "topic": "rr/abc/duid1", + "payload_hex": "aabbcc", + "decoded_messages": [{"rpc": {"method": "get_status", "params": ["secret-token"]}}], + } + ], + ) + + entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path) + + assert len(entries) == 2 + serialized = json.dumps(entries) + assert "super-secret" not in serialized + assert "hunter2" not in serialized + assert "abc123" not in serialized + assert "secret-token" not in serialized + + http_entry = next(e for e in entries if e["source"] == "http") + assert http_entry["method"] == "POST" + assert http_entry["path"] == "/api/v1/user/login" + assert "headers" not in http_entry + assert "body_json" not in http_entry + + mqtt_entry = next(e for e in entries if e["source"] == "mqtt") + assert mqtt_entry["topic"] == "rr/abc/duid1" + assert mqtt_entry["rpc_methods"] == ["get_status"] + assert "payload_hex" not in mqtt_entry + assert "decoded_messages" not in mqtt_entry + + +def test_read_recent_activity_raw_mode_returns_full_entries(tmp_path: Path) -> None: + http_path = tmp_path / "http.jsonl" + mqtt_path = tmp_path / "mqtt.jsonl" + _write_jsonl( + http_path, + [ + { + "time": "2026-01-01T00:00:00Z", + "method": "POST", + "headers": {"authorization": "Bearer super-secret"}, + } + ], + ) + _write_jsonl(mqtt_path, []) + + entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path, raw=True) + + assert len(entries) == 1 + assert entries[0]["source"] == "http" + assert entries[0]["headers"] == {"authorization": "Bearer super-secret"} + + +def test_read_recent_activity_sorts_newest_first_across_sources(tmp_path: Path) -> None: + http_path = tmp_path / "http.jsonl" + mqtt_path = tmp_path / "mqtt.jsonl" + _write_jsonl(http_path, [{"time": "2026-01-01T00:00:00Z", "method": "GET", "clean_path": "/a"}]) + _write_jsonl(mqtt_path, [{"time": "2026-01-01T00:00:05Z", "topic": "rr/x"}]) + + entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path) + + assert [entry["source"] for entry in entries] == ["mqtt", "http"] + + +def test_read_recent_activity_missing_files_returns_empty(tmp_path: Path) -> None: + entries = read_recent_activity( + http_jsonl_path=tmp_path / "missing_http.jsonl", + mqtt_jsonl_path=tmp_path / "missing_mqtt.jsonl", + ) + assert entries == [] + + +def test_read_recent_activity_skips_malformed_lines(tmp_path: Path) -> None: + http_path = tmp_path / "http.jsonl" + mqtt_path = tmp_path / "mqtt.jsonl" + http_path.write_text( + "not json at all\n" + json.dumps({"time": "2026-01-01T00:00:00Z", "method": "GET"}) + "\n", + encoding="utf-8", + ) + _write_jsonl(mqtt_path, []) + + entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path) + + assert len(entries) == 1 + assert entries[0]["method"] == "GET" + + +def test_read_recent_activity_respects_limit(tmp_path: Path) -> None: + http_path = tmp_path / "http.jsonl" + mqtt_path = tmp_path / "mqtt.jsonl" + _write_jsonl( + http_path, + [{"time": f"2026-01-01T00:00:{i:02d}Z", "method": "GET", "clean_path": "/a"} for i in range(10)], + ) + _write_jsonl(mqtt_path, []) + + entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path, limit=3) + + assert len(entries) == 3 + assert entries[0]["time"] == "2026-01-01T00:00:09Z" + + +def test_read_recent_activity_without_mitm_path_is_unaffected(tmp_path: Path) -> None: + http_path = tmp_path / "http.jsonl" + mqtt_path = tmp_path / "mqtt.jsonl" + _write_jsonl(http_path, [{"time": "2026-01-01T00:00:00Z", "method": "GET"}]) + _write_jsonl(mqtt_path, []) + + entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path) + + assert len(entries) == 1 + + +def test_read_recent_activity_redacts_mitm_entries_by_default(tmp_path: Path) -> None: + http_path = tmp_path / "http.jsonl" + mqtt_path = tmp_path / "mqtt.jsonl" + mitm_path = tmp_path / "mitm.jsonl" + _write_jsonl(http_path, []) + _write_jsonl(mqtt_path, []) + _write_jsonl( + mitm_path, + [ + { + "time": "2026-01-01T00:00:00Z", + "host": "usiot.roborock.com", + "method": "GET", + "path": "/api/v1/userinfo", + "status": 200, + "rewritten": True, + "request_headers": {"authorization": "real-cloud-token-999"}, + "response_body": "real account data here", + } + ], + ) + + entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path, mitm_jsonl_path=mitm_path) + + assert len(entries) == 1 + entry = entries[0] + assert entry["source"] == "mitm" + assert entry["host"] == "usiot.roborock.com" + assert entry["method"] == "GET" + assert entry["path"] == "/api/v1/userinfo" + assert entry["status"] == 200 + assert entry["rewritten"] is True + assert "request_headers" not in entry + assert "response_body" not in entry + assert "real-cloud-token-999" not in json.dumps(entry) + + +def test_read_recent_activity_mitm_raw_mode_returns_full_entry(tmp_path: Path) -> None: + http_path = tmp_path / "http.jsonl" + mqtt_path = tmp_path / "mqtt.jsonl" + mitm_path = tmp_path / "mitm.jsonl" + _write_jsonl(http_path, []) + _write_jsonl(mqtt_path, []) + _write_jsonl( + mitm_path, + [{"time": "2026-01-01T00:00:00Z", "host": "usiot.roborock.com", "request_headers": {"x": "y"}}], + ) + + entries = read_recent_activity( + http_jsonl_path=http_path, + mqtt_jsonl_path=mqtt_path, + mitm_jsonl_path=mitm_path, + raw=True, + ) + + assert entries[0]["source"] == "mitm" + assert entries[0]["request_headers"] == {"x": "y"} + + +def test_read_recent_activity_sorts_across_all_three_sources(tmp_path: Path) -> None: + http_path = tmp_path / "http.jsonl" + mqtt_path = tmp_path / "mqtt.jsonl" + mitm_path = tmp_path / "mitm.jsonl" + _write_jsonl(http_path, [{"time": "2026-01-01T00:00:00Z", "method": "GET"}]) + _write_jsonl(mqtt_path, [{"time": "2026-01-01T00:00:02Z", "topic": "rr/x"}]) + _write_jsonl(mitm_path, [{"time": "2026-01-01T00:00:01Z", "host": "usiot.roborock.com"}]) + + entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path, mitm_jsonl_path=mitm_path) + + assert [entry["source"] for entry in entries] == ["mqtt", "mitm", "http"] diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 752c5c1..70155c8 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -1572,3 +1572,125 @@ def test_execute_scene_hydrates_missing_zone_ranges_from_mqtt(tmp_path: Path) -> persisted_outer = json.loads(scene["param"]) persisted_step = json.loads(persisted_outer["action"]["items"][0]["param"]) assert persisted_step["params"]["data"][0]["zones"][0]["range"] == [32800, 22750, 34550, 25350] + + +def _logged_in_client(supervisor: ReleaseSupervisor) -> TestClient: + client = TestClient(supervisor.app) + response = client.post("/admin/api/login", json={"password": "correct horse battery staple"}) + assert response.status_code == 200 + return client + + +def test_admin_activity_requires_auth(tmp_path: Path) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = TestClient(supervisor.app) + + response = client.get("/admin/api/activity") + + assert response.status_code == 401 + + +def test_admin_activity_returns_redacted_entries_by_default(tmp_path: Path) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + paths.http_jsonl_path.parent.mkdir(parents=True, exist_ok=True) + paths.http_jsonl_path.write_text( + json.dumps( + { + "time": "2026-01-01T00:00:00Z", + "method": "GET", + "clean_path": "/api/v1/user/login", + "headers": {"authorization": "Bearer super-secret"}, + } + ) + + "\n", + encoding="utf-8", + ) + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = _logged_in_client(supervisor) + + response = client.get("/admin/api/activity") + + assert response.status_code == 200 + payload = response.json() + assert payload["raw"] is False + assert len(payload["entries"]) == 1 + assert payload["entries"][0]["method"] == "GET" + assert "headers" not in payload["entries"][0] + assert "super-secret" not in json.dumps(payload) + + +def test_admin_activity_raw_mode_via_env_var(tmp_path: Path, monkeypatch) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + paths.http_jsonl_path.parent.mkdir(parents=True, exist_ok=True) + paths.http_jsonl_path.write_text( + json.dumps({"time": "2026-01-01T00:00:00Z", "method": "GET", "headers": {"x": "y"}}) + "\n", + encoding="utf-8", + ) + monkeypatch.setenv("ROBOROCK_SERVER_ACTIVITY_RAW", "1") + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = _logged_in_client(supervisor) + + response = client.get("/admin/api/activity") + + assert response.status_code == 200 + payload = response.json() + assert payload["raw"] is True + assert payload["entries"][0]["headers"] == {"x": "y"} + + +def test_admin_activity_rejects_non_integer_limit(tmp_path: Path) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = _logged_in_client(supervisor) + + response = client.get("/admin/api/activity", params={"limit": "not-a-number"}) + + assert response.status_code == 400 + + +def test_admin_activity_clamps_limit(tmp_path: Path) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + paths.http_jsonl_path.parent.mkdir(parents=True, exist_ok=True) + paths.http_jsonl_path.write_text( + "\n".join( + json.dumps({"time": f"2026-01-01T00:00:{i:02d}Z", "method": "GET"}) for i in range(10) + ) + + "\n", + encoding="utf-8", + ) + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = _logged_in_client(supervisor) + + response = client.get("/admin/api/activity", params={"limit": "2"}) + + assert response.status_code == 200 + assert len(response.json()["entries"]) == 2 + + +def test_catchall_requests_are_not_logged_as_activity_but_named_routes_are(tmp_path: Path) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = TestClient(supervisor.app) + + client.get("/favicon.ico") + client.get("/region") + + log_entries = [ + json.loads(line) for line in paths.http_jsonl_path.read_text(encoding="utf-8").splitlines() if line.strip() + ] + routes = [entry.get("route") for entry in log_entries] + assert "catchall" not in routes + assert "region" in routes diff --git a/tests/test_config.py b/tests/test_config.py index 7db8ee3..83ad9e2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,7 @@ from pathlib import Path import pytest -from roborock_local_server.config import load_config, resolve_paths +from roborock_local_server.config import diagnose_config, load_config, resolve_paths from conftest import write_release_config @@ -533,3 +533,54 @@ def test_load_config_rejects_invalid_trusted_proxy(tmp_path: Path) -> None: with pytest.raises(ValueError, match="network.trusted_proxies entry 'traefik'"): load_config(config_file) + + +_VALID_NETWORK_BROKER_STORAGE_TLS = """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "provided" +cert_file = "certs/fullchain.pem" +key_file = "certs/privkey.pem" +""" + +_VALID_ADMIN = """ +[admin] +password_hash = "pbkdf2_sha256$600000$abc$def" +session_secret = "abcdefghijklmnopqrstuvwxyz123456" +protocol_login_email = "user@example.com" +protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl" +""" + + +def test_diagnose_config_ok(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text((_VALID_NETWORK_BROKER_STORAGE_TLS + _VALID_ADMIN).strip(), encoding="utf-8") + + assert diagnose_config(config_file) == "ok" + + +def test_diagnose_config_missing_admin_when_only_admin_section_absent(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text(_VALID_NETWORK_BROKER_STORAGE_TLS.strip(), encoding="utf-8") + + assert diagnose_config(config_file) == "missing_admin" + + +def test_diagnose_config_invalid_when_file_does_not_exist(tmp_path: Path) -> None: + assert diagnose_config(tmp_path / "does-not-exist.toml") == "invalid" + + +def test_diagnose_config_invalid_when_network_is_broken_even_if_admin_is_fine(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + broken_network = _VALID_NETWORK_BROKER_STORAGE_TLS.replace('mode = "embedded"', 'mode = "bogus"') + config_file.write_text((broken_network + _VALID_ADMIN).strip(), encoding="utf-8") + + assert diagnose_config(config_file) == "invalid" diff --git a/tests/test_configure.py b/tests/test_configure.py index 31a9d3d..85bc1b0 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -12,6 +12,7 @@ def _answers( https_port: int = 555, mqtt_tls_port: int = 8881, broker_mode: str = "embedded", + broker_host: str = "", tls_mode: str = "cloudflare_acme", acme_server: str = "zerossl", acme_eab_kid: str = "", @@ -22,6 +23,7 @@ def _answers( https_port=https_port, mqtt_tls_port=mqtt_tls_port, broker_mode=broker_mode, + broker_host=broker_host, tls_mode=tls_mode, base_domain="example.com" if tls_mode == "cloudflare_acme" else "", email="you@example.com" if tls_mode == "cloudflare_acme" else "", @@ -85,6 +87,23 @@ def test_write_config_setup_external_broker_requires_host_before_serve(tmp_path: load_config(config_file) +def test_write_config_setup_external_broker_with_host_needs_no_manual_edit(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + + result = write_config_setup( + config_file=config_file, + answers=_answers(broker_mode="external", broker_host="mqtt.internal", tls_mode="provided"), + ) + + assert not result.broker_template_needs_edit + rendered = config_file.read_text(encoding="utf-8") + assert 'host = "mqtt.internal"' in rendered + assert "# Fill this in" not in rendered + + config = load_config(config_file) + assert config.broker.host == "mqtt.internal" + + def test_write_config_setup_refuses_overwrite_without_force(tmp_path: Path) -> None: config_file = tmp_path / "config.toml" write_config_setup(config_file=config_file, answers=_answers()) diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py index c224947..4dc2884 100644 --- a/tests/test_container_entrypoint.py +++ b/tests/test_container_entrypoint.py @@ -65,3 +65,125 @@ def test_run_entrypoint_prefers_compose_config_when_present( ) assert calls == [compose_config] + + +def test_run_entrypoint_prefers_existing_data_config_over_env_vars( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + compose_config = tmp_path / "app-config.toml" + data_config = tmp_path / "data-config.toml" + addon_options = tmp_path / "options.json" + data_config.write_text("existing = true\n", encoding="utf-8") + + monkeypatch.setenv("ROBOROCK_SERVER_STACK_FQDN", "api-roborock.example.com") + calls: list[Path] = [] + monkeypatch.setattr( + container_entrypoint, + "_exec_server", + lambda config_path: calls.append(config_path), + ) + monkeypatch.setattr( + container_entrypoint, + "write_config_from_env", + lambda *args, **kwargs: pytest.fail("should not regenerate an existing config.toml"), + ) + + container_entrypoint._run_entrypoint( + compose_config=compose_config, + data_config=data_config, + addon_options=addon_options, + ) + + assert calls == [data_config] + + +def test_run_entrypoint_generates_config_from_env_vars( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + compose_config = tmp_path / "app-config.toml" + data_config = tmp_path / "data-config.toml" + addon_options = tmp_path / "options.json" + + monkeypatch.setenv("ROBOROCK_SERVER_STACK_FQDN", "api-roborock.example.com") + calls: list[tuple[str, Path]] = [] + monkeypatch.setattr( + container_entrypoint, + "write_config_from_env", + lambda env, *, config_path: calls.append(("write", config_path)), + ) + monkeypatch.setattr( + container_entrypoint, + "_exec_server", + lambda config_path: calls.append(("exec", config_path)), + ) + + container_entrypoint._run_entrypoint( + compose_config=compose_config, + data_config=data_config, + addon_options=addon_options, + ) + + assert calls == [("write", data_config), ("exec", data_config)] + + +def test_run_entrypoint_falls_back_to_wizard_when_env_config_is_incomplete( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # STACK_FQDN alone triggers has_env_config(), but the rest of the + # required vars for the default tls_mode may not be set. That must fall + # through into the Setup Wizard, not crash the container. + compose_config = tmp_path / "app-config.toml" + data_config = tmp_path / "data-config.toml" + addon_options = tmp_path / "options.json" + monkeypatch.setenv("ROBOROCK_SERVER_STACK_FQDN", "api-roborock.example.com") + + def fake_write_config_from_env(env, *, config_path): + raise ValueError("ROBOROCK_SERVER_TLS_BASE_DOMAIN is required") + + monkeypatch.setattr(container_entrypoint, "write_config_from_env", fake_write_config_from_env) + calls: list[Path] = [] + monkeypatch.setattr( + container_entrypoint, + "_exec_server", + lambda config_path: calls.append(config_path), + ) + + container_entrypoint._run_entrypoint( + compose_config=compose_config, + data_config=data_config, + addon_options=addon_options, + ) + + assert calls == [data_config] + assert data_config.exists() is False + + +def test_run_entrypoint_execs_server_even_without_any_config_present( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # No config source at all, and no ROBOROCK_SERVER_* env vars either: the + # entrypoint no longer errors out here, since `serve` now falls into the + # Setup Wizard for exactly this case. + compose_config = tmp_path / "app-config.toml" + data_config = tmp_path / "data-config.toml" + addon_options = tmp_path / "options.json" + monkeypatch.delenv("ROBOROCK_SERVER_STACK_FQDN", raising=False) + + calls: list[Path] = [] + monkeypatch.setattr( + container_entrypoint, + "_exec_server", + lambda config_path: calls.append(config_path), + ) + + container_entrypoint._run_entrypoint( + compose_config=compose_config, + data_config=data_config, + addon_options=addon_options, + ) + + assert calls == [data_config] diff --git a/tests/test_device_view.py b/tests/test_device_view.py new file mode 100644 index 0000000..9b3cf4f --- /dev/null +++ b/tests/test_device_view.py @@ -0,0 +1,177 @@ +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from conftest import write_release_config +from roborock_local_server.activity_log import read_recent_activity +from roborock_local_server.config import load_config, resolve_paths +from roborock_local_server.server import ReleaseSupervisor + +QREVO = "2e2txkqKqxVifcIdyOT1lY" +QREVO_DID = "1103829226788" +S7 = "1OVJHS7cL6XxkYkoOGr2Hw" + + +def _write_jsonl(path: Path, entries: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(entry) for entry in entries) + "\n", encoding="utf-8") + + +def test_device_filter_matches_whole_segments_across_http_and_mqtt(tmp_path: Path) -> None: + http = tmp_path / "http.jsonl" + mqtt = tmp_path / "mqtt.jsonl" + _write_jsonl( + http, + [ + {"time": "2026-01-01T00:00:01Z", "clean_path": f"/user/devices/{QREVO}/jobs", "method": "GET"}, + {"time": "2026-01-01T00:00:02Z", "clean_path": f"/user/devices/{S7}/jobs", "method": "GET"}, + {"time": "2026-01-01T00:00:03Z", "clean_path": "/region", "method": "GET"}, + {"time": "2026-01-01T00:00:04Z", "clean_path": "/nc/prepare", "did": QREVO_DID, "method": "POST"}, + # A longer id that merely contains the wanted one must not match. + {"time": "2026-01-01T00:00:05Z", "clean_path": f"/user/devices/{QREVO}extra/jobs", "method": "GET"}, + ], + ) + _write_jsonl( + mqtt, + [ + {"time": "2026-01-01T00:00:06Z", "topic": f"rr/d/i/{QREVO_DID}/c6420abe", "direction": "c2b"}, + {"time": "2026-01-01T00:00:07Z", "topic": "rr/d/i/999/other", "direction": "c2b"}, + {"time": "2026-01-01T00:00:08Z", "topic": f"rr/m/o/u/user/{QREVO}", "direction": "b2c"}, + ], + ) + + entries = read_recent_activity( + http_jsonl_path=http, + mqtt_jsonl_path=mqtt, + limit=50, + device_ids={QREVO, QREVO_DID, "c6420abe"}, + ) + + assert [entry["time"] for entry in entries] == [ + "2026-01-01T00:00:08Z", + "2026-01-01T00:00:06Z", + "2026-01-01T00:00:04Z", + "2026-01-01T00:00:01Z", + ] + + +def test_device_filter_looks_past_the_newest_lines_to_find_a_quiet_device(tmp_path: Path) -> None: + http = tmp_path / "http.jsonl" + quiet = {"time": "2026-01-01T00:00:00Z", "clean_path": f"/user/devices/{S7}/jobs", "method": "GET"} + # limit=3 reads a 30-line tail when filtering; 25 noisy lines + the quiet one fit inside it. + noisy = [ + {"time": f"2026-01-01T00:01:{i:02d}Z", "clean_path": f"/user/devices/{QREVO}/jobs", "method": "GET"} + for i in range(25) + ] + _write_jsonl(http, [quiet, *noisy]) + + entries = read_recent_activity( + http_jsonl_path=http, mqtt_jsonl_path=tmp_path / "none.jsonl", limit=3, device_ids={S7} + ) + + assert [entry["time"] for entry in entries] == ["2026-01-01T00:00:00Z"] + + +def test_empty_device_ids_match_nothing(tmp_path: Path) -> None: + http = tmp_path / "http.jsonl" + _write_jsonl(http, [{"time": "2026-01-01T00:00:00Z", "clean_path": "/region"}]) + + assert read_recent_activity( + http_jsonl_path=http, mqtt_jsonl_path=tmp_path / "none.jsonl", device_ids=set() + ) == [] + + +def _supervisor(tmp_path: Path) -> tuple[ReleaseSupervisor, object]: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + paths.inventory_path.parent.mkdir(parents=True, exist_ok=True) + paths.inventory_path.write_text( + json.dumps( + { + "home": {"id": 1, "name": "Home"}, + "devices": [ + {"duid": QREVO, "name": "Quevo", "did": QREVO_DID}, + {"duid": S7, "name": "Downstairs S7"}, + ], + } + ), + encoding="utf-8", + ) + _write_jsonl( + paths.http_jsonl_path, + [ + {"time": "2026-01-01T00:00:01Z", "clean_path": f"/user/devices/{QREVO}/jobs", "method": "GET"}, + {"time": "2026-01-01T00:00:02Z", "clean_path": f"/user/devices/{S7}/jobs", "method": "GET"}, + ], + ) + supervisor = ReleaseSupervisor(config=config, paths=paths) + supervisor.refresh_inventory_state() + return supervisor, paths + + +def _login(supervisor: ReleaseSupervisor) -> TestClient: + client = TestClient(supervisor.app) + response = client.post("/admin/api/login", json={"password": "correct horse battery staple"}) + assert response.status_code == 200 + return client + + +def test_activity_endpoint_returns_only_the_selected_vacuums_traffic(tmp_path: Path) -> None: + supervisor, _paths = _supervisor(tmp_path) + client = _login(supervisor) + + everything = client.get("/admin/api/activity").json()["entries"] + one = client.get("/admin/api/activity", params={"device": QREVO}).json() + + assert len(everything) == 2 + assert [entry["path"] for entry in one["entries"]] == [f"/user/devices/{QREVO}/jobs"] + assert one["device"] == QREVO + + +def test_activity_finds_device_traffic_that_only_names_its_did_and_mqtt_user(tmp_path: Path) -> None: + supervisor, paths = _supervisor(tmp_path) + # The vacuum's own MQTT topics carry its did and MQTT user, never the duid. + supervisor.runtime_credentials.ensure_device(did=QREVO_DID, duid=QREVO, device_mqtt_usr="c6420abe") + _write_jsonl( + paths.mqtt_jsonl_path, + [ + {"time": "2026-01-01T00:00:09Z", "topic": f"rr/d/i/{QREVO_DID}/c6420abe", "direction": "c2b"}, + {"time": "2026-01-01T00:00:10Z", "topic": "rr/d/i/555/someoneelse", "direction": "c2b"}, + ], + ) + client = _login(supervisor) + + topics = [ + entry.get("topic") + for entry in client.get("/admin/api/activity", params={"device": QREVO}).json()["entries"] + if entry["source"] == "mqtt" + ] + + assert topics == [f"rr/d/i/{QREVO_DID}/c6420abe"] + + +def test_activity_endpoint_404s_for_an_unknown_vacuum(tmp_path: Path) -> None: + supervisor, _paths = _supervisor(tmp_path) + client = _login(supervisor) + + response = client.get("/admin/api/activity", params={"device": "nope"}) + + assert response.status_code == 404 + + +def test_dashboard_has_a_device_selector_and_a_server_panel(tmp_path: Path) -> None: + supervisor, _paths = _supervisor(tmp_path) + client = _login(supervisor) + + page = client.get("/admin").text + + assert 'id="deviceTabs"' in page + assert 'id="devicePanel"' in page + assert 'id="serverPanel"' in page + # The global cards live under Server now, not stacked beneath every vacuum. + assert page.index('id="serverPanel"') < page.index("Cloud Import") < page.index("New Connections") + assert "Vacuums (raw)" not in page + # The local key is a credential and must never be rendered, even in raw details. + assert '""' in page diff --git a/tests/test_env_config.py b/tests/test_env_config.py new file mode 100644 index 0000000..fcab6b1 --- /dev/null +++ b/tests/test_env_config.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib + +import pytest + +from roborock_local_server.env_config import SecretPaths, has_env_config, write_config_from_env + +_BASE_ENV = { + "ROBOROCK_SERVER_STACK_FQDN": "api-roborock.example.com", + "ROBOROCK_SERVER_TLS_MODE": "provided", + "ROBOROCK_SERVER_CERT_FILE": "/ssl/fullchain.pem", + "ROBOROCK_SERVER_KEY_FILE": "/ssl/privkey.pem", + "ROBOROCK_SERVER_ADMIN_PASSWORD": "super-secret-password", + "ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL": "user@example.com", + "ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN": "123456", +} + + +def test_has_env_config_requires_stack_fqdn() -> None: + assert has_env_config({}) is False + assert has_env_config({"ROBOROCK_SERVER_STACK_FQDN": "api-roborock.example.com"}) is True + + +def test_write_config_from_env_provided_tls(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + + write_config_from_env(_BASE_ENV, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["stack_fqdn"] == "api-roborock.example.com" + assert parsed["network"]["listener_mode"] == "local_tls" + assert parsed["network"]["https_port"] == 555 + assert parsed["network"]["mqtt_tls_port"] == 8881 + assert parsed["network"]["advertised_https_port"] == 555 + assert parsed["network"]["advertised_mqtt_tls_port"] == 8881 + assert parsed["network"]["trusted_proxies"] == ["127.0.0.1", "::1"] + assert parsed["broker"]["mode"] == "embedded" + assert parsed["broker"]["host"] == "127.0.0.1" + assert parsed["broker"]["port"] == 18830 + assert parsed["tls"]["mode"] == "provided" + assert parsed["tls"]["cert_file"] == "/ssl/fullchain.pem" + assert parsed["tls"]["key_file"] == "/ssl/privkey.pem" + assert parsed["admin"]["protocol_auth_enabled"] is True + assert parsed["admin"]["new_connections_enabled"] is True + assert parsed["admin"]["protocol_login_email"] == "user@example.com" + assert len(str(parsed["admin"]["session_secret"])) >= 24 + assert str(parsed["admin"]["password_hash"]).startswith("pbkdf2_sha256$") + assert str(parsed["admin"]["protocol_login_pin_hash"]).startswith("pbkdf2_sha256$") + + +def test_write_config_from_env_reverse_proxy_ports(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_ADVERTISED_HTTPS_PORT"] = "443" + env["ROBOROCK_SERVER_ADVERTISED_MQTT_TLS_PORT"] = "8883" + + write_config_from_env(env, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["https_port"] == 555 + assert parsed["network"]["advertised_https_port"] == 443 + assert parsed["network"]["advertised_mqtt_tls_port"] == 8883 + + +def test_write_config_from_env_external_tls_requires_provided(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_LISTENER_MODE"] = "external_tls" + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + with pytest.raises(ValueError, match="external_tls"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_external_broker_requires_host(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_BROKER_MODE"] = "external" + + with pytest.raises(ValueError, match="BROKER_HOST"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_external_broker(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_BROKER_MODE"] = "external" + env["ROBOROCK_SERVER_BROKER_HOST"] = "mqtt.internal" + + write_config_from_env(env, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["broker"]["mode"] == "external" + assert parsed["broker"]["host"] == "mqtt.internal" + assert parsed["broker"]["port"] == 1883 + + +def test_write_config_from_env_cloudflare_writes_secret(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + token_path = tmp_path / "run" / "secrets" / "cloudflare_token" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + env["ROBOROCK_SERVER_TLS_BASE_DOMAIN"] = "example.com" + env["ROBOROCK_SERVER_TLS_EMAIL"] = "acme@example.com" + env["ROBOROCK_SERVER_CLOUDFLARE_TOKEN"] = "cloudflare-token-123" + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + write_config_from_env( + env, + config_path=config_path, + secret_paths=SecretPaths(cloudflare_token_file=token_path), + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["mode"] == "cloudflare_acme" + assert parsed["tls"]["base_domain"] == "example.com" + assert parsed["tls"]["cloudflare_token_file"] == str(token_path) + assert token_path.read_text(encoding="utf-8") == "cloudflare-token-123" + + +def test_write_config_from_env_cloudflare_token_file_override_skips_write(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + existing_token_path = tmp_path / "existing-token" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + env["ROBOROCK_SERVER_TLS_BASE_DOMAIN"] = "example.com" + env["ROBOROCK_SERVER_TLS_EMAIL"] = "acme@example.com" + env["ROBOROCK_SERVER_CLOUDFLARE_TOKEN_FILE"] = str(existing_token_path) + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + write_config_from_env(env, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["cloudflare_token_file"] == str(existing_token_path) + assert existing_token_path.exists() is False + + +def test_write_config_from_env_actalis_requires_eab(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + env["ROBOROCK_SERVER_TLS_BASE_DOMAIN"] = "example.com" + env["ROBOROCK_SERVER_TLS_EMAIL"] = "acme@example.com" + env["ROBOROCK_SERVER_ACME_SERVER"] = "actalis" + env["ROBOROCK_SERVER_CLOUDFLARE_TOKEN"] = "cloudflare-token-123" + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + with pytest.raises(ValueError, match="ACME_EAB_KID"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_actalis_writes_eab(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + token_path = tmp_path / "cloudflare_token" + kid_path = tmp_path / "acme_eab_kid" + hmac_path = tmp_path / "acme_eab_hmac_key" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + env["ROBOROCK_SERVER_TLS_BASE_DOMAIN"] = "example.com" + env["ROBOROCK_SERVER_TLS_EMAIL"] = "acme@example.com" + env["ROBOROCK_SERVER_ACME_SERVER"] = "actalis" + env["ROBOROCK_SERVER_ACME_EAB_KID"] = "kid-123" + env["ROBOROCK_SERVER_ACME_EAB_HMAC_KEY"] = "hmac-456" + env["ROBOROCK_SERVER_CLOUDFLARE_TOKEN"] = "cloudflare-token-123" + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + write_config_from_env( + env, + config_path=config_path, + secret_paths=SecretPaths( + cloudflare_token_file=token_path, + acme_eab_kid_file=kid_path, + acme_eab_hmac_key_file=hmac_path, + ), + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["acme_server"] == "actalis" + assert parsed["tls"]["acme_eab_kid_file"] == str(kid_path) + assert parsed["tls"]["acme_eab_hmac_key_file"] == str(hmac_path) + assert kid_path.read_text(encoding="utf-8") == "kid-123" + assert hmac_path.read_text(encoding="utf-8") == "hmac-456" + + +def test_write_config_from_env_rejects_partial_admin_fields(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + del env["ROBOROCK_SERVER_ADMIN_PASSWORD"] + + with pytest.raises(ValueError, match="Set all of .*ADMIN_PASSWORD.*or none of them"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_omits_admin_section_when_all_admin_fields_absent(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + del env["ROBOROCK_SERVER_ADMIN_PASSWORD"] + del env["ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL"] + del env["ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN"] + + write_config_from_env(env, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert "admin" not in parsed + # Network/broker/storage/tls are still fully written - the Setup Wizard + # only needs to fill in [admin] on top of this. + assert parsed["network"]["stack_fqdn"] == "api-roborock.example.com" + assert parsed["tls"]["mode"] == "provided" + + +def test_write_config_from_env_writes_admin_section_when_all_admin_fields_present(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + + write_config_from_env(_BASE_ENV, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["admin"]["protocol_login_email"] == "user@example.com" + + +def test_write_config_from_env_requires_api_prefix(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_STACK_FQDN"] = "roborock.example.com" + + with pytest.raises(ValueError, match="STACK_FQDN must start with api-"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_rejects_bad_pin(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN"] = "12" + + with pytest.raises(ValueError, match="PROTOCOL_LOGIN_PIN must be exactly 6 digits"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_reuses_existing_session_secret(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + + write_config_from_env(_BASE_ENV, config_path=config_path) + first_secret = tomllib.loads(config_path.read_text(encoding="utf-8"))["admin"]["session_secret"] + + write_config_from_env(_BASE_ENV, config_path=config_path) + second_secret = tomllib.loads(config_path.read_text(encoding="utf-8"))["admin"]["session_secret"] + + assert len(str(first_secret)) >= 24 + assert second_secret == first_secret + + +def test_write_config_from_env_trusted_proxies(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TRUSTED_PROXIES"] = "10.0.0.0/8, 192.168.1.5" + + write_config_from_env(env, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["trusted_proxies"] == ["10.0.0.0/8", "192.168.1.5"] + + +def test_write_config_from_env_rejects_invalid_trusted_proxy(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TRUSTED_PROXIES"] = "not-an-ip" + + with pytest.raises(ValueError, match="not a valid IP address"): + write_config_from_env(env, config_path=config_path) diff --git a/tests/test_management_app.py b/tests/test_management_app.py new file mode 100644 index 0000000..7326024 --- /dev/null +++ b/tests/test_management_app.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient +import pytest + +from roborock_local_server.config import diagnose_config, load_config +from roborock_local_server.management_app import ( + DEFAULT_BOOTSTRAP_HTTPS_PORT, + _setup_wizard_html, + _wizard_prefill_from_env, + bootstrap_port_from_env, + create_management_app, +) + +_VALID_PAYLOAD = { + "stack_fqdn": "api-roborock.example.com", + "https_port": 555, + "mqtt_tls_port": 8881, + "broker_mode": "embedded", + "tls_mode": "provided", + "admin_password": "super-secret-password", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", +} + +_FULL_CONFIG = """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "provided" +cert_file = "certs/fullchain.pem" +key_file = "certs/privkey.pem" + +[admin] +password_hash = "pbkdf2_sha256$600000$abc$def" +session_secret = "abcdefghijklmnopqrstuvwxyz123456" +protocol_login_email = "user@example.com" +protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl" +""".strip() + +_PARTIAL_CONFIG_MISSING_ADMIN = """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "provided" +cert_file = "certs/fullchain.pem" +key_file = "certs/privkey.pem" +""".strip() + + +def test_bootstrap_port_from_env_default() -> None: + assert bootstrap_port_from_env({}) == DEFAULT_BOOTSTRAP_HTTPS_PORT + + +def test_bootstrap_port_from_env_reuses_https_port_var() -> None: + assert bootstrap_port_from_env({"ROBOROCK_SERVER_HTTPS_PORT": "9090"}) == 9090 + + +def test_bootstrap_port_from_env_rejects_bad_value() -> None: + with pytest.raises(ValueError, match="must be an integer"): + bootstrap_port_from_env({"ROBOROCK_SERVER_HTTPS_PORT": "not-a-port"}) + + +def test_index_serves_setup_wizard_when_unconfigured(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + client = TestClient(create_management_app(config_file=config_file)) + + response = client.get("/") + + assert response.status_code == 200 + assert "Roborock Local Server Setup" in response.text + + +def test_index_serves_status_page_when_configured(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text(_FULL_CONFIG, encoding="utf-8") + client = TestClient(create_management_app(config_file=config_file)) + + response = client.get("/") + + assert response.status_code == 200 + assert "Setup is complete" in response.text + + +def test_index_serves_admin_only_wizard_when_only_admin_missing(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text(_PARTIAL_CONFIG_MISSING_ADMIN, encoding="utf-8") + client = TestClient(create_management_app(config_file=config_file)) + + response = client.get("/admin") + + assert response.status_code == 200 + assert "Just add admin credentials" in response.text + assert 'name="stack_fqdn"' not in response.text + + +def test_setup_status_reflects_config_state(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + client = TestClient(create_management_app(config_file=config_file)) + + assert client.get("/admin/api/setup/status").json() == {"status": "invalid"} + + config_file.write_text(_PARTIAL_CONFIG_MISSING_ADMIN, encoding="utf-8") + assert client.get("/admin/api/setup/status").json() == {"status": "missing_admin"} + + config_file.write_text(_FULL_CONFIG, encoding="utf-8") + assert client.get("/admin/api/setup/status").json() == {"status": "ok"} + + +def test_submit_setup_writes_bootable_config(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + calls: list[str] = [] + client = TestClient(create_management_app(config_file=config_file, on_configured=lambda: calls.append("done"))) + + response = client.post("/admin/api/setup", json=_VALID_PAYLOAD) + + assert response.status_code == 200 + assert response.json()["ok"] is True + assert calls == ["done"] + + config = load_config(config_file) + assert config.network.stack_fqdn == "api-roborock.example.com" + assert config.admin.protocol_login_email == "user@example.com" + + +def test_submit_setup_with_external_broker_needs_no_manual_edit(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + client = TestClient(create_management_app(config_file=config_file)) + payload = dict(_VALID_PAYLOAD, broker_mode="external", broker_host="mqtt.internal") + + response = client.post("/admin/api/setup", json=payload) + + assert response.status_code == 200 + config = load_config(config_file) + assert config.broker.mode == "external" + assert config.broker.host == "mqtt.internal" + + +def test_submit_setup_rejects_missing_external_broker_host(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + client = TestClient(create_management_app(config_file=config_file)) + payload = dict(_VALID_PAYLOAD, broker_mode="external") + + response = client.post("/admin/api/setup", json=payload) + + assert response.status_code == 400 + assert "broker_host" in response.json()["error"] + assert config_file.exists() is False + + +def test_submit_setup_rejects_bad_stack_fqdn(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + client = TestClient(create_management_app(config_file=config_file)) + payload = dict(_VALID_PAYLOAD, stack_fqdn="roborock.example.com") + + response = client.post("/admin/api/setup", json=payload) + + assert response.status_code == 400 + assert "must start with api-" in response.json()["error"] + + +def test_submit_setup_refuses_to_overwrite_a_fully_configured_stack(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text(_FULL_CONFIG, encoding="utf-8") + client = TestClient(create_management_app(config_file=config_file)) + + response = client.post("/admin/api/setup", json=_VALID_PAYLOAD) + + assert response.status_code == 409 + assert config_file.read_text(encoding="utf-8") == _FULL_CONFIG + + +def test_submit_setup_refuses_to_overwrite_an_existing_broken_config(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text("existing = true\n", encoding="utf-8") + client = TestClient(create_management_app(config_file=config_file)) + + response = client.post("/admin/api/setup", json=_VALID_PAYLOAD) + + assert response.status_code == 400 + assert "Refusing to overwrite" in response.json()["error"] + assert config_file.read_text(encoding="utf-8") == "existing = true\n" + + +def test_submit_setup_completes_admin_only_config(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text(_PARTIAL_CONFIG_MISSING_ADMIN, encoding="utf-8") + calls: list[str] = [] + client = TestClient(create_management_app(config_file=config_file, on_configured=lambda: calls.append("done"))) + + response = client.post( + "/admin/api/setup", + json={ + "admin_password": "super-secret-password", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", + }, + ) + + assert response.status_code == 200 + assert calls == ["done"] + + config = load_config(config_file) + # Network/broker/storage/tls came from the pre-existing partial config, untouched. + assert config.network.stack_fqdn == "api-roborock.example.com" + assert config.admin.protocol_login_email == "user@example.com" + assert str(config.admin.password_hash).startswith("pbkdf2_sha256$") + + +def test_submit_setup_admin_only_rejects_bad_pin(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text(_PARTIAL_CONFIG_MISSING_ADMIN, encoding="utf-8") + client = TestClient(create_management_app(config_file=config_file)) + + response = client.post( + "/admin/api/setup", + json={ + "admin_password": "super-secret-password", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "12", + }, + ) + + assert response.status_code == 400 + assert "6 digits" in response.json()["error"] + assert diagnose_config(config_file) == "missing_admin" + + +def test_submit_setup_cloudflare_actalis_requires_eab(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + client = TestClient(create_management_app(config_file=config_file)) + payload = dict( + _VALID_PAYLOAD, + tls_mode="cloudflare_acme", + base_domain="example.com", + email="acme@example.com", + cloudflare_token="cf-token", + acme_server="actalis", + ) + + response = client.post("/admin/api/setup", json=payload) + + assert response.status_code == 400 + assert "Actalis" in response.json()["error"] + + +def test_wizard_prefill_from_env_ignores_secrets() -> None: + env = { + "ROBOROCK_SERVER_STACK_FQDN": "api-rr.example.com", + "ROBOROCK_SERVER_ADMIN_PASSWORD": "super-secret", + "ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN": "123456", + "ROBOROCK_SERVER_CLOUDFLARE_TOKEN": "cf-secret-token", + } + + prefill = _wizard_prefill_from_env(env) + + assert prefill == {"stack_fqdn": "api-rr.example.com"} + + +def test_wizard_prefill_from_env_only_includes_set_values() -> None: + assert _wizard_prefill_from_env({}) == {} + + +def test_setup_wizard_html_prefills_stack_fqdn_and_defaults_ports() -> None: + html = _setup_wizard_html(prefill={"stack_fqdn": "api-rr.binarycow.io"}) + + assert 'value="api-rr.binarycow.io"' in html + assert 'value="555"' in html + assert 'value="8881"' in html + + +def test_setup_wizard_html_reveals_external_broker_section_when_prefilled() -> None: + html = _setup_wizard_html(prefill={"broker_mode": "external", "broker_host": "mqtt.internal"}) + + assert 'value="mqtt.internal"' in html + assert 'id="broker_external" class="row hidden"' not in html + assert 'name="broker_mode" type="radio" value="external" checked' in html + + +def test_setup_wizard_html_reveals_provided_cert_section_when_prefilled() -> None: + html = _setup_wizard_html(prefill={"tls_mode": "provided"}) + + assert 'id="tls_cloudflare" class="hidden"' in html + assert 'name="tls_mode" type="radio" value="provided" checked' in html + + +def test_setup_wizard_html_reveals_actalis_fields_when_prefilled() -> None: + html = _setup_wizard_html(prefill={"acme_server": "actalis"}) + + assert 'id="acme_actalis" class="row hidden"' not in html + assert 'value="actalis" selected' in html + + +def test_setup_wizard_html_with_no_prefill_matches_original_defaults() -> None: + html = _setup_wizard_html() + + assert 'input id="stack_fqdn" name="stack_fqdn" type="text" value="" required' in html + assert 'id="broker_external" class="row hidden"' in html + assert 'id="tls_cloudflare" class="">' in html + assert 'id="acme_actalis" class="row hidden"' in html + assert 'name="broker_mode" type="radio" value="embedded" checked' in html + assert 'name="tls_mode" type="radio" value="cloudflare_acme" checked' in html diff --git a/tests/test_mitm_redirect.py b/tests/test_mitm_redirect.py index 73056e3..1e818b4 100644 --- a/tests/test_mitm_redirect.py +++ b/tests/test_mitm_redirect.py @@ -276,3 +276,97 @@ def test_host_only_local_api_still_defaults_to_555(monkeypatch) -> None: assert (host, port) == ("api-rr.example.com", 555) assert env_authority == "api-rr.example.com:555" + + +def test_sync_callback_url_supports_custom_path(monkeypatch) -> None: + mitm_redirect = _load_mitm_redirect(monkeypatch) + + assert ( + mitm_redirect._sync_callback_url("api-roborock.example.com", path=mitm_redirect.MITM_ACTIVITY_SYNC_PATH) + == "https://api-roborock.example.com/internal/protocol/mitm-activity" + ) + + +def test_activity_sync_disabled_by_default_makes_no_request(monkeypatch) -> None: + mitm_redirect = _load_mitm_redirect(monkeypatch) + monkeypatch.setattr(mitm_redirect, "_log_flow", lambda *args, **kwargs: None) + mitm_redirect.LOCAL_SYNC_SECRET = "abcdefghijklmnopqrstuvwxyz123456" + mitm_redirect.LOCAL_API = "api-roborock.example.com" + assert mitm_redirect.ACTIVITY_SYNC_ENABLED is False + + called = [] + monkeypatch.setattr(mitm_redirect, "urlopen", lambda *a, **k: called.append(1) or _FakeResponse()) + + flow = _FakeResponseFlow("usiot.roborock.com", "/api/v1/userinfo", b"plain text") + flow.response.headers["content-type"] = "text/plain" + mitm_redirect.response(flow) + + assert called == [] + + +def test_activity_sync_requires_secret(monkeypatch) -> None: + mitm_redirect = _load_mitm_redirect(monkeypatch) + monkeypatch.setattr(mitm_redirect, "_log_flow", lambda *args, **kwargs: None) + mitm_redirect.ACTIVITY_SYNC_ENABLED = True + mitm_redirect.LOCAL_SYNC_SECRET = "" + mitm_redirect.LOCAL_API = "api-roborock.example.com" + + called = [] + monkeypatch.setattr(mitm_redirect, "urlopen", lambda *a, **k: called.append(1) or _FakeResponse()) + + flow = _FakeResponseFlow("usiot.roborock.com", "/api/v1/userinfo", b"plain text") + flow.response.headers["content-type"] = "text/plain" + mitm_redirect.response(flow) + + assert called == [] + + +def test_activity_sync_sends_entry_when_enabled(monkeypatch) -> None: + mitm_redirect = _load_mitm_redirect(monkeypatch) + monkeypatch.setattr(mitm_redirect, "_log_flow", lambda *args, **kwargs: None) + mitm_redirect.ACTIVITY_SYNC_ENABLED = True + mitm_redirect.LOCAL_SYNC_SECRET = "abcdefghijklmnopqrstuvwxyz123456" + mitm_redirect.LOCAL_API = "api-roborock.example.com" + + captured: dict[str, object] = {} + + def fake_urlopen(request, timeout, context): + captured["url"] = request.full_url + captured["body"] = json.loads(request.data) + captured["headers"] = dict(request.headers) + return _FakeResponse() + + monkeypatch.setattr(mitm_redirect, "urlopen", fake_urlopen) + + flow = _FakeResponseFlow("usiot.roborock.com", "/api/v1/userinfo", b"plain text") + flow.response.headers["content-type"] = "text/plain" + mitm_redirect.response(flow) + + assert captured["url"] == "https://api-roborock.example.com/internal/protocol/mitm-activity" + assert captured["headers"]["X-local-sync-secret"] == "abcdefghijklmnopqrstuvwxyz123456" + entries = captured["body"]["entries"] + assert len(entries) == 1 + assert entries[0]["host"] == "usiot.roborock.com" + assert entries[0]["path"] == "/api/v1/userinfo" + assert entries[0]["method"] == "GET" + assert entries[0]["status"] == 200 + assert entries[0]["rewritten"] is False + + +def test_activity_sync_failure_does_not_raise(monkeypatch) -> None: + mitm_redirect = _load_mitm_redirect(monkeypatch) + monkeypatch.setattr(mitm_redirect, "_log_flow", lambda *args, **kwargs: None) + mitm_redirect.ACTIVITY_SYNC_ENABLED = True + mitm_redirect.LOCAL_SYNC_SECRET = "abcdefghijklmnopqrstuvwxyz123456" + mitm_redirect.LOCAL_API = "api-roborock.example.com" + + def fake_urlopen(request, timeout, context): + raise OSError("connection refused") + + monkeypatch.setattr(mitm_redirect, "urlopen", fake_urlopen) + + flow = _FakeResponseFlow("usiot.roborock.com", "/api/v1/userinfo", b"plain text") + flow.response.headers["content-type"] = "text/plain" + + # Must not raise - a broken activity-sync endpoint must never break the actual redirect. + mitm_redirect.response(flow) diff --git a/tests/test_protocol_auth.py b/tests/test_protocol_auth.py index de9d3a0..41dfda0 100644 --- a/tests/test_protocol_auth.py +++ b/tests/test_protocol_auth.py @@ -6,7 +6,7 @@ from conftest import write_release_config from roborock_local_server.config import load_config, resolve_paths -from roborock_local_server.server import PROTOCOL_AUTH_SYNC_PATH, ReleaseSupervisor +from roborock_local_server.server import MITM_ACTIVITY_SYNC_PATH, PROTOCOL_AUTH_SYNC_PATH, ReleaseSupervisor from https_server.routes.auth.service import load_cloud_user_data from shared.protocol_auth import ProtocolAuthStore, build_hawk_authorization @@ -450,6 +450,75 @@ def test_protocol_sync_route_persists_additional_sessions_and_redacts_logs(tmp_p assert sync_entry["headers"]["x-local-sync-secret"] == "" +def test_mitm_activity_sync_route_appends_entries_and_redacts_http_log(tmp_path: Path) -> None: + supervisor, paths = _build_supervisor(tmp_path) + client = TestClient(supervisor.app) + + response = client.post( + MITM_ACTIVITY_SYNC_PATH, + json={ + "entries": [ + { + "host": "usiot.roborock.com", + "method": "GET", + "path": "/api/v1/userinfo", + "status": 200, + "rewritten": False, + "request_headers": {"authorization": "real-cloud-token-999"}, + } + ] + }, + headers={"X-Local-Sync-Secret": "abcdefghijklmnopqrstuvwxyz123456"}, + ) + + assert response.status_code == 200 + assert response.json()["data"]["stored"] == 1 + assert paths.mitm_activity_jsonl_path.exists() + + stored_entries = [ + json.loads(line) for line in paths.mitm_activity_jsonl_path.read_text(encoding="utf-8").splitlines() if line.strip() + ] + assert len(stored_entries) == 1 + assert stored_entries[0]["source"] == "mitm" + assert stored_entries[0]["host"] == "usiot.roborock.com" + assert "time" in stored_entries[0] + + # The sync request's own body (which can carry real Roborock account + # tokens) must not leak unredacted into the regular HTTP activity log. + log_entries = [ + json.loads(line) for line in paths.http_jsonl_path.read_text(encoding="utf-8").splitlines() if line.strip() + ] + sync_entry = next(entry for entry in log_entries if entry.get("route") == "mitm_activity_sync") + assert sync_entry["body_redacted"] is True + assert "body_text" not in sync_entry + + +def test_mitm_activity_sync_route_rejects_invalid_secret(tmp_path: Path) -> None: + supervisor, _paths = _build_supervisor(tmp_path) + client = TestClient(supervisor.app) + + response = client.post( + MITM_ACTIVITY_SYNC_PATH, + json={"entries": [{"host": "usiot.roborock.com"}]}, + headers={"X-Local-Sync-Secret": "wrong-secret"}, + ) + + assert response.status_code == 401 + + +def test_mitm_activity_sync_route_rejects_missing_entries(tmp_path: Path) -> None: + supervisor, _paths = _build_supervisor(tmp_path) + client = TestClient(supervisor.app) + + response = client.post( + MITM_ACTIVITY_SYNC_PATH, + json={"entries": []}, + headers={"X-Local-Sync-Secret": "abcdefghijklmnopqrstuvwxyz123456"}, + ) + + assert response.status_code == 400 + + def test_local_issued_and_imported_sessions_coexist(tmp_path: Path) -> None: supervisor, paths = _build_supervisor(tmp_path) client = TestClient(supervisor.app)