Skip to content

Commit b9362d8

Browse files
committed
add external tls support
1 parent f4aca8d commit b9362d8

11 files changed

Lines changed: 295 additions & 77 deletions

File tree

‎compose.yaml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ services:
1313
- ./data:/data
1414
- ./secrets:/run/secrets:ro
1515
healthcheck:
16-
test: ["CMD", "curl", "-skf", "https://127.0.0.1:${ROBOROCK_SERVER_HTTPS_PORT:-555}/admin"]
16+
# Defaults to https for local_tls. Set ROBOROCK_SERVER_HEALTHCHECK_SCHEME=http
17+
# when listener_mode = "external_tls" (the server speaks plain HTTP behind the proxy).
18+
test: ["CMD", "curl", "-skf", "${ROBOROCK_SERVER_HEALTHCHECK_SCHEME:-https}://127.0.0.1:${ROBOROCK_SERVER_HTTPS_PORT:-555}/admin"]
1719
interval: 30s
1820
timeout: 5s
1921
retries: 5

‎config.example.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ mqtt_tls_port = 8881
99
# Set these when a proxy maps public HTTPS/MQTT ports to different backend ports.
1010
# advertised_https_port = 443
1111
# advertised_mqtt_tls_port = 8883
12+
# listener_mode = "local_tls" terminates TLS in the server (default).
13+
# Set "external_tls" only when a reverse proxy terminates TLS and forwards plain
14+
# traffic to the server; the proxy must present a valid cert to clients. See docs/reverse_proxy.md.
15+
# listener_mode = "local_tls"
1216
region = "us"
1317

1418
[broker]

‎docs/reverse_proxy.md‎

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
# Reverse Proxy
22

3-
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.
3+
Reverse proxy support is mainly useful when public or LAN clients reach the stack on different ports than the backend listeners, or when you already run a proxy (Caddy, Traefik, nginx) that owns your TLS certificates. I do not use a reverse proxy for my own setup, so please report any issues.
44

5-
## Supported Layout
5+
Whatever endpoint a vacuum or the Roborock app connects to **must present a valid, trusted TLS certificate** — vacuums refuse to connect otherwise. That endpoint can be the server itself or the proxy in front of it; the rest of this page is about choosing which one terminates TLS.
66

7-
Use this when:
7+
## Advertised Ports
88

9-
- HTTPS reaches the proxy on `443`, then forwards to the stack HTTPS listener such as `555`
10-
- MQTT/TLS reaches a TCP/stream proxy on `8883`, then forwards to the stack MQTT/TLS listener such as `8881`
11-
- the proxy preserves the original `Host` header
12-
13-
Example:
9+
Use these when the proxy maps public ports to different backend listener ports. The server binds the `*_port` listeners but advertises the `advertised_*` ports to the Roborock app, vacuums, and Home Assistant.
1410

1511
```toml
1612
[network]
@@ -21,28 +17,79 @@ bind_host = "0.0.0.0"
2117
https_port = 555
2218
mqtt_tls_port = 8881
2319

24-
# Public ports advertised to the Roborock app, vacuums, and Home Assistant.
20+
# Public ports advertised to clients.
2521
advertised_https_port = 443
2622
advertised_mqtt_tls_port = 8883
2723
```
2824

29-
With that config the server listens on `https://*:555` and `ssl://*:8881`, but responses advertise:
25+
With that config the server listens on `*:555` / `*:8881`, but responses advertise:
3026

3127
- `https://api-roborock.example.com`
3228
- `ssl://api-roborock.example.com:8883`
3329

34-
## Proxy Requirements
30+
## TLS Termination Modes
31+
32+
### `local_tls` (default) — the server terminates TLS
33+
34+
The server runs its own TLS listeners; the proxy forwards encrypted traffic to them. The proxy must preserve the original `Host` header. For MQTT, use a TCP/stream proxy (a normal HTTP location is not enough because MQTT is not HTTP).
35+
36+
If you already manage certificates in the proxy, point the server at the same certificate chain so both present an identical, valid cert:
37+
38+
```toml
39+
[network]
40+
listener_mode = "local_tls"
41+
42+
[tls]
43+
mode = "provided"
44+
cert_file = "/path/to/proxy/fullchain.pem"
45+
key_file = "/path/to/proxy/privkey.pem"
46+
```
47+
48+
### `external_tls` — the proxy terminates TLS
3549

36-
For HTTPS admin/API traffic, the proxy must forward the original `Host` header unchanged:
50+
The proxy terminates TLS and forwards plain HTTP/TCP to the server, which holds no certificates at all. The proxy is responsible for presenting a valid cert to clients.
3751

38-
```text
39-
Host: $host
52+
```toml
53+
[network]
54+
listener_mode = "external_tls"
55+
56+
[tls]
57+
# No certificate material is required in this mode.
58+
mode = "provided"
59+
```
60+
61+
Requirements:
62+
63+
- HTTPS: the proxy terminates TLS, preserves the original `Host` header, and forwards plain HTTP to `https_port`.
64+
- MQTT: a **stream / layer-4** proxy must terminate TLS with a valid cert on the public MQTT port and forward plain TCP to `mqtt_tls_port`. An HTTP reverse proxy alone cannot do this.
65+
- `tls.mode` must be `"provided"`. `external_tls` never issues or renews certificates, so `cloudflare_acme` is rejected to avoid a silent no-op.
66+
67+
> **Do not expose the backend ports publicly.** In `external_tls` the server speaks plain, unencrypted HTTP and MQTT on `https_port` / `mqtt_tls_port`. Bind them to localhost or an internal Docker network reachable only by the proxy — never publish them to the host or the internet. With the bundled `compose.yaml`, the `ports:` mappings publish the backend ports; remove or restrict them so only the proxy reaches the server. If you run the proxy in the same Compose project, drop the `ports:` entries entirely and reference the service by name (e.g. `roborock-local-server:555`).
68+
>
69+
> The Docker healthcheck defaults to `https`. In `external_tls` set `ROBOROCK_SERVER_HEALTHCHECK_SCHEME=http` so it probes the plain-HTTP listener.
70+
71+
Example Caddy config (HTTPS via the standard reverse proxy, MQTT via the [layer4 plugin](https://github.com/mholt/caddy-l4)):
72+
73+
```caddyfile
74+
api-roborock.example.com {
75+
reverse_proxy roborock-local-server:555
76+
}
4077
```
4178

42-
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`.
79+
```caddyfile
80+
# layer4 app (Caddy JSON / global block) terminating MQTT TLS on 8883
81+
:8883 {
82+
route {
83+
tls
84+
proxy {
85+
upstream roborock-local-server:8881
86+
}
87+
}
88+
}
89+
```
4390

4491
## What Is Not Supported
4592

4693
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`.
4794

48-
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.
95+
`external_tls` is intended for Docker / standalone deployments. The Home Assistant add-on always terminates its own TLS and does not expose this option.

‎src/roborock_local_server/config.py‎

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
@dataclass(frozen=True)
1313
class NetworkConfig:
1414
stack_fqdn: str
15+
listener_mode: str
1516
bind_host: str
1617
https_port: int
1718
mqtt_tls_port: int
@@ -194,8 +195,8 @@ def load_config(path: str | Path) -> AppConfig:
194195
if tls_mode not in {"cloudflare_acme", "provided"}:
195196
raise ValueError("tls.mode must be 'cloudflare_acme' or 'provided'")
196197
listener_mode = str(network.get("listener_mode", "local_tls")).strip().lower() or "local_tls"
197-
if listener_mode != "local_tls":
198-
raise ValueError("network.listener_mode='external_tls' is no longer supported")
198+
if listener_mode not in {"local_tls", "external_tls"}:
199+
raise ValueError("network.listener_mode must be 'local_tls' or 'external_tls'")
199200

200201
raw_broker_host = broker.get("host")
201202
broker_host = str(raw_broker_host).strip() if raw_broker_host is not None else "127.0.0.1"
@@ -209,6 +210,7 @@ def load_config(path: str | Path) -> AppConfig:
209210
config = AppConfig(
210211
network=NetworkConfig(
211212
stack_fqdn=_require_stack_fqdn(network.get("stack_fqdn"), "network.stack_fqdn"),
213+
listener_mode=listener_mode,
212214
bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0",
213215
https_port=https_port,
214216
mqtt_tls_port=mqtt_tls_port,
@@ -280,26 +282,36 @@ def load_config(path: str | Path) -> AppConfig:
280282
if config.broker.mode == "external":
281283
_require_non_empty(config.broker.host, "broker.host")
282284

283-
if config.tls.mode == "cloudflare_acme":
284-
_normalize_hostname(config.tls.base_domain, "tls.base_domain")
285-
_require_non_empty(config.tls.email, "tls.email")
286-
_require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file")
287-
has_kid = bool(config.tls.acme_eab_kid or config.tls.acme_eab_kid_file)
288-
has_hmac = bool(config.tls.acme_eab_hmac_key or config.tls.acme_eab_hmac_key_file)
289-
if has_kid != has_hmac:
290-
raise ValueError(
291-
"tls.acme_eab_kid/tls.acme_eab_kid_file and "
292-
"tls.acme_eab_hmac_key/tls.acme_eab_hmac_key_file must be set together"
293-
)
294-
if config.tls.acme_server == "actalis":
295-
if not has_kid:
285+
# In external_tls the proxy terminates TLS and presents the cert to clients,
286+
# so the server itself needs no certificate material.
287+
if config.network.listener_mode == "local_tls":
288+
if config.tls.mode == "cloudflare_acme":
289+
_normalize_hostname(config.tls.base_domain, "tls.base_domain")
290+
_require_non_empty(config.tls.email, "tls.email")
291+
_require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file")
292+
has_kid = bool(config.tls.acme_eab_kid or config.tls.acme_eab_kid_file)
293+
has_hmac = bool(config.tls.acme_eab_hmac_key or config.tls.acme_eab_hmac_key_file)
294+
if has_kid != has_hmac:
296295
raise ValueError(
297-
"Actalis requires tls.acme_eab_kid or tls.acme_eab_kid_file, "
298-
"and tls.acme_eab_hmac_key or tls.acme_eab_hmac_key_file"
296+
"tls.acme_eab_kid/tls.acme_eab_kid_file and "
297+
"tls.acme_eab_hmac_key/tls.acme_eab_hmac_key_file must be set together"
299298
)
300-
else:
301-
_require_non_empty(config.tls.cert_file, "tls.cert_file")
302-
_require_non_empty(config.tls.key_file, "tls.key_file")
299+
if config.tls.acme_server == "actalis":
300+
if not has_kid:
301+
raise ValueError(
302+
"Actalis requires tls.acme_eab_kid or tls.acme_eab_kid_file, "
303+
"and tls.acme_eab_hmac_key or tls.acme_eab_hmac_key_file"
304+
)
305+
else:
306+
_require_non_empty(config.tls.cert_file, "tls.cert_file")
307+
_require_non_empty(config.tls.key_file, "tls.key_file")
308+
elif config.tls.mode == "cloudflare_acme":
309+
# external_tls never issues or renews certificates, so cloudflare_acme
310+
# would be a silent no-op. Require the proxy-managed 'provided' mode.
311+
raise ValueError(
312+
"network.listener_mode='external_tls' requires tls.mode='provided' "
313+
"(the proxy terminates TLS; the server does not issue certificates)"
314+
)
303315
return config
304316

305317

‎src/roborock_local_server/ha_addon.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ def _render_config_toml(
163163
region = str(merged.get("region", "us") or "us").strip().lower() or "us"
164164
listener_mode = str(merged.get("listener_mode", "local_tls") or "local_tls").strip().lower() or "local_tls"
165165
if listener_mode != "local_tls":
166-
raise ValueError("listener_mode='external_tls' is no longer supported")
166+
# The add-on always terminates its own TLS; external_tls is Docker-only.
167+
raise ValueError("listener_mode='external_tls' is not supported by the Home Assistant add-on")
167168
https_port = _as_int(merged.get("https_port"), field_name="https_port", default=555)
168169
mqtt_tls_port = _as_int(merged.get("mqtt_tls_port"), field_name="mqtt_tls_port", default=8881)
169170
advertised_https_port = _as_optional_port(

‎src/roborock_local_server/server.py‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,13 @@ def _require_admin(self, request: Request) -> None:
488488
if not self._authenticated(request):
489489
raise HTTPException(status_code=401, detail="Authentication required")
490490

491+
def cookie_secure(self, request: Request) -> bool:
492+
# In external_tls the backend speaks plain HTTP behind a TLS-terminating
493+
# proxy, so the request scheme is "http" even though clients use HTTPS.
494+
if self.config.network.listener_mode == "external_tls":
495+
return True
496+
return request.url.scheme == "https"
497+
491498
def protocol_auth_enabled(self) -> bool:
492499
return bool(self.config.admin.protocol_auth_enabled)
493500

@@ -1585,15 +1592,19 @@ def _create_app(self) -> FastAPI:
15851592
self._register_protocol_routes(app)
15861593
return app
15871594

1595+
def _uses_local_tls(self) -> bool:
1596+
return self.config.network.listener_mode == "local_tls"
1597+
15881598
async def _start_http_server(self) -> None:
1599+
local_tls = self._uses_local_tls()
15891600
cert_paths = self.certificate_manager.certificate_paths
15901601
self._http_server = ManagedFastApiServer(
15911602
app=self.app,
15921603
bind_host=self.config.network.bind_host,
15931604
port=self.config.network.https_port,
1594-
tls_enabled=True,
1595-
cert_file=cert_paths.cert_file,
1596-
key_file=cert_paths.key_file,
1605+
tls_enabled=local_tls,
1606+
cert_file=cert_paths.cert_file if local_tls else None,
1607+
key_file=cert_paths.key_file if local_tls else None,
15971608
)
15981609
await self._http_server.start()
15991610
self.runtime_state.set_service("https_server", running=True, required=True, enabled=True)
@@ -1617,7 +1628,7 @@ def _start_mqtt_proxy(self) -> None:
16171628
runtime_state=self.runtime_state,
16181629
runtime_credentials=self.runtime_credentials,
16191630
zone_ranges_store=self.context.zone_ranges_store,
1620-
tls_enabled=True,
1631+
tls_enabled=self._uses_local_tls(),
16211632
)
16221633
self._mqtt_proxy.start()
16231634
self.runtime_state.set_service("mqtt_tls_proxy", running=True, required=True, enabled=True)
@@ -1649,7 +1660,8 @@ async def start(self) -> None:
16491660
for path in (self.paths.data_dir, self.paths.runtime_dir, self.paths.state_dir, self.paths.certs_dir, self.paths.acme_dir):
16501661
path.mkdir(parents=True, exist_ok=True)
16511662

1652-
self.certificate_manager.ensure_certificate()
1663+
if self._uses_local_tls():
1664+
self.certificate_manager.ensure_certificate()
16531665
self.refresh_inventory_state()
16541666

16551667
if self.config.broker.mode == "embedded":
@@ -1697,7 +1709,7 @@ async def start(self) -> None:
16971709
self.config.broker.port,
16981710
)
16991711

1700-
if self.config.tls.mode == "cloudflare_acme":
1712+
if self._uses_local_tls() and self.config.tls.mode == "cloudflare_acme":
17011713
self._renew_task = asyncio.create_task(self._renew_loop(), name="tls-renew-loop")
17021714

17031715
async def stop(self) -> None:

‎src/roborock_local_server/standalone_admin.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ async def admin_login(request: Request) -> JSONResponse:
329329
supervisor.session_manager.cookie_name,
330330
supervisor.session_manager.issue(),
331331
httponly=True,
332-
secure=request.url.scheme == "https",
332+
secure=supervisor.cookie_secure(request),
333333
samesite="lax",
334334
max_age=supervisor.config.admin.session_ttl_seconds,
335335
path="/",

‎tests/conftest.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def write_release_config(
1818
tmp_path: Path,
1919
*,
2020
stack_fqdn: str = "api-roborock.example.com",
21+
listener_mode: str = "local_tls",
2122
https_port: int = 443,
2223
mqtt_tls_port: int = 8883,
2324
broker_mode: str = "external",
@@ -37,6 +38,7 @@ def write_release_config(
3738
f"""
3839
[network]
3940
stack_fqdn = "{stack_fqdn}"
41+
listener_mode = "{listener_mode}"
4042
https_port = {https_port}
4143
mqtt_tls_port = {mqtt_tls_port}
4244

‎tests/test_admin_api.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,23 @@ def _hawk_headers(
199199
}
200200

201201

202+
def test_admin_login_cookie_secure_in_external_tls(tmp_path: Path) -> None:
203+
config_file = write_release_config(tmp_path, listener_mode="external_tls")
204+
config = load_config(config_file)
205+
paths = resolve_paths(config_file, config)
206+
supervisor = ReleaseSupervisor(config=config, paths=paths)
207+
# The backend speaks plain HTTP behind the TLS-terminating proxy.
208+
client = TestClient(supervisor.app, base_url="http://api-roborock.example.com")
209+
210+
response = client.post(
211+
"/admin/api/login",
212+
json={"password": "correct horse battery staple"},
213+
)
214+
215+
assert response.status_code == 200
216+
assert "secure" in response.headers["set-cookie"].lower()
217+
218+
202219
def test_admin_login_and_status_flow(tmp_path: Path) -> None:
203220
config_file = write_release_config(tmp_path)
204221
config = load_config(config_file)

0 commit comments

Comments
 (0)