Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion docs/home_assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 4 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions docs/reverse_proxy.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions roborock_local_server_addon/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions roborock_local_server_addon/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand All @@ -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
Expand Down
19 changes: 17 additions & 2 deletions src/roborock_local_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
20 changes: 20 additions & 0 deletions src/roborock_local_server/ha_addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]",
Expand Down
12 changes: 6 additions & 6 deletions src/roborock_local_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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={},
Expand Down
41 changes: 41 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
60 changes: 60 additions & 0 deletions tests/test_custom_ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading