diff --git a/.gitignore b/.gitignore index 323723a..1e8c21e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,11 @@ __pycache__/ *.pyo *.pyd .pytest_cache/ +dist/ +dev/ data/ site/ secrets/ config.toml mitm_logs -dist/ \ No newline at end of file +.tmp* diff --git a/Dockerfile b/Dockerfile index 406fd81..14c228b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,4 @@ RUN pip install --no-cache-dir /app EXPOSE 443 8883 -CMD ["roborock-local-server", "serve", "--config", "/app/config.toml"] +CMD ["python", "-m", "roborock_local_server.container_entrypoint"] diff --git a/README.md b/README.md index 6adf9a4..7a0e02c 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,8 @@ The best way to support this project is the next time you are buying a Roborock device come back here and use one of my affiliate links where I will receive a commission. [![Amazon Affiliate][badge-amazon]][link-amazon] -[![Roborock 5 Off][badge-roborock-discount]][link-roborock-discount] [![Roborock Affiliate][badge-roborock-affiliate]][link-roborock-affiliate] - +[![Roborock 5 Off][badge-roborock-discount]][link-roborock-discount] You can also support via BMAC or paypal: @@ -21,25 +20,26 @@ This project is in VERY EARLY BETA!!! Do not use this repository unless you are ## Requirements -- Docker with `docker compose` -- `uv` -- a Linux server or Linux VM on your LAN - a domain you control +- a place to run the stack on your LAN +- either Docker Compose or a Home Assistant installation that supports add-ons +- a second machine for onboarding later - a Cloudflare API token with DNS edit access for the zone if you want automatic certificate renewal ## Getting Started Start here if this is your first time setting up the stack: -1. [Installation](docs/installation.md) for requirements, network setup, configuration, and starting the stack. -2. [Cloudflare setup](docs/cloudflare_setup.md) if you want Cloudflare DNS-01 auto-renew for certificates. -3. [Onboarding](docs/onboarding.md) to pair a vacuum from a second machine after the server is running. +1. [Installation](docs/installation.md) for the shared requirements, network setup, and Docker Compose install path. +2. [Home Assistant](docs/home_assistant.md) if you want to install the stack as a Home Assistant add-on instead of Docker Compose. +3. [Cloudflare setup](docs/cloudflare_setup.md) if you want Cloudflare DNS-01 auto-renew for certificates. +4. [Onboarding](docs/onboarding.md) to pair a vacuum from a second machine after the server is running. Additional docs: - [Docs index](docs/index.md) - [Tested vacuums](docs/tested_vacuums.md) -- [Home Assistant](docs/home_assistant.md) +- [Home Assistant](docs/home_assistant.md) for the add-on install path and Home Assistant integration rewiring - [Using the Roborock App](docs/roborock_app.md) - [Custom MQTT](docs/custom_mqtt.md) - [Custom certificate management](docs/custom_cert_management.md) diff --git a/config.example.toml b/config.example.toml index 52614eb..dbf29fd 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,8 +1,8 @@ [network] # The one hostname the stack will serve. Keep this as the hostname only. -stack_fqdn = "roborock.example.com" +stack_fqdn = "api-roborock.example.com" bind_host = "0.0.0.0" -# Change these if you need the stack to advertise and listen on custom ports. +# Change these if you need custom published ports. https_port = 555 mqtt_tls_port = 8881 region = "us" diff --git a/docs/home_assistant.md b/docs/home_assistant.md index 6bcd832..91c91b9 100644 --- a/docs/home_assistant.md +++ b/docs/home_assistant.md @@ -1,17 +1,74 @@ # Home Assistant -Use this after [Installation](installation.md) and [Onboarding](onboarding.md) if you want Home Assistant to talk to your local stack. +This page covers two separate Home Assistant tasks: -To use this server with Home Assistant, edit your config entry at `config/.storage/core.config_entries`. +- installing the local stack as a Home Assistant add-on +- repointing Home Assistant's Roborock integration to a local stack that is already running -Find `"roborock.com"` and replace the endpoint values with your local stack URLs: +## Install As A Home Assistant Add-on -- `base_url` -> `https://api-roborock.example.com:555` -- `"a"` -> `https://api-roborock.example.com:555` -- `"l"` -> `https://api-roborock.example.com:555` -- `"m"` -> `ssl://mqtt-roborock.example.com:8881` +This is an installation method, not a post-install integration step. The add-on uses the same container image as the Docker deployment: -If you changed `network.https_port` or `network.mqtt_tls_port`, use those values instead. +- `ghcr.io/python-roborock/local_roborock_server` + +### Install Steps + +1. Open the Home Assistant Add-on Store. +2. Add this repository under **Repositories**: + + - `https://github.com/Python-roborock/local_roborock_server` + +3. Install **Roborock Local Server**. +4. Fill the add-on options: + + - `stack_fqdn` + - `https_port` + - `mqtt_tls_port` + - `region` + - `admin_password` + - `protocol_login_email` + - `protocol_login_pin` + - TLS settings: + - `tls_mode = provided` with `cert_file` and `key_file` + - or `tls_mode = cloudflare_acme` with `tls_base_domain`, `tls_email`, and `cloudflare_token` + +5. Start the add-on. + +Then open the admin dashboard at your configured stack hostname, for example: + +- `https://api-roborock.example.com:555/admin` + +Do not use the Home Assistant UI hostname unless it is the same hostname covered by the TLS certificate you configured for `stack_fqdn`. + +If you need the MITM protocol sync secret for the Roborock app flow, sign in to the admin page and open **Protocol Auth**. The dashboard shows the active `admin.session_secret`, so you do not need to inspect `/data/config.toml` manually. + +### Add-on Behavior + +- The add-on always runs the embedded MQTT broker and keeps the topic bridge enabled. +- The add-on terminates TLS itself and publishes two ports: HTTPS on `https_port` and MQTT/TLS on `mqtt_tls_port`. +- If you already manage certificates in another Home Assistant add-on such as Nginx Proxy Manager, you can point `cert_file` and `key_file` at those PEM files through `/all_addon_configs/...`. +- Installing the add-on does **not** automatically rewrite Home Assistant's Roborock integration entry. + +## Repoint The Home Assistant Roborock Integration + +This applies whether your local stack is running via Docker Compose or via the Home Assistant add-on. + +1. Open your Home Assistant configuration directory and locate `.storage/core.config_entries`. + + On many Home Assistant systems this file is at `/config/.storage/core.config_entries`. + +2. Find the Roborock entry and replace the endpoint values with your local stack URLs: + + - `base_url` -> `https://api-roborock.example.com:555` + - `"a"` -> `https://api-roborock.example.com:555` + - `"l"` -> `https://api-roborock.example.com:555` + - `"m"` -> `ssl://api-roborock.example.com:8881` + + The current server advertises the same hostname for HTTPS and MQTT/TLS, so `"m"` should normally use the same `stack_fqdn`, not a separate `mqtt-...` hostname. + +3. If you changed `https_port` or `mqtt_tls_port`, use those values instead. + +4. Restart Home Assistant so the integration reloads the updated endpoints. ## Related Docs diff --git a/docs/index.md b/docs/index.md index c52cfeb..6fc02c5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,9 +4,12 @@ Use this page as the main docs hub for setup, onboarding, and follow-up guides. ## Start Here -1. [Installation](installation.md) for requirements, network setup, configuration, and starting the stack. -2. [Cloudflare setup](cloudflare_setup.md) if you want Cloudflare DNS-01 auto-renew for certificates. -3. [Onboarding](onboarding.md) to pair a vacuum from a second machine after the server is running. +1. Read [Installation](installation.md) for the shared requirements and network setup. +2. Choose an install method: + - finish the Docker Compose steps in [Installation](installation.md) + - or use [Home Assistant](home_assistant.md) to install the stack as a Home Assistant add-on +3. Use [Cloudflare setup](cloudflare_setup.md) if you want Cloudflare DNS-01 auto-renew for certificates. +4. Run [Onboarding](onboarding.md) from a second machine after the server is running. ## Support This Project diff --git a/docs/installation.md b/docs/installation.md index b3ddba2..227cfae 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,66 +1,77 @@ # Installation -Start here for a first-time setup. After the stack is running, continue with [Onboarding](onboarding.md) to pair a vacuum. +Start here for a first-time setup. The project supports two installation methods: -## Requirements +- Docker Compose on your own Linux host or VM +- the Home Assistant add-on from this repository + +After the stack is running, continue with [Onboarding](onboarding.md) to pair a vacuum. + +## Shared Requirements -- Docker with `docker compose` -- Python (I recommend installing [uv](https://docs.astral.sh/uv/getting-started/installation/)) -- Two machines - one to run the server and one to do the onboarding - A domain name that you own -- A machine that can host the stack's HTTPS and MQTT TLS ports internally on your network. The defaults are `555` and `8881`. +- A place to run the stack on your LAN +- A second machine for onboarding later +- A network that can host the stack's HTTPS and MQTT TLS ports internally. The defaults are `555` and `8881`. - A Cloudflare API token with DNS edit access for the zone if you want Cloudflare DNS-01 auto-renew. See [Cloudflare setup](cloudflare_setup.md). ## Network Setup -1. Pick a URL for this application. It needs to be a subdomain of a domain you own, and it **must** start with `api-`. It does NOT need to be accessible outside your network - in fact, I strongly recommend you keep it internal only for now. +1. Pick a hostname for this application. It must be a subdomain of a domain you own, and it **must** start with `api-`. - For example, if you own `example.com`, I'd recommend `api-roborock.example.com`. Throughout the rest of the docs we'll refer to this as the **FQDN**. If you follow this format, you can just replace `example.com` with your real domain wherever you see it. + For example, if you own `example.com`, use `api-roborock.example.com`. Throughout the docs this is the **stack FQDN**. 2. Your network **must** handle its own DNS for the network the vacuum connects to. If it uses an external DNS server like `8.8.8.8`, this will not work. -3. Create DNS records pointing to your server's local IP address for both `api-roborock.example.com` and `mqtt-roborock.example.com`. +3. Create a DNS record pointing your stack FQDN to the local IP of the machine running the stack. -## Docker Setup + With the current server behavior, the same hostname is advertised for both HTTPS and MQTT/TLS, so you do not need a separate `mqtt-...` hostname unless you have built your own custom client routing around one. -1. Clone this repository: +## Method 1: Docker Compose -`git clone https://github.com/Python-roborock/local_roborock_server` +### Additional Requirements -2. Change into the project folder. +- Docker with `docker compose` +- Python +- [uv](https://docs.astral.sh/uv/getting-started/installation/) -```bash -cd local_roborock_server -``` +### Steps -3. Install the project dependencies. +1. Clone this repository: -```bash -uv sync -``` + ```bash + git clone https://github.com/Python-roborock/local_roborock_server + cd local_roborock_server + ``` -4. Run the setup wizard. +2. Install the project dependencies: -```bash -uv run roborock-local-server configure -``` + ```bash + uv sync + ``` + +3. Run the setup wizard: + + ```bash + uv run roborock-local-server configure + ``` -The wizard asks only for: + The wizard asks for: -- your `stack_fqdn` (the URL for your server - must start with `api-`) -- your HTTPS and MQTT TLS ports if you do not want the defaults `555` and `8881` -- embedded MQTT or your own broker -- whether to use Cloudflare DNS-01 auto-renew -- your admin password -- your Home Assistant/app login email and 6-digit PIN + - `stack_fqdn` (must start with `api-`) + - HTTPS and MQTT TLS ports if you do not want the defaults `555` and `8881` + - embedded MQTT or your own broker + - whether to use Cloudflare DNS-01 auto-renew + - your admin password + - your Home Assistant/app login email and 6-digit PIN -It then writes `config.toml`, generates `admin.password_hash` and `admin.session_secret`, and if you chose Cloudflare it also writes `secrets/cloudflare_token`. + It then writes `config.toml`, generates `admin.password_hash` and `admin.session_secret`, and if you chose Cloudflare it also writes `secrets/cloudflare_token`. -5. 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, fill in `broker.host` in `config.toml` before starting the stack. See [Custom MQTT](custom_mqtt.md). -6. If you skipped Cloudflare, put your certificate files in `data/certs/fullchain.pem` and `data/certs/privkey.pem`. See [Custom certificate management](custom_cert_management.md). +5. If you skipped Cloudflare, put your certificate files in `data/certs/fullchain.pem` and `data/certs/privkey.pem`. See [Custom certificate management](custom_cert_management.md). -7. Start the container: +6. Start the container: ```bash docker compose up -d --build @@ -74,15 +85,21 @@ It then writes `config.toml`, generates `admin.password_hash` and `admin.session docker compose up -d --build ``` -8. Go to the admin dashboard: `https://api-roborock.example.com:555/admin` by default, or `https://api-roborock.example.com:YOUR_HTTPS_PORT/admin` if you chose a custom HTTPS port. +## 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. + +## After The Stack Starts + +1. Open the admin dashboard at `https://api-roborock.example.com:555/admin` by default, or `https://api-roborock.example.com:YOUR_HTTPS_PORT/admin` if you chose a custom HTTPS port. -9. Import your data from the cloud so things like routines and rooms will work. Enter your email in under cloud import, then hit send code. Once the code is returned enter the code and hit fetch data. +2. Import your data from the cloud so things like routines and rooms will work. Enter your email under cloud import, select **Send code**, then enter the returned code and select **Fetch data**. -10. For any routines that use zones, you need to re-save them so the server stores the zone data correctly. In the Roborock app, open each routine that has zones, click on the zone, tap **Edit**, click on any **Zone Cleaning** entry, then tap **Save**. Repeat for each zone in the routine. +3. 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. ## Next Steps -- [Onboarding](onboarding.md) for pairing a new vacuum. -- [Home Assistant](home_assistant.md) if you want the local stack in Home Assistant. -- [Using the Roborock App](roborock_app.md) if you want to point the official app at your local stack. -- [Docs index](index.md) for the rest of the guides. +- [Onboarding](onboarding.md) for pairing a new vacuum +- [Home Assistant](home_assistant.md) if you want to repoint Home Assistant's Roborock integration to your local stack +- [Using the Roborock App](roborock_app.md) if you want to point the official app at your local stack +- [Docs index](index.md) for the rest of the guides diff --git a/docs/roborock_app.md b/docs/roborock_app.md index ab98cca..86e4425 100644 --- a/docs/roborock_app.md +++ b/docs/roborock_app.md @@ -2,7 +2,9 @@ Use this after [Installation](installation.md) and [Onboarding](onboarding.md) if you want the official Roborock app to talk to your local stack. -During the MITM login step, the script now needs to sync the captured protocol-auth session back to your server. Pass `admin.session_secret` from `config.toml` as `--sync-secret`. That sync callback always uses the `--local-api` host and port. +During the MITM login step, the script now needs to sync the captured protocol-auth session back to your server. Pass `admin.session_secret` from the active server config as `--sync-secret`. That sync callback always uses the `--local-api` host and port. + +The launcher can auto-load a sync secret from `config.toml` beside `mitm_redirect.py`, but that is only correct when that file matches the config used by the running server. If you run the MITM step from a second machine, or your server is using a generated Home Assistant config or another config file, pass `--sync-secret` explicitly. 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. @@ -16,7 +18,9 @@ The launcher now preflights that callback before starting `mitmweb`. If the `--l uv run mitm_redirect.py --local-api api-roborock.example.com --sync-secret YOUR_ADMIN_SESSION_SECRET ``` - Use the `admin.session_secret` value from `config.toml` for `YOUR_ADMIN_SESSION_SECRET`. + Use the `admin.session_secret` value from the config file your running server actually uses for `YOUR_ADMIN_SESSION_SECRET`. + + If startup fails with `invalid_sync_secret`, the launcher either auto-loaded the wrong local `config.toml` or you copied a stale secret. Re-read `admin.session_secret` from the active server config and pass it explicitly with `--sync-secret`. If you use the default local stack ports, host-only values are fine here: the script assumes HTTPS `:555` and MQTT TLS `:8881`. @@ -119,7 +123,9 @@ Make sure you have the following installed: uv run mitm_redirect.py --local-api api-roborock.example.com --sync-secret YOUR_ADMIN_SESSION_SECRET ``` - Use the `admin.session_secret` value from `config.toml` for `YOUR_ADMIN_SESSION_SECRET`. + Use the `admin.session_secret` value from the config file your running server actually uses for `YOUR_ADMIN_SESSION_SECRET`. + + If startup fails with `invalid_sync_secret`, the launcher either auto-loaded the wrong local `config.toml` or you copied a stale secret. Re-read `admin.session_secret` from the active server config and pass it explicitly with `--sync-secret`. If you use the default local stack ports, host-only values are fine here: the script assumes HTTPS `:555` and MQTT TLS `:8881`. diff --git a/mitm_redirect.py b/mitm_redirect.py index d5cbe1c..9e390b3 100644 --- a/mitm_redirect.py +++ b/mitm_redirect.py @@ -684,7 +684,7 @@ def _rewrite_value(text: str) -> str: parser.add_argument( "--sync-secret", default=None, - help="Optional admin.session_secret for protocol auth sync. Defaults to config.toml when available.", + 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("--mode", default="wireguard", help="mitmweb proxy mode (default: wireguard)") parser.add_argument("--listen-port", default=None, help="mitmweb listen port") diff --git a/mkdocs.yml b/mkdocs.yml index 0a06bb1..28ef104 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,10 +50,10 @@ nav: - Support This Project: support.md - Getting Started: - Installation: installation.md + - Home Assistant: home_assistant.md - Cloudflare Setup: cloudflare_setup.md - Onboarding: onboarding.md - Integrations: - - Home Assistant: home_assistant.md - Using the Roborock App: roborock_app.md - Reference: - Tested Vacuums: tested_vacuums.md diff --git a/pyproject.toml b/pyproject.toml index d288a9a..a1997e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roborock-local-server" -version = "0.0.2-rc1" +version = "0.0.2-rc6" description = "private local Roborock server stack." requires-python = ">=3.11,<3.14" readme = "README.md" diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..2b92890 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: Roborock Local Server Apps +url: "https://github.com/Python-roborock/local_roborock_server" +maintainer: Luke Lashley diff --git a/roborock_local_server_addon/CHANGELOG.md b/roborock_local_server_addon/CHANGELOG.md new file mode 100644 index 0000000..5fba8ba --- /dev/null +++ b/roborock_local_server_addon/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.2-rc6 + +- Initial Home Assistant add-on manifest using the shared GHCR image. diff --git a/roborock_local_server_addon/DOCS.md b/roborock_local_server_addon/DOCS.md new file mode 100644 index 0000000..576e779 --- /dev/null +++ b/roborock_local_server_addon/DOCS.md @@ -0,0 +1,30 @@ +# Roborock Local Server + +This add-on runs the same `ghcr.io/python-roborock/local_roborock_server` image used for Docker installs. + +It publishes two TLS ports directly: + +- `555/tcp` for the Roborock HTTPS API +- `8881/tcp` for the Roborock MQTT TLS proxy + +## Setup + +1. Set `stack_fqdn` to your `api-...` hostname. +2. Set `admin_password`, `protocol_login_email`, and `protocol_login_pin` (6 digits). +3. Choose TLS mode: + - `provided`: set `cert_file` and `key_file` (defaults: `/ssl/fullchain.pem`, `/ssl/privkey.pem`) + - `cloudflare_acme`: set `tls_base_domain`, `tls_email`, `cloudflare_token` +4. Start the add-on. + +The add-on always runs the embedded MQTT broker and keeps the topic bridge enabled. + +Then open `https://api-roborock.example.com:555/admin` using your configured `stack_fqdn` and HTTPS port. + +This add-on does not auto-edit Home Assistant's Roborock config entry. You still need to update `.storage/core.config_entries` so Home Assistant points at your local stack. + +## Notes + +- This add-on expects internal LAN-only usage. Do not expose it directly to the internet. +- If you change `https_port` or `mqtt_tls_port`, update your DNS/clients to use those ports. +- The current server advertises the same hostname for HTTPS and MQTT/TLS, so Home Assistant's Roborock entry should normally use `ssl://api-roborock.example.com:8881`, not a separate `mqtt-...` hostname. +- If you already manage certificates in another Home Assistant add-on such as Nginx Proxy Manager, you can point `cert_file` and `key_file` at that add-on's certs through `/all_addon_configs/...`. Example: `/all_addon_configs/a0d7b954_nginxproxymanager/letsencrypt/live/npm-3/fullchain.pem`. diff --git a/roborock_local_server_addon/config.yaml b/roborock_local_server_addon/config.yaml new file mode 100644 index 0000000..a97f2ec --- /dev/null +++ b/roborock_local_server_addon/config.yaml @@ -0,0 +1,51 @@ +name: Roborock Local Server +version: "0.0.2-rc6" +slug: roborock_local_server +description: Private Roborock HTTPS and MQTT stack for Home Assistant environments. +url: "https://github.com/Python-roborock/local_roborock_server" +image: "ghcr.io/python-roborock/local_roborock_server" +startup: services +boot: auto +init: false +arch: + - amd64 + - aarch64 +ports: + 555/tcp: 555 + 8881/tcp: 8881 +ports_description: + 555/tcp: Roborock HTTPS API + 8881/tcp: Roborock MQTT TLS proxy +map: + - ssl:ro + - addon_config:rw + - all_addon_configs:ro +webui: "https://[HOST]:[PORT:555]/admin" +options: + stack_fqdn: "api-roborock.example.com" + https_port: 555 + mqtt_tls_port: 8881 + region: "us" + tls_mode: "provided" + tls_base_domain: "" + tls_email: "" + cloudflare_token: "" + cert_file: "/ssl/fullchain.pem" + key_file: "/ssl/privkey.pem" + admin_password: "" + protocol_login_email: "" + protocol_login_pin: "" +schema: + stack_fqdn: str + https_port: port + mqtt_tls_port: port + region: list(us|eu|cn|ru) + tls_mode: list(provided|cloudflare_acme) + tls_base_domain: str + tls_email: str + cloudflare_token: str + cert_file: str + key_file: str + admin_password: password + protocol_login_email: email + protocol_login_pin: password diff --git a/src/roborock_local_server/__init__.py b/src/roborock_local_server/__init__.py index f4b23ae..deeb218 100644 --- a/src/roborock_local_server/__init__.py +++ b/src/roborock_local_server/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.0.2-rc1" +__version__ = "0.0.2-rc6" diff --git a/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py b/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py index f87799f..411bbca 100644 --- a/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py +++ b/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py @@ -103,6 +103,10 @@ from .routes.user.devices.detail import build_extra as _build_get_device_extra from .routes.user.devices.detail import match as _match_get_device from .routes.user.devices.detail import match_extra as _match_get_device_extra +from .routes.user.deviceshare import build_received_devices as _build_get_received_devices +from .routes.user.deviceshare import build_rooms as _build_get_shared_device_rooms +from .routes.user.deviceshare import match_received_devices as _match_get_received_devices +from .routes.user.deviceshare import match_rooms as _match_get_shared_device_rooms from .routes.user.devices.jobs import build as _build_get_schedules from .routes.user.devices.jobs import match as _match_get_schedules from .routes.user.devices.newadd import build as _build_add_device @@ -567,6 +571,8 @@ def default_endpoint_rules() -> Sequence[EndpointRule]: EndpointRule("post_home_rooms", _match_post_home_rooms, _build_post_home_rooms), EndpointRule("post_scene_create", _match_post_scene_create, _build_post_scene_create), EndpointRule("get_home_rooms", _match_get_home_rooms, _build_get_home_rooms), + EndpointRule("get_received_devices", _match_get_received_devices, _build_get_received_devices), + EndpointRule("get_shared_device_rooms", _match_get_shared_device_rooms, _build_get_shared_device_rooms), EndpointRule("get_scenes", _match_get_scenes, _build_get_scenes), EndpointRule("get_home_scenes", _match_get_home_scenes, _build_get_home_scenes), EndpointRule("get_scene_order", _match_get_scene_order, _build_get_scene_order), diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/user/deviceshare.py b/src/roborock_local_server/bundled_backend/https_server/routes/user/deviceshare.py new file mode 100644 index 0000000..2c13ced --- /dev/null +++ b/src/roborock_local_server/bundled_backend/https_server/routes/user/deviceshare.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import re +from typing import Any + +from shared.context import ServerContext +from shared.http_helpers import wrap_response + +from .devices.service import _home_data as device_home_payload +from .homes.service import home_payload, home_rooms_payload + + +def match_received_devices(path: str, method: str = "GET") -> bool: + clean = path.rstrip("/") + return method.upper() == "GET" and clean == "/user/deviceshare/query/receiveddevices" + + +def build_received_devices( + ctx: ServerContext, + _query_params: dict[str, list[str]], + _body_params: dict[str, list[str]], + _clean_path: str, +) -> dict[str, Any]: + payload = device_home_payload(ctx) + received_devices = payload.get("receivedDevices") if isinstance(payload, dict) else [] + return wrap_response(received_devices if isinstance(received_devices, list) else []) + + +def match_rooms(path: str, method: str = "GET") -> bool: + clean = path.rstrip("/") + return method.upper() == "GET" and bool(re.fullmatch(r"/user/deviceshare/query/[^/]+/rooms", clean)) + + +def build_rooms( + ctx: ServerContext, + _query_params: dict[str, list[str]], + _body_params: dict[str, list[str]], + _clean_path: str, +) -> dict[str, Any]: + return wrap_response(home_rooms_payload(ctx)) diff --git a/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py b/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py index c18540a..368c472 100644 --- a/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py +++ b/src/roborock_local_server/bundled_backend/mqtt_tls_proxy_server/server.py @@ -29,8 +29,8 @@ class MqttTlsProxy: def __init__( self, *, - cert_file: Path, - key_file: Path, + cert_file: Path | None, + key_file: Path | None, listen_host: str, listen_port: int, backend_host: str, @@ -44,9 +44,11 @@ def __init__( runtime_state: RuntimeState | None = None, runtime_credentials: RuntimeCredentialsStore | None = None, zone_ranges_store: ZoneRangesStore | None = None, + tls_enabled: bool = True, ) -> None: self.cert_file = cert_file self.key_file = key_file + self.tls_enabled = tls_enabled self.listen_host = listen_host self.listen_port = listen_port self.backend_host = backend_host @@ -715,7 +717,7 @@ def _relay(self, src: socket.socket, dst: socket.socket, conn_id: str, direction except OSError: pass - def _handle_client(self, tls_conn: ssl.SSLSocket, addr: tuple[str, int]) -> None: + def _handle_client(self, tls_conn: socket.socket | ssl.SSLSocket, addr: tuple[str, int]) -> None: conn_id = self._next_conn() backend: socket.socket | None = None relay_started = False @@ -809,7 +811,9 @@ def start(self) -> threading.Thread: thread.start() return thread - def _run(self) -> None: + def _build_tls_context(self) -> ssl.SSLContext: + if self.cert_file is None or self.key_file is None: + raise RuntimeError("TLS-enabled MQTT proxy requires cert_file and key_file") tls_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) tls_ctx.set_ciphers("DEFAULT:@SECLEVEL=0") # Older Roborock firmware MQTT clients negotiate TLSv1.0/1.1. @@ -825,6 +829,31 @@ def _run(self) -> None: tls_ctx.load_cert_chain(str(self.cert_file), str(self.key_file)) tls_ctx.check_hostname = False tls_ctx.verify_mode = ssl.CERT_NONE + return tls_ctx + + def _accept_client_connection( + self, + *, + raw_conn: socket.socket, + addr: tuple[str, int], + tls_ctx: ssl.SSLContext | None, + ) -> socket.socket | ssl.SSLSocket | None: + if not self.tls_enabled: + self.logger.info("Plain MQTT accept from %s:%d", addr[0], addr[1]) + return raw_conn + if tls_ctx is None: + raise RuntimeError("TLS MQTT accept requires an SSL context") + try: + tls_conn = tls_ctx.wrap_socket(raw_conn, server_side=True) + self.logger.info("TLS handshake ok from %s:%d (%s)", addr[0], addr[1], tls_conn.version()) + return tls_conn + except (ssl.SSLError, ConnectionResetError, OSError) as exc: + self.logger.warning("TLS handshake failed from %s:%d: %s", addr[0], addr[1], exc) + raw_conn.close() + return None + + def _run(self) -> None: + tls_ctx = self._build_tls_context() if self.tls_enabled else None self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -832,7 +861,8 @@ def _run(self) -> None: self._server_socket.listen(10) self._running = True self.logger.info( - "TLS MQTT proxy listening on %s:%d -> %s:%d", + "%s MQTT proxy listening on %s:%d -> %s:%d", + "TLS" if self.tls_enabled else "Plain", self.listen_host, self.listen_port, self.backend_host, @@ -842,14 +872,10 @@ def _run(self) -> None: while self._running: try: raw_conn, addr = self._server_socket.accept() - try: - tls_conn = tls_ctx.wrap_socket(raw_conn, server_side=True) - self.logger.info("TLS handshake ok from %s:%d (%s)", addr[0], addr[1], tls_conn.version()) - except (ssl.SSLError, ConnectionResetError, OSError) as exc: - self.logger.warning("TLS handshake failed from %s:%d: %s", addr[0], addr[1], exc) - raw_conn.close() + client_conn = self._accept_client_connection(raw_conn=raw_conn, addr=addr, tls_ctx=tls_ctx) + if client_conn is None: continue - threading.Thread(target=self._handle_client, args=(tls_conn, addr), daemon=True).start() + threading.Thread(target=self._handle_client, args=(client_conn, addr), daemon=True).start() except OSError as exc: if not self._running: break diff --git a/src/roborock_local_server/config.py b/src/roborock_local_server/config.py index c60e67e..43a1190 100644 --- a/src/roborock_local_server/config.py +++ b/src/roborock_local_server/config.py @@ -4,7 +4,9 @@ from dataclasses import dataclass from pathlib import Path +import re import tomllib +from urllib.parse import urlsplit @dataclass(frozen=True) @@ -99,6 +101,36 @@ def _require_non_empty(value: object, field_name: str) -> str: return text +_HOST_RE = re.compile(r"^[a-z0-9.-]+$") + + +def _normalize_hostname(value: object, field_name: str, *, require_api_prefix: bool = False) -> str: + text = _require_non_empty(value, field_name) + 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 _require_stack_fqdn(value: object, field_name: str) -> str: + return _normalize_hostname(value, field_name, require_api_prefix=True) + + def _as_int(value: object, field_name: str, default: int) -> int: if value in (None, ""): return default @@ -108,6 +140,13 @@ def _as_int(value: object, field_name: str, default: int) -> int: raise ValueError(f"{field_name} must be an integer") from exc +def _as_port(value: object, field_name: str, default: int) -> int: + candidate = _as_int(value, field_name, default) + if not (1 <= candidate <= 65535): + raise ValueError(f"{field_name} must be between 1 and 65535") + return candidate + + def _as_bool(value: object, default: bool) -> bool: if value is None: return default @@ -138,6 +177,9 @@ def load_config(path: str | Path) -> AppConfig: tls_mode = str(tls.get("mode", "cloudflare_acme")).strip().lower() if tls_mode not in {"cloudflare_acme", "provided"}: raise ValueError("tls.mode must be 'cloudflare_acme' or 'provided'") + listener_mode = str(network.get("listener_mode", "local_tls")).strip().lower() or "local_tls" + if listener_mode != "local_tls": + raise ValueError("network.listener_mode='external_tls' is no longer supported") raw_broker_host = broker.get("host") broker_host = str(raw_broker_host).strip() if raw_broker_host is not None else "127.0.0.1" @@ -147,10 +189,10 @@ def load_config(path: str | Path) -> AppConfig: config = AppConfig( network=NetworkConfig( - stack_fqdn=_require_non_empty(network.get("stack_fqdn"), "network.stack_fqdn"), + stack_fqdn=_require_stack_fqdn(network.get("stack_fqdn"), "network.stack_fqdn"), bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0", - https_port=_as_int(network.get("https_port"), "network.https_port", 555), - mqtt_tls_port=_as_int(network.get("mqtt_tls_port"), "network.mqtt_tls_port", 8881), + 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), region=str(network.get("region", "us")).strip().lower() or "us", localkey=str(network.get("localkey", "")).strip(), duid=str(network.get("duid", "")).strip(), @@ -161,7 +203,7 @@ def load_config(path: str | Path) -> AppConfig: broker=BrokerConfig( mode=broker_mode, host=broker_host, - port=_as_int(broker.get("port"), "broker.port", broker_port_default), + 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), ), @@ -170,7 +212,11 @@ def load_config(path: str | Path) -> AppConfig: ), tls=TlsConfig( mode=tls_mode, - base_domain=str(tls.get("base_domain", "")).strip(), + 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), @@ -201,7 +247,7 @@ def load_config(path: str | Path) -> AppConfig: _require_non_empty(config.broker.host, "broker.host") if config.tls.mode == "cloudflare_acme": - _require_non_empty(config.tls.base_domain, "tls.base_domain") + _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") else: diff --git a/src/roborock_local_server/configure.py b/src/roborock_local_server/configure.py index 1c77b0c..d99657c 100644 --- a/src/roborock_local_server/configure.py +++ b/src/roborock_local_server/configure.py @@ -65,7 +65,7 @@ def _toml_string(value: str) -> str: return json.dumps(value) -def _normalize_hostname(raw_value: str, *, field_name: str) -> str: +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") @@ -85,6 +85,8 @@ def _normalize_hostname(raw_value: str, *, field_name: str) -> str: 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 @@ -100,7 +102,11 @@ def _prompt_hostname(prompt: str, *, field_name: str) -> str: while True: raw_value = _prompt_non_empty(prompt) try: - return _normalize_hostname(raw_value, field_name=field_name) + return _normalize_hostname( + raw_value, + field_name=field_name, + require_api_prefix=field_name == "stack_fqdn", + ) except ValueError as exc: print(exc) @@ -177,8 +183,8 @@ def collect_configure_answers() -> ConfigureAnswers: "Stack FQDN (hostname only (no 'https://'); it needs to start with api-): ", field_name="stack_fqdn", ) - https_port = _prompt_port("HTTPS port to advertise and listen on", default=555) - mqtt_tls_port = _prompt_port("MQTT TLS port to advertise and listen on", default=8881) + https_port = _prompt_port("Advertised HTTPS port", default=555) + mqtt_tls_port = _prompt_port("Advertised MQTT TLS port", default=8881) use_external_broker = _prompt_yes_no("Use your own MQTT broker instead of the embedded one?", default=False) use_cloudflare_acme = _prompt_yes_no("Use Cloudflare DNS-01 for automatic TLS renewal?", default=True) diff --git a/src/roborock_local_server/container_entrypoint.py b/src/roborock_local_server/container_entrypoint.py new file mode 100644 index 0000000..3d423ea --- /dev/null +++ b/src/roborock_local_server/container_entrypoint.py @@ -0,0 +1,50 @@ +"""Container entrypoint that supports compose and Home Assistant apps.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from .ha_addon import write_config_from_home_assistant_options + + +def _exec_server(config_path: Path) -> None: + os.execvp( + "roborock-local-server", + ["roborock-local-server", "serve", "--config", str(config_path)], + ) + + +def _run_entrypoint(*, compose_config: Path, data_config: Path, addon_options: Path) -> None: + if compose_config.exists(): + _exec_server(compose_config) + return + + if addon_options.exists(): + write_config_from_home_assistant_options( + options_path=addon_options, + config_path=data_config, + ) + _exec_server(data_config) + return + + if data_config.exists(): + _exec_server(data_config) + return + + raise SystemExit( + "No config file found. Expected /app/config.toml, /data/config.toml, or /data/options.json." + ) + + +def main() -> int: + _run_entrypoint( + compose_config=Path("/app/config.toml"), + data_config=Path("/data/config.toml"), + addon_options=Path("/data/options.json"), + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/roborock_local_server/ha_addon.py b/src/roborock_local_server/ha_addon.py new file mode 100644 index 0000000..6148613 --- /dev/null +++ b/src/roborock_local_server/ha_addon.py @@ -0,0 +1,288 @@ +"""Home Assistant app option adapter for config.toml generation.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import secrets +import tomllib +from typing import Any +from urllib.parse import urlsplit + +from .configure import hash_password + + +DEFAULT_CERT_FILE = "/ssl/fullchain.pem" +DEFAULT_KEY_FILE = "/ssl/privkey.pem" + + +DEFAULT_OPTIONS: dict[str, Any] = { + "stack_fqdn": "", + "https_port": 555, + "mqtt_tls_port": 8881, + "region": "us", + "tls_mode": "provided", + "tls_base_domain": "", + "tls_email": "", + "cloudflare_token": "", + "cert_file": DEFAULT_CERT_FILE, + "key_file": DEFAULT_KEY_FILE, + "admin_password": "", + "protocol_login_email": "", + "protocol_login_pin": "", +} + +_HOST_RE = re.compile(r"^[a-z0-9.-]+$") +DEFAULT_OPTIONS_PATH = Path("/data/options.json") +DEFAULT_CONFIG_PATH = Path("/data/config.toml") +DEFAULT_CLOUDFLARE_TOKEN_PATH = Path("/run/secrets/cloudflare_token") + + +def _toml_string(value: str) -> str: + return json.dumps(value) + + +def _toml_bool(value: bool) -> str: + return "true" if value else "false" + + +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_int(value: object, *, 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 _require_non_empty(value: object, *, field_name: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"{field_name} is required") + return text + + +def _require_email(value: object, *, field_name: str) -> str: + text = _require_non_empty(value, field_name=field_name) + if "@" not in text: + raise ValueError(f"{field_name} must be an email address") + return text + + +def _require_pin(value: object, *, field_name: str) -> str: + text = _require_non_empty(value, field_name=field_name) + if len(text) != 6 or not text.isdigit(): + raise ValueError(f"{field_name} must be exactly 6 digits") + return text + + +def _load_options(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + parsed = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(parsed, dict): + raise ValueError(f"{path} must contain a JSON object") + return parsed + + +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 "" + + +def _render_config_toml( + *, + options: dict[str, Any], + config_path: Path, + cloudflare_token_path: Path, +) -> tuple[str, str]: + merged = dict(DEFAULT_OPTIONS) + merged.update(options) + + stack_fqdn = _normalize_hostname( + merged.get("stack_fqdn", ""), + field_name="stack_fqdn", + require_api_prefix=True, + ) + region = str(merged.get("region", "us") or "us").strip().lower() or "us" + listener_mode = str(merged.get("listener_mode", "local_tls") or "local_tls").strip().lower() or "local_tls" + if listener_mode != "local_tls": + raise ValueError("listener_mode='external_tls' is no longer supported") + https_port = _as_int(merged.get("https_port"), field_name="https_port", default=555) + mqtt_tls_port = _as_int(merged.get("mqtt_tls_port"), field_name="mqtt_tls_port", default=8881) + + # Legacy HA options for broker selection are ignored now that the add-on + # always runs the embedded broker with the topic bridge enabled. + broker_mode = "embedded" + broker_host = "127.0.0.1" + broker_port = 18830 + + tls_mode = str(merged.get("tls_mode", "provided") or "provided").strip().lower() + if tls_mode not in {"provided", "cloudflare_acme"}: + raise ValueError("tls_mode must be 'provided' or 'cloudflare_acme'") + tls_base_domain = str(merged.get("tls_base_domain", "") or "").strip() + tls_email = str(merged.get("tls_email", "") or "").strip() + cloudflare_token = str(merged.get("cloudflare_token", "") or "").strip() + cert_file = str(merged.get("cert_file", DEFAULT_CERT_FILE) or "").strip() + key_file = str(merged.get("key_file", DEFAULT_KEY_FILE) or "").strip() + effective_tls_mode = "cloudflare_acme" if cloudflare_token else tls_mode + + if effective_tls_mode == "cloudflare_acme": + _normalize_hostname(tls_base_domain, field_name="tls_base_domain") + _require_email(tls_email, field_name="tls_email") + _require_non_empty(cloudflare_token, field_name="cloudflare_token") + else: + cert_file = cert_file or DEFAULT_CERT_FILE + key_file = key_file or DEFAULT_KEY_FILE + _require_non_empty(cert_file, field_name="cert_file") + _require_non_empty(key_file, field_name="key_file") + + admin_password = _require_non_empty(merged.get("admin_password"), field_name="admin_password") + admin_session_secret = ( + str(merged.get("admin_session_secret", "") or "").strip() + or _load_existing_admin_session_secret(config_path) + or secrets.token_urlsafe(32) + ) + if len(admin_session_secret) < 24: + raise ValueError("admin_session_secret must be at least 24 characters when set") + # The Home Assistant add-on no longer exposes this toggle. + # Keep protocol auth enabled even if a stale stored option is present. + protocol_auth_enabled = True + protocol_login_email = _require_email(merged.get("protocol_login_email"), field_name="protocol_login_email") + protocol_login_pin = _require_pin(merged.get("protocol_login_pin"), field_name="protocol_login_pin") + + password_hash = hash_password(admin_password) + protocol_login_pin_hash = hash_password(protocol_login_pin) + cloudflare_token_file = str(cloudflare_token_path) + + lines = [ + "[network]", + f"stack_fqdn = {_toml_string(stack_fqdn)}", + 'bind_host = "0.0.0.0"', + f"https_port = {https_port}", + f"mqtt_tls_port = {mqtt_tls_port}", + f"region = {_toml_string(region)}", + "", + "[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]", + 'data_dir = "/data"', + "", + "[tls]", + f"mode = {_toml_string(effective_tls_mode)}", + ] + if effective_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", + 'acme_server = "zerossl"', + ] + ) + else: + lines.extend( + [ + 'base_domain = ""', + 'email = ""', + 'cloudflare_token_file = ""', + "renew_days_before = 30", + "renew_check_seconds = 43200", + 'acme_server = "zerossl"', + f"cert_file = {_toml_string(cert_file)}", + f"key_file = {_toml_string(key_file)}", + ] + ) + lines.extend( + [ + "", + "[admin]", + f"password_hash = {_toml_string(password_hash)}", + f"session_secret = {_toml_string(admin_session_secret)}", + "session_ttl_seconds = 86400", + f"protocol_auth_enabled = {_toml_bool(protocol_auth_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), cloudflare_token if effective_tls_mode == "cloudflare_acme" else "" + + +def write_config_from_home_assistant_options( + *, + options_path: Path = DEFAULT_OPTIONS_PATH, + config_path: Path = DEFAULT_CONFIG_PATH, + cloudflare_token_path: Path = DEFAULT_CLOUDFLARE_TOKEN_PATH, +) -> Path: + options = _load_options(options_path) + config_text, cloudflare_token = _render_config_toml( + options=options, + config_path=config_path, + cloudflare_token_path=cloudflare_token_path, + ) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(config_text, encoding="utf-8") + if cloudflare_token: + cloudflare_token_path.parent.mkdir(parents=True, exist_ok=True) + cloudflare_token_path.write_text(cloudflare_token, encoding="utf-8") + if os.name != "nt": + cloudflare_token_path.chmod(0o600) + elif cloudflare_token_path.exists(): + cloudflare_token_path.unlink() + return config_path + + +def main() -> int: + write_config_from_home_assistant_options() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py index 466e537..deeda1f 100644 --- a/src/roborock_local_server/server.py +++ b/src/roborock_local_server/server.py @@ -49,6 +49,8 @@ from https_server.routes.auth.service import ( build_login_data_response, cloud_login_data_required_response, + load_cloud_full_snapshot, + with_current_server_urls, ) from .bundled_backend.shared.zone_ranges_store import ZoneRangesStore from .security import AdminSessionManager, verify_password @@ -173,28 +175,36 @@ def __init__( app: FastAPI, bind_host: str, port: int, - cert_file: Path, - key_file: Path, + tls_enabled: bool, + cert_file: Path | None = None, + key_file: Path | None = None, ) -> None: self._app = app self._bind_host = bind_host self._port = port + self._tls_enabled = tls_enabled self._cert_file = cert_file self._key_file = key_file self._server: uvicorn.Server | None = None self._serve_task: asyncio.Task[bool] | None = None async def start(self) -> None: - config = uvicorn.Config( - app=self._app, - host=self._bind_host, - port=self._port, - log_level="warning", - access_log=False, - ssl_certfile=str(self._cert_file), - ssl_keyfile=str(self._key_file), - ssl_ciphers="DEFAULT:@SECLEVEL=0", - ) + config_kwargs: dict[str, Any] = { + "app": self._app, + "host": self._bind_host, + "port": self._port, + "log_level": "warning", + "access_log": False, + } + if self._tls_enabled: + if self._cert_file is None or self._key_file is None: + raise RuntimeError("TLS-enabled HTTP server requires cert_file and key_file") + config_kwargs.update( + ssl_certfile=str(self._cert_file), + ssl_keyfile=str(self._key_file), + ssl_ciphers="DEFAULT:@SECLEVEL=0", + ) + config = uvicorn.Config(**config_kwargs) self._server = uvicorn.Server(config) self._serve_task = asyncio.create_task(self._server.serve(), name="release-https-server") await self._wait_started() @@ -350,14 +360,14 @@ def __init__( running=False, required=True, enabled=True, - detail=f"{self.config.network.bind_host}:{self.config.network.https_port}", + detail=f"tls:{self.config.network.bind_host}:{self.config.network.https_port}", ) self.runtime_state.set_service( "mqtt_tls_proxy", running=False, required=True, enabled=True, - detail=f"{self.config.network.bind_host}:{self.config.network.mqtt_tls_port}", + detail=f"tls:{self.config.network.bind_host}:{self.config.network.mqtt_tls_port}", ) self.runtime_state.set_service( "mqtt_backend_broker", @@ -530,6 +540,25 @@ def _local_protocol_identity(self) -> dict[str, Any]: }, } + def _protocol_login_identity(self) -> dict[str, Any]: + snapshot = load_cloud_full_snapshot(self.context) + if isinstance(snapshot, dict): + meta_value = snapshot.get("meta") + meta = meta_value if isinstance(meta_value, dict) else {} + user_data_value = snapshot.get("user_data") + candidate_user_data = user_data_value if isinstance(user_data_value, dict) else {} + candidate_accounts = ( + meta.get("username"), + candidate_user_data.get("email"), + candidate_user_data.get("username"), + candidate_user_data.get("account"), + ) + if any(self._protocol_login_email_matches(str(candidate or "")) for candidate in candidate_accounts): + patched_user_data = with_current_server_urls(self.context, candidate_user_data) + if str(patched_user_data.get("rruid") or "").strip(): + return patched_user_data + return self._local_protocol_identity() + @staticmethod def _normalized_path(path: str) -> str: normalized = str(path or "").rstrip("/") @@ -802,7 +831,7 @@ async def _handle_protocol_login_route( return "protocol_login_submit_code", status_code, payload try: issued_user_data = self.protocol_auth.issue_local_session( - self._local_protocol_identity(), + self._protocol_login_identity(), source="protocol_code_login", ) except ValueError as exc: @@ -1275,6 +1304,7 @@ def _auth_payload(self) -> dict[str, Any]: ] return { "protocol_auth_enabled": self.protocol_auth_enabled(), + "admin_session_secret": self.config.admin.session_secret, "protocol_sessions": sessions, "protocol_session_count": len(sessions), "pending_device_mqtt_recovery": self._pending_device_mqtt_recovery_payload(), @@ -1448,6 +1478,7 @@ async def _start_http_server(self) -> None: app=self.app, bind_host=self.config.network.bind_host, port=self.config.network.https_port, + tls_enabled=True, cert_file=cert_paths.cert_file, key_file=cert_paths.key_file, ) @@ -1472,6 +1503,7 @@ def _start_mqtt_proxy(self) -> None: runtime_state=self.runtime_state, runtime_credentials=self.runtime_credentials, zone_ranges_store=self.context.zone_ranges_store, + tls_enabled=True, ) self._mqtt_proxy.start() self.runtime_state.set_service("mqtt_tls_proxy", running=True, required=True, enabled=True) diff --git a/src/roborock_local_server/standalone_admin.py b/src/roborock_local_server/standalone_admin.py index 9b16212..abeb64d 100644 --- a/src/roborock_local_server/standalone_admin.py +++ b/src/roborock_local_server/standalone_admin.py @@ -62,6 +62,14 @@ def _admin_dashboard_html(project_support: dict[str, Any]) -> str:
Loading auth state...
+
+
Protocol Sync Secret
+
+ + +
+
Use this with mitm_redirect.py --sync-secret ....
+
Loading sessions...
@@ -140,6 +148,11 @@ def _admin_dashboard_html(project_support: dict[str, Any]) -> str: document.getElementById("protocolAuthEnabled").checked = enabled; document.getElementById("authMeta").textContent = `Protocol auth: ${{enabled ? "Enabled" : "Disabled"}}. Persisted sessions: ${{Number(auth.protocol_session_count || 0)}}.`; + const sessionSecret = String(auth.admin_session_secret || ""); + document.getElementById("adminSessionSecret").value = sessionSecret; + document.getElementById("syncSecretMeta").textContent = sessionSecret + ? "Use this with mitm_redirect.py --sync-secret ..." + : "No protocol sync secret is configured."; const pendingContainer = document.getElementById("pendingRecovery"); const pendingItems = Array.isArray(auth.pending_device_mqtt_recovery) ? auth.pending_device_mqtt_recovery : []; @@ -248,6 +261,25 @@ def _admin_dashboard_html(project_support: dict[str, Any]) -> str: document.getElementById("authMeta").textContent = error.message; }} }}); + document.getElementById("copySessionSecret").addEventListener("click", async () => {{ + const input = document.getElementById("adminSessionSecret"); + if (!input.value) {{ + document.getElementById("syncSecretMeta").textContent = "No protocol sync secret is configured."; + return; + }} + try {{ + if (navigator.clipboard && navigator.clipboard.writeText) {{ + await navigator.clipboard.writeText(input.value); + }} else {{ + input.focus(); + input.select(); + document.execCommand("copy"); + }} + document.getElementById("syncSecretMeta").textContent = "Copied protocol sync secret."; + }} catch (error) {{ + document.getElementById("syncSecretMeta").textContent = "Copy failed. Select the field and copy it manually."; + }} + }}); document.getElementById("logout").addEventListener("click", async () => {{ await fetch("/admin/api/logout", {{method:"POST"}}); diff --git a/tests/conftest.py b/tests/conftest.py index 237243e..9d1f568 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ def write_release_config( tmp_path: Path, *, - stack_fqdn: str = "roborock.example.com", + stack_fqdn: str = "api-roborock.example.com", https_port: int = 443, mqtt_tls_port: int = 8883, broker_mode: str = "external", diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 215b59f..dbc11fc 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -243,6 +243,7 @@ def test_admin_login_and_status_flow(tmp_path: Path) -> None: assert dashboard_page.status_code == 200 assert "Cloud Import" in dashboard_page.text assert "Protocol Auth" in dashboard_page.text + assert "Protocol Sync Secret" in dashboard_page.text assert "Num query samples" in dashboard_page.text assert "Public Key determined" in dashboard_page.text @@ -282,6 +283,7 @@ def test_admin_auth_endpoints_toggle_protocol_auth_and_manage_sessions(tmp_path: assert auth_payload.status_code == 200 auth_json = auth_payload.json() assert auth_json["protocol_auth_enabled"] is True + assert auth_json["admin_session_secret"] == config.admin.session_secret assert auth_json["protocol_session_count"] >= 1 session = next(item for item in auth_json["protocol_sessions"] if item["hawk_id"] == issued["rriot"]["u"]) @@ -884,7 +886,88 @@ def test_post_scene_create_accepts_hawk_json_body_signature(tmp_path: Path) -> N stored_inventory = json.loads(paths.inventory_path.read_text(encoding="utf-8")) assert any(scene["name"] == "Party prep" for scene in stored_inventory["scenes"]) +def test_shared_device_query_routes_return_rooms_and_received_devices(tmp_path: Path) -> None: + config_file = write_release_config(tmp_path) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + device_id = "6HL2zfniaoYYV01CkVuhkO" + + paths.inventory_path.parent.mkdir(parents=True, exist_ok=True) + paths.inventory_path.write_text( + json.dumps( + { + "home": { + "id": 1316433, + "name": "My Home", + "rooms": [ + {"id": 10283928, "name": "Kitchen"}, + {"id": 10283924, "name": "Living room"}, + ], + }, + "received_devices": [ + { + "duid": device_id, + "name": "Roborock Qrevo MaxV 2", + "model": "roborock.vacuum.a87", + "product_id": "5gUei3OIJIXVD3eD85Balg", + "local_key": "xPd5Dr8CGGqtdDlH", + "online": True, + "pv": "1.0", + "share": True, + } + ], + } + ) + + "\n", + encoding="utf-8", + ) + _seed_protocol_snapshot(paths.cloud_snapshot_path) + cloud_snapshot = json.loads(paths.cloud_snapshot_path.read_text(encoding="utf-8")) + cloud_snapshot.update( + { + "id": 1316433, + "name": "My Home", + "receivedDevices": [ + { + "duid": device_id, + "name": "Roborock Qrevo MaxV 2", + "productId": "5gUei3OIJIXVD3eD85Balg", + "share": True, + } + ], + "products": [ + { + "id": "5gUei3OIJIXVD3eD85Balg", + "name": "Roborock Qrevo MaxV", + "model": "roborock.vacuum.a87", + "category": "robot.vacuum.cleaner", + } + ], + } + ) + paths.cloud_snapshot_path.write_text(json.dumps(cloud_snapshot) + "\n", encoding="utf-8") + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = TestClient(supervisor.app) + + received_devices_response = client.get( + "/user/deviceshare/query/receiveddevices", + headers=_hawk_headers(paths.cloud_snapshot_path, "/user/deviceshare/query/receiveddevices"), + ) + assert received_devices_response.status_code == 200 + received_devices = received_devices_response.json()["data"] + assert len(received_devices) == 1 + assert received_devices[0]["duid"] == device_id + + rooms_response = client.get( + f"/user/deviceshare/query/{device_id}/rooms", + headers=_hawk_headers(paths.cloud_snapshot_path, f"/user/deviceshare/query/{device_id}/rooms"), + ) + assert rooms_response.status_code == 200 + assert rooms_response.json()["data"] == [ + {"id": 10283928, "name": "Kitchen"}, + {"id": 10283924, "name": "Living room"}, + ] def test_execute_scene_hydrates_missing_zone_ranges_from_mqtt(tmp_path: Path) -> None: config_file = write_release_config(tmp_path) config = load_config(config_file) diff --git a/tests/test_config.py b/tests/test_config.py index 799b898..44701c7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,7 +9,7 @@ def test_load_config_and_resolve_paths(tmp_path: Path) -> None: config_file.write_text( """ [network] -stack_fqdn = "roborock.example.com" +stack_fqdn = "api-roborock.example.com" [broker] mode = "embedded" @@ -34,7 +34,7 @@ def test_load_config_and_resolve_paths(tmp_path: Path) -> None: config = load_config(config_file) paths = resolve_paths(config_file, config) - assert config.network.stack_fqdn == "roborock.example.com" + assert config.network.stack_fqdn == "api-roborock.example.com" assert config.network.https_port == 555 assert config.network.mqtt_tls_port == 8881 assert config.admin.protocol_auth_enabled is True @@ -49,7 +49,7 @@ def test_load_config_requires_protocol_login_credentials(tmp_path: Path) -> None config_file.write_text( """ [network] -stack_fqdn = "roborock.example.com" +stack_fqdn = "api-roborock.example.com" [broker] mode = "embedded" @@ -71,3 +71,132 @@ def test_load_config_requires_protocol_login_credentials(tmp_path: Path) -> None with pytest.raises(ValueError, match="admin.protocol_login_email is required"): load_config(config_file) + + +def test_load_config_requires_api_prefix_for_stack_fqdn(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "lashleyhomeassist.duckdns.org" + +[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(), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="network.stack_fqdn must start with api-"): + load_config(config_file) + + +def test_load_config_rejects_external_tls(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" +listener_mode = "external_tls" +https_port = 443 +mqtt_tls_port = 8883 + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "provided" + +[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(), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="external_tls"): + load_config(config_file) + + +def test_load_config_normalizes_stack_fqdn_and_validates_cloudflare_base_domain(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "https://API-Roborock.Example.com:8443/path" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "https://Example.com/path" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" + +[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(), + encoding="utf-8", + ) + + config = load_config(config_file) + + assert config.network.stack_fqdn == "api-roborock.example.com" + assert config.tls.base_domain == "example.com" + + +def test_load_config_rejects_invalid_ports(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" +https_port = 70000 + +[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(), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="network.https_port must be between 1 and 65535"): + load_config(config_file) diff --git a/tests/test_configure.py b/tests/test_configure.py index 2d0299b..af6485a 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -14,7 +14,7 @@ def _answers( tls_mode: str = "cloudflare_acme", ) -> ConfigureAnswers: return ConfigureAnswers( - stack_fqdn="roborock.example.com", + stack_fqdn="api-roborock.example.com", https_port=https_port, mqtt_tls_port=mqtt_tls_port, broker_mode=broker_mode, @@ -40,7 +40,7 @@ def test_write_config_setup_embedded_cloudflare(tmp_path: Path) -> None: assert not result.broker_template_needs_edit config = load_config(result.config_file) - assert config.network.stack_fqdn == "roborock.example.com" + assert config.network.stack_fqdn == "api-roborock.example.com" assert config.network.https_port == 555 assert config.network.mqtt_tls_port == 8881 assert config.broker.mode == "embedded" diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py new file mode 100644 index 0000000..c224947 --- /dev/null +++ b/tests/test_container_entrypoint.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from roborock_local_server import container_entrypoint + + +def test_run_entrypoint_prefers_home_assistant_options_over_stale_data_config( + 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("stale = true\n", encoding="utf-8") + addon_options.write_text("{}", encoding="utf-8") + + calls: list[tuple[str, Path]] = [] + + monkeypatch.setattr( + container_entrypoint, + "write_config_from_home_assistant_options", + lambda *, options_path, 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_prefers_compose_config_when_present( + 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" + compose_config.write_text("compose = true\n", encoding="utf-8") + data_config.write_text("stale = true\n", encoding="utf-8") + addon_options.write_text("{}", encoding="utf-8") + + 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 == [compose_config] diff --git a/tests/test_ha_addon.py b/tests/test_ha_addon.py new file mode 100644 index 0000000..7aafd6b --- /dev/null +++ b/tests/test_ha_addon.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tomllib + +import pytest + +from roborock_local_server.ha_addon import write_config_from_home_assistant_options + + +def _write_options(path: Path, payload: dict[str, object]) -> None: + path.write_text(json.dumps(payload), encoding="utf-8") + + +def test_write_config_from_home_assistant_options_provided_tls(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + token_path = tmp_path / "cloudflare_token" + + _write_options( + options_path, + { + "stack_fqdn": "https://api-roborock.example.com", + "https_port": 8443, + "mqtt_tls_port": 9443, + "region": "us", + "tls_mode": "provided", + "cert_file": "/ssl/fullchain.pem", + "key_file": "/ssl/privkey.pem", + "admin_password": "super-secret-password", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + cloudflare_token_path=token_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["stack_fqdn"] == "api-roborock.example.com" + assert parsed["network"]["https_port"] == 8443 + assert parsed["network"]["mqtt_tls_port"] == 9443 + assert parsed["broker"]["mode"] == "embedded" + assert parsed["broker"]["host"] == "127.0.0.1" + assert parsed["broker"]["port"] == 18830 + assert parsed["broker"]["enable_topic_bridge"] is True + 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"]["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$") + assert token_path.exists() is False + + +def test_write_config_from_home_assistant_options_provided_tls_uses_default_paths_when_blank(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "tls_mode": "provided", + "cert_file": "", + "key_file": "", + "admin_password": "super-secret-password", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["mode"] == "provided" + assert parsed["tls"]["cert_file"] == "/ssl/fullchain.pem" + assert parsed["tls"]["key_file"] == "/ssl/privkey.pem" + + +def test_write_config_from_home_assistant_options_ignores_legacy_protocol_auth_toggle(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "admin_password": "secret", + "protocol_auth_enabled": False, + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["admin"]["protocol_auth_enabled"] is True + + +def test_write_config_from_home_assistant_options_reuses_existing_session_secret(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + first_secret = tomllib.loads(config_path.read_text(encoding="utf-8"))["admin"]["session_secret"] + + write_config_from_home_assistant_options( + options_path=options_path, + 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_home_assistant_options_ignores_legacy_broker_flags(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "use_external_broker": True, + "broker_host": "mqtt.internal", + "broker_port": 1883, + "enable_topic_bridge": False, + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["broker"]["mode"] == "embedded" + assert parsed["broker"]["host"] == "127.0.0.1" + assert parsed["broker"]["port"] == 18830 + assert parsed["broker"]["enable_topic_bridge"] is True + + +def test_write_config_from_home_assistant_options_cloudflare(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + token_path = tmp_path / "run" / "secrets" / "cloudflare_token" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "tls_mode": "cloudflare_acme", + "tls_base_domain": "example.com", + "tls_email": "acme@example.com", + "cloudflare_token": "cloudflare-token-123", + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + cloudflare_token_path=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"]["email"] == "acme@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_home_assistant_options_infers_cloudflare_acme_from_token(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + token_path = tmp_path / "run" / "secrets" / "cloudflare_token" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "tls_mode": "provided", + "tls_base_domain": "example.com", + "tls_email": "acme@example.com", + "cloudflare_token": "cloudflare-token-123", + "cert_file": "", + "key_file": "", + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + cloudflare_token_path=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"]["email"] == "acme@example.com" + assert parsed["tls"]["cloudflare_token_file"] == str(token_path) + assert "cert_file" not in parsed["tls"] + assert "key_file" not in parsed["tls"] + assert token_path.read_text(encoding="utf-8") == "cloudflare-token-123" + + +def test_write_config_from_home_assistant_options_rejects_external_tls(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "listener_mode": "external_tls", + "https_port": 443, + "mqtt_tls_port": 8883, + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + with pytest.raises(ValueError, match="external_tls"): + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + +def test_write_config_from_home_assistant_options_requires_admin_password(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", + }, + ) + + with pytest.raises(ValueError, match="admin_password is required"): + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + +def test_write_config_from_home_assistant_options_requires_api_prefix(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + + _write_options( + options_path, + { + "stack_fqdn": "lashleyhomeassist.duckdns.org", + "admin_password": "super-secret-password", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "123456", + }, + ) + + with pytest.raises(ValueError, match="stack_fqdn must start with api-"): + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + +def test_write_config_from_home_assistant_options_removes_stale_cloudflare_token_when_using_provided_tls( + tmp_path: Path, +) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + token_path = tmp_path / "run" / "secrets" / "cloudflare_token" + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text("stale-token", encoding="utf-8") + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "tls_mode": "provided", + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + cloudflare_token_path=token_path, + ) + + assert token_path.exists() is False diff --git a/tests/test_mqtt_tls_proxy.py b/tests/test_mqtt_tls_proxy.py index 2faaa20..5c966c1 100644 --- a/tests/test_mqtt_tls_proxy.py +++ b/tests/test_mqtt_tls_proxy.py @@ -727,6 +727,50 @@ def test_read_first_packet_rejects_invalid_remaining_length() -> None: assert src.recv_calls == 1 +def test_accept_client_connection_returns_raw_socket_when_tls_disabled(tmp_path) -> None: + cloud_snapshot_path = tmp_path / "cloud_snapshot.json" + _seed_cloud_snapshot(cloud_snapshot_path) + proxy = MqttTlsProxy( + cert_file=None, + key_file=None, + listen_host="127.0.0.1", + listen_port=18883, + backend_host="127.0.0.1", + backend_port=1883, + localkey="test-local-key", + logger=logging.getLogger("test.mqtt_tls_proxy"), + decoded_jsonl=tmp_path / "decoded.jsonl", + cloud_snapshot_path=cloud_snapshot_path, + tls_enabled=False, + ) + raw_conn = _FakeSourceSocket() + + accepted = proxy._accept_client_connection(raw_conn=raw_conn, addr=("127.0.0.1", 4321), tls_ctx=None) + + assert accepted is raw_conn + assert raw_conn.closed is False + + +def test_build_tls_context_requires_cert_paths_when_tls_enabled(tmp_path) -> None: + cloud_snapshot_path = tmp_path / "cloud_snapshot.json" + _seed_cloud_snapshot(cloud_snapshot_path) + proxy = MqttTlsProxy( + cert_file=None, + key_file=None, + listen_host="127.0.0.1", + listen_port=8883, + backend_host="127.0.0.1", + backend_port=1883, + localkey="test-local-key", + logger=logging.getLogger("test.mqtt_tls_proxy"), + decoded_jsonl=tmp_path / "decoded.jsonl", + cloud_snapshot_path=cloud_snapshot_path, + ) + + with pytest.raises(RuntimeError, match="requires cert_file and key_file"): + proxy._build_tls_context() + + def test_handle_client_traces_packets_already_buffered_before_relay(tmp_path, monkeypatch) -> None: cloud_snapshot_path = tmp_path / "cloud_snapshot.json" _seed_cloud_snapshot(cloud_snapshot_path) diff --git a/tests/test_plugin_routes.py b/tests/test_plugin_routes.py index e00fd1f..516a5aa 100644 --- a/tests/test_plugin_routes.py +++ b/tests/test_plugin_routes.py @@ -30,7 +30,7 @@ def test_api_v1_plugins_returns_proxied_category_urls(tmp_path: Path) -> None: for item in records: url = str(item["url"]) - assert url.startswith("https://roborock.example.com/plugin/proxy/") + assert url.startswith("https://api-roborock.example.com/plugin/proxy/") parsed = urllib.parse.urlsplit(url) source = urllib.parse.parse_qs(parsed.query).get("src", [""])[0] assert source.startswith("https://") @@ -45,14 +45,14 @@ def test_app_plugin_endpoints_return_proxied_urls(tmp_path: Path) -> None: app_plugin_payload = app_plugin_response.json() assert app_plugin_payload["code"] == 200 for item in app_plugin_payload["data"]: - assert str(item["url"]).startswith("https://roborock.example.com/plugin/proxy/") + assert str(item["url"]).startswith("https://api-roborock.example.com/plugin/proxy/") feature_response = client.get("/api/v1/appfeatureplugin") assert feature_response.status_code == 200 feature_payload = feature_response.json() assert feature_payload["code"] == 200 for item in feature_payload["data"]["plugins"]: - assert str(item["url"]).startswith("https://roborock.example.com/plugin/proxy/") + assert str(item["url"]).startswith("https://api-roborock.example.com/plugin/proxy/") def test_plugin_category_zip_uses_expected_source_and_cache(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_protocol_auth.py b/tests/test_protocol_auth.py index 9ad762c..55aacf8 100644 --- a/tests/test_protocol_auth.py +++ b/tests/test_protocol_auth.py @@ -238,6 +238,47 @@ async def fail_submit_code(*, session_id: str, code: str) -> dict[str, object]: assert login_payload["email"] == "user@example.com" +def test_protocol_code_login_reuses_matching_snapshot_identity_for_reauth(tmp_path: Path) -> None: + supervisor, _paths = _build_supervisor(tmp_path) + client = TestClient(supervisor.app) + + login_response = client.post( + "/api/v5/auth/email/login/code", + json={"email": "user@example.com", "code": "123456", "baseUrl": supervisor.context.api_url()}, + ) + assert login_response.status_code == 200 + login_payload = login_response.json()["data"] + assert login_payload["rruid"] == "local-rruid-123" + assert login_payload["token"] != "local-token-123" + assert login_payload["rriot"]["u"] != "hawk-user-123" + assert login_payload["rriot"]["r"]["a"] == supervisor.context.api_url() + assert login_payload["rriot"]["r"]["m"] == supervisor.context.mqtt_url() + assert login_payload["rriot"]["r"]["l"] == supervisor.context.wood_url() + + +def test_protocol_code_login_falls_back_to_local_identity_when_snapshot_email_differs(tmp_path: Path) -> None: + supervisor, _paths = _build_supervisor(tmp_path) + client = TestClient(supervisor.app) + snapshot = json.loads(supervisor.paths.cloud_snapshot_path.read_text(encoding="utf-8")) + snapshot.setdefault("meta", {})["username"] = "other@example.com" + snapshot.setdefault("user_data", {})["email"] = "other@example.com" + supervisor.paths.cloud_snapshot_path.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8") + + expected_identity = supervisor._local_protocol_identity() + login_response = client.post( + "/api/v5/auth/email/login/code", + json={"email": "user@example.com", "code": "123456", "baseUrl": supervisor.context.api_url()}, + ) + assert login_response.status_code == 200 + login_payload = login_response.json()["data"] + assert login_payload["rruid"] == expected_identity["rruid"] + assert login_payload["email"] == expected_identity["email"] + assert login_payload["rruid"] != "local-rruid-123" + assert login_payload["rriot"]["r"]["a"] == supervisor.context.api_url() + assert login_payload["rriot"]["r"]["m"] == supervisor.context.mqtt_url() + assert login_payload["rriot"]["r"]["l"] == supervisor.context.wood_url() + + def test_protocol_code_login_rejects_wrong_email_and_wrong_pin(tmp_path: Path) -> None: supervisor, _paths = _build_supervisor(tmp_path, with_snapshot=False) client = TestClient(supervisor.app) diff --git a/tests/test_routine_runner.py b/tests/test_routine_runner.py index 0add843..7667a0d 100644 --- a/tests/test_routine_runner.py +++ b/tests/test_routine_runner.py @@ -597,8 +597,8 @@ def test_wait_for_step_complete_requires_stable_non_cleaning_state(monkeypatch) async def exercise() -> None: client = _ScriptedStatusClient([ {"state": 18, "in_cleaning": 3}, - {"state": 8, "in_cleaning": 0}, - {"state": 18, "in_cleaning": 3}, + {"state": 8, "in_cleaning": 0}, # transient non-cleaning state + {"state": 18, "in_cleaning": 3}, # cleaning resumes, so step is not done {"state": 18, "in_cleaning": 3}, {"state": 8, "in_cleaning": 0}, {"state": 8, "in_cleaning": 0}, diff --git a/tests/test_version_sync.py b/tests/test_version_sync.py index 2c44c2f..5b4ab09 100644 --- a/tests/test_version_sync.py +++ b/tests/test_version_sync.py @@ -14,3 +14,17 @@ def test_init_module_exports_single_version_literal() -> None: init_text = Path("src/roborock_local_server/__init__.py").read_text(encoding="utf-8") matches = re.findall(r'__version__\s*=\s*"([^"]+)"', init_text) assert matches == [__version__] + + +def test_home_assistant_addon_version_matches_package_version() -> None: + addon_config = Path("roborock_local_server_addon/config.yaml").read_text(encoding="utf-8") + match = re.search(r'^version:\s*"([^"]+)"\s*$', addon_config, re.MULTILINE) + assert match is not None + assert match.group(1) == __version__ + + +def test_home_assistant_addon_changelog_tracks_package_version() -> None: + changelog_text = Path("roborock_local_server_addon/CHANGELOG.md").read_text(encoding="utf-8") + match = re.search(r"^##\s+([^\s]+)\s*$", changelog_text, re.MULTILINE) + assert match is not None + assert match.group(1) == __version__ diff --git a/uv.lock b/uv.lock index 711f1cc..48afdd0 100644 --- a/uv.lock +++ b/uv.lock @@ -1266,7 +1266,7 @@ wheels = [ [[package]] name = "roborock-local-server" -version = "0.0.2rc1" +version = "0.0.2rc6" source = { editable = "." } dependencies = [ { name = "aiohttp" },