From 30f7f70b3718382d7489acf7e9d6227fbf04f1ce Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 11 Jun 2026 21:30:41 -0400 Subject: [PATCH] Add basic reverse proxy support --- config.example.toml | 4 ++ docs/home_assistant.md | 2 +- docs/index.md | 1 + docs/installation.md | 4 ++ docs/reverse_proxy.md | 48 ++++++++++++++++++++ mkdocs.yml | 1 + roborock_local_server_addon/DOCS.md | 1 + roborock_local_server_addon/config.yaml | 4 ++ src/roborock_local_server/config.py | 19 +++++++- src/roborock_local_server/ha_addon.py | 20 +++++++++ src/roborock_local_server/server.py | 12 ++--- tests/test_config.py | 41 +++++++++++++++++ tests/test_custom_ports.py | 60 +++++++++++++++++++++++++ tests/test_ha_addon.py | 35 +++++++++++++++ 14 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 docs/reverse_proxy.md diff --git a/config.example.toml b/config.example.toml index 3216359..7f98f96 100644 --- a/config.example.toml +++ b/config.example.toml @@ -5,6 +5,10 @@ bind_host = "0.0.0.0" # Change these if you need custom published ports. https_port = 555 mqtt_tls_port = 8881 +# Optional reverse-proxy support. Leave unset to advertise the listener ports above. +# Set these when a proxy maps public HTTPS/MQTT ports to different backend ports. +# advertised_https_port = 443 +# advertised_mqtt_tls_port = 8883 region = "us" [broker] diff --git a/docs/home_assistant.md b/docs/home_assistant.md index 97537ee..aaa5d0f 100644 --- a/docs/home_assistant.md +++ b/docs/home_assistant.md @@ -64,7 +64,7 @@ If you need the MITM protocol sync secret for the Roborock app flow, sign in to - 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/...`. +- 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/...`. Nginx Proxy Manager is mainly useful here as a certificate source or admin/API HTTPS convenience; it does not remove the need for a reachable MQTT/TLS port. See [Reverse Proxy](reverse_proxy.md). - Installing the add-on does **not** automatically rewrite Home Assistant's Roborock integration entry. ## Repoint The Home Assistant Roborock Integration diff --git a/docs/index.md b/docs/index.md index 308a23b..78325c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,6 +23,7 @@ If you want to support this project, next time you buy a Roborock, use one of my - [Using the Roborock App](roborock_app.md) - [Updating](updating.md) - [Tested vacuums](tested_vacuums.md) +- [Reverse proxy](reverse_proxy.md) - [Custom MQTT](custom_mqtt.md) - [Custom certificate management](custom_cert_management.md) - [Technical Writeup](technical_writeup.md) diff --git a/docs/installation.md b/docs/installation.md index d89d045..707ecea 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -66,6 +66,8 @@ If your model already has certificate notes on the tested-vacuums page, follow t 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. + If a reverse proxy maps public ports to different backend listener ports, see [Reverse Proxy](reverse_proxy.md) before starting the stack. + ## Method 1: Docker Compose ### Additional Requirements @@ -134,6 +136,8 @@ If your model already has certificate notes on the tested-vacuums page, follow t docker compose up -d --build ``` + 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. + ## 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. diff --git a/docs/reverse_proxy.md b/docs/reverse_proxy.md new file mode 100644 index 0000000..392fcbc --- /dev/null +++ b/docs/reverse_proxy.md @@ -0,0 +1,48 @@ +# Reverse Proxy + +Reverse proxy support is mainly useful when public or LAN clients reach the stack on different ports than the backend listeners. The server still runs its own TLS listeners for both HTTPS and MQTT/TLS; the proxy forwards traffic to those listeners. I do not use a reverse proxy for my own setup, so please report any issues. + +## Supported Layout + +Use this when: + +- HTTPS reaches the proxy on `443`, then forwards to the stack HTTPS listener such as `555` +- MQTT/TLS reaches a TCP/stream proxy on `8883`, then forwards to the stack MQTT/TLS listener such as `8881` +- the proxy preserves the original `Host` header + +Example: + +```toml +[network] +stack_fqdn = "api-roborock.example.com" +bind_host = "0.0.0.0" + +# Backend listener ports. +https_port = 555 +mqtt_tls_port = 8881 + +# Public ports advertised to the Roborock app, vacuums, and Home Assistant. +advertised_https_port = 443 +advertised_mqtt_tls_port = 8883 +``` + +With that config the server listens on `https://*:555` and `ssl://*:8881`, but responses advertise: + +- `https://api-roborock.example.com` +- `ssl://api-roborock.example.com:8883` + +## Proxy Requirements + +For HTTPS admin/API traffic, the proxy must forward the original `Host` header unchanged: + +```text +Host: $host +``` + +For MQTT/TLS, use TCP or stream proxying. A normal HTTP reverse proxy location is not enough because MQTT is not HTTP. The proxy must forward raw TCP from the public MQTT/TLS port to `mqtt_tls_port`. + +## What Is Not Supported + +Path-prefix hosting is not supported. The Roborock protocol and the admin API expect the stack at the hostname root, for example `/region`, `/api/...`, and `/admin`. + +Plain HTTP backends are not supported. If you already manage certificates in a proxy, point `tls.cert_file` and `tls.key_file` at those certificate files so the backend TLS listener uses the same certificate chain. diff --git a/mkdocs.yml b/mkdocs.yml index 2a9b2f3..6f003a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - Reference: - Known Limitations: known_limitations.md - Tested Vacuums: tested_vacuums.md + - Reverse Proxy: reverse_proxy.md - Custom MQTT: custom_mqtt.md - Custom Certificate Management: custom_cert_management.md - Technical Writeup: technical_writeup.md diff --git a/roborock_local_server_addon/DOCS.md b/roborock_local_server_addon/DOCS.md index 458056e..caecbec 100644 --- a/roborock_local_server_addon/DOCS.md +++ b/roborock_local_server_addon/DOCS.md @@ -40,3 +40,4 @@ Use **Reconfigure** on the Roborock integration after Home Assistant has loaded - 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`. +- If a reverse proxy exposes different public ports than the add-on listeners, keep `https_port`/`mqtt_tls_port` as the add-on listener ports and set `advertised_https_port`/`advertised_mqtt_tls_port` to the public ports. The proxy must preserve the original `Host` header, and MQTT/TLS still needs a reachable port or a TCP/stream proxy. diff --git a/roborock_local_server_addon/config.yaml b/roborock_local_server_addon/config.yaml index 436c6a7..85a6fed 100644 --- a/roborock_local_server_addon/config.yaml +++ b/roborock_local_server_addon/config.yaml @@ -25,6 +25,8 @@ options: stack_fqdn: "api-roborock.example.com" https_port: 555 mqtt_tls_port: 8881 + advertised_https_port: 0 + advertised_mqtt_tls_port: 0 region: "us" tls_mode: "provided" tls_base_domain: "" @@ -42,6 +44,8 @@ schema: stack_fqdn: str https_port: port mqtt_tls_port: port + advertised_https_port: int + advertised_mqtt_tls_port: int region: list(us|eu|cn|ru) tls_mode: list(provided|cloudflare_acme) tls_base_domain: str diff --git a/src/roborock_local_server/config.py b/src/roborock_local_server/config.py index 7206b2d..40f5db8 100644 --- a/src/roborock_local_server/config.py +++ b/src/roborock_local_server/config.py @@ -15,6 +15,8 @@ class NetworkConfig: bind_host: str https_port: int mqtt_tls_port: int + advertised_https_port: int + advertised_mqtt_tls_port: int region: str localkey: str duid: str @@ -201,12 +203,25 @@ def load_config(path: str | Path) -> AppConfig: broker_host = "127.0.0.1" broker_port_default = 18830 if broker_mode == "embedded" else 1883 + 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"), bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0", - 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), + 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(), diff --git a/src/roborock_local_server/ha_addon.py b/src/roborock_local_server/ha_addon.py index 6089dd4..80408dc 100644 --- a/src/roborock_local_server/ha_addon.py +++ b/src/roborock_local_server/ha_addon.py @@ -17,6 +17,8 @@ "stack_fqdn": "", "https_port": 555, "mqtt_tls_port": 8881, + "advertised_https_port": 0, + "advertised_mqtt_tls_port": 0, "region": "us", "tls_mode": "provided", "tls_base_domain": "", @@ -85,6 +87,12 @@ def _as_int(value: object, *, field_name: str, default: int) -> int: return candidate +def _as_optional_port(value: object, *, field_name: str) -> int: + if value in (None, "", 0, "0"): + return 0 + return _as_int(value, field_name=field_name, default=0) + + def _require_non_empty(value: object, *, field_name: str) -> str: text = str(value or "").strip() if not text: @@ -158,6 +166,16 @@ def _render_config_toml( 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) + advertised_https_port = _as_optional_port( + merged.get("advertised_https_port"), + field_name="advertised_https_port", + ) + advertised_mqtt_tls_port = _as_optional_port( + merged.get("advertised_mqtt_tls_port"), + field_name="advertised_mqtt_tls_port", + ) + advertised_https_port = advertised_https_port or https_port + advertised_mqtt_tls_port = advertised_mqtt_tls_port or mqtt_tls_port # Legacy HA options for broker selection are ignored now that the add-on # always runs the embedded broker with the topic bridge enabled. @@ -218,6 +236,8 @@ def _render_config_toml( 'bind_host = "0.0.0.0"', 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)}", "", "[broker]", diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py index 6caf4a8..4a9d355 100644 --- a/src/roborock_local_server/server.py +++ b/src/roborock_local_server/server.py @@ -395,8 +395,8 @@ def __init__( mqtt_usr=self._bootstrap_credentials["mqtt_usr"], mqtt_passwd=self._bootstrap_credentials["mqtt_passwd"], mqtt_clientid=self._bootstrap_credentials["mqtt_clientid"], - https_port=self.config.network.https_port, - mqtt_tls_port=self.config.network.mqtt_tls_port, + https_port=self.config.network.advertised_https_port, + mqtt_tls_port=self.config.network.advertised_mqtt_tls_port, mqtt_backend_port=self.config.broker.port, ) self.runtime_credentials.sync_inventory() @@ -417,8 +417,8 @@ def __init__( mqtt_usr=self._bootstrap_credentials["mqtt_usr"], mqtt_passwd=self._bootstrap_credentials["mqtt_passwd"], mqtt_clientid=self._bootstrap_credentials["mqtt_clientid"], - https_port=self.config.network.https_port, - mqtt_tls_port=self.config.network.mqtt_tls_port, + https_port=self.config.network.advertised_https_port, + mqtt_tls_port=self.config.network.advertised_mqtt_tls_port, http_jsonl=self.paths.http_jsonl_path, mqtt_jsonl=self.paths.mqtt_jsonl_path, loggers=self.loggers, @@ -1785,8 +1785,8 @@ def repair_runtime_identities(*, config_file: Path, links: list[str]) -> int: mqtt_usr=str(runtime_credentials.bootstrap_value("mqtt_usr", "") or ""), mqtt_passwd=str(runtime_credentials.bootstrap_value("mqtt_passwd", "") or ""), mqtt_clientid=str(runtime_credentials.bootstrap_value("mqtt_clientid", "") or ""), - https_port=config.network.https_port, - mqtt_tls_port=config.network.mqtt_tls_port, + https_port=config.network.advertised_https_port, + mqtt_tls_port=config.network.advertised_mqtt_tls_port, http_jsonl=paths.http_jsonl_path, mqtt_jsonl=paths.mqtt_jsonl_path, loggers={}, diff --git a/tests/test_config.py b/tests/test_config.py index 28c5bae..6d1fa4b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -37,6 +37,8 @@ def test_load_config_and_resolve_paths(tmp_path: Path) -> None: assert config.network.stack_fqdn == "api-roborock.example.com" assert config.network.https_port == 555 assert config.network.mqtt_tls_port == 8881 + assert config.network.advertised_https_port == 555 + assert config.network.advertised_mqtt_tls_port == 8881 assert config.admin.protocol_auth_enabled is True assert config.admin.new_connections_enabled is True assert config.admin.protocol_login_email == "user@example.com" @@ -346,3 +348,42 @@ def test_load_config_rejects_invalid_ports(tmp_path: Path) -> None: with pytest.raises(ValueError, match="network.https_port must be between 1 and 65535"): load_config(config_file) + + +def test_load_config_accepts_reverse_proxy_network_settings(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" +https_port = 555 +mqtt_tls_port = 8881 +advertised_https_port = 443 +advertised_mqtt_tls_port = 8883 + +[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", + ) + + config = load_config(config_file) + + assert config.network.https_port == 555 + assert config.network.mqtt_tls_port == 8881 + assert config.network.advertised_https_port == 443 + assert config.network.advertised_mqtt_tls_port == 8883 diff --git a/tests/test_custom_ports.py b/tests/test_custom_ports.py index 1eab546..53fce4c 100644 --- a/tests/test_custom_ports.py +++ b/tests/test_custom_ports.py @@ -57,3 +57,63 @@ def test_server_advertises_custom_https_and_mqtt_ports(tmp_path) -> None: assert supervisor.context.api_url() == "https://api-roborock.example.com:8443" assert supervisor.context.mqtt_url() == "ssl://api-roborock.example.com:9443" assert supervisor.context.wood_url() == "https://api-roborock.example.com:8443" + + +def test_server_advertises_reverse_proxy_public_ports(tmp_path) -> None: + config_file = tmp_path / "config.toml" + cert_dir = tmp_path / "certs" + cert_dir.mkdir(parents=True, exist_ok=True) + (cert_dir / "fullchain.pem").write_text("test-cert\n", encoding="utf-8") + (cert_dir / "privkey.pem").write_text("test-key\n", encoding="utf-8") + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" +https_port = 555 +mqtt_tls_port = 8881 +advertised_https_port = 443 +advertised_mqtt_tls_port = 8883 + +[broker] +mode = "external" +host = "127.0.0.1" +port = 1883 +enable_topic_bridge = false + +[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" +session_ttl_seconds = 3600 +protocol_auth_enabled = true +new_connections_enabled = true +protocol_login_email = "user@example.com" +protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl" + """.strip(), + encoding="utf-8", + ) + config = load_config(config_file) + paths = resolve_paths(config_file, config) + _write_json(paths.inventory_path, {"home": {"id": 12345, "name": "Test Home"}, "devices": []}) + + supervisor = ReleaseSupervisor(config=config, paths=paths) + client = TestClient(supervisor.app) + + region_response = client.get( + "/region", + headers={"host": "api-roborock.example.com"}, + ) + + assert region_response.status_code == 200 + region_payload = region_response.json()["data"] + assert region_payload["apiUrl"] == "https://api-roborock.example.com" + assert region_payload["mqttUrl"] == "ssl://api-roborock.example.com:8883" + assert supervisor.context.api_url() == "https://api-roborock.example.com" + assert supervisor.context.mqtt_url() == "ssl://api-roborock.example.com:8883" diff --git a/tests/test_ha_addon.py b/tests/test_ha_addon.py index 9c7b4d8..c442a09 100644 --- a/tests/test_ha_addon.py +++ b/tests/test_ha_addon.py @@ -44,6 +44,8 @@ def test_write_config_from_home_assistant_options_provided_tls(tmp_path: Path) - assert parsed["network"]["stack_fqdn"] == "api-roborock.example.com" assert parsed["network"]["https_port"] == 8443 assert parsed["network"]["mqtt_tls_port"] == 9443 + assert parsed["network"]["advertised_https_port"] == 8443 + assert parsed["network"]["advertised_mqtt_tls_port"] == 9443 assert parsed["broker"]["mode"] == "embedded" assert parsed["broker"]["host"] == "127.0.0.1" assert parsed["broker"]["port"] == 18830 @@ -60,6 +62,39 @@ def test_write_config_from_home_assistant_options_provided_tls(tmp_path: Path) - assert token_path.exists() is False +def test_write_config_from_home_assistant_options_reverse_proxy_settings(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", + "https_port": 555, + "mqtt_tls_port": 8881, + "advertised_https_port": 443, + "advertised_mqtt_tls_port": 8883, + "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, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["https_port"] == 555 + assert parsed["network"]["mqtt_tls_port"] == 8881 + assert parsed["network"]["advertised_https_port"] == 443 + assert parsed["network"]["advertised_mqtt_tls_port"] == 8883 + + def test_write_config_from_home_assistant_options_provided_tls_requires_paths_when_blank(tmp_path: Path) -> None: options_path = tmp_path / "options.json" config_path = tmp_path / "config.toml"