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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ This service is meant to stay private. Point your own DNS at your server's LAN I

This project is in VERY EARLY BETA!!! Do not use this repository unless you are sure you know what you are doing and are rather technical.

## Contributing

If you would like to contribute there are a few ways that would be great!

1. Code is always welcome that you have fully tested.
2. Video walkthroughs of how to actually set this up would be great.
3. Documentation. I hate documentation and it's something I find myself often pushing off to AI so that I can focus more on the harder problems. But I find that human written documentation always 'feels' better.

## Requirements

- a domain you control
Expand Down
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ services:
volumes:
- ./config.toml:/app/config.toml:ro
- ./data:/data
- ./secrets/cloudflare_token:/run/secrets/cloudflare_token:ro
- ./secrets:/run/secrets:ro
healthcheck:
test: ["CMD", "curl", "-skf", "https://127.0.0.1:${ROBOROCK_SERVER_HTTPS_PORT:-555}/admin"]
interval: 30s
Expand Down
9 changes: 8 additions & 1 deletion docs/cloudflare_setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

Use this optional guide if you want Cloudflare DNS-01 certificate issuance and automatic renewal during [Installation](installation.md). If you would rather provide your own certificate files, see [Custom certificate management](custom_cert_management.md).

Cloudflare is used to get the certificates for your domain so that when we run the server, the vacuum will trust the domain. This also lets the server renew the certificate automatically so you do not have to rotate it by hand when it expires.
Cloudflare is used for DNS-01 validation against your zone so that the stack can request and renew certificates automatically. The ACME CA is configurable. The default is ZeroSSL, and any deployment can switch to `actalis` if an older vacuum trusts that chain more reliably.

If you choose `acme_server = actalis`, you must also provide `acme_eab_kid` and `acme_eab_hmac_key` from your Actalis ACME account. Generated configs store those in separate secret files instead of embedding them directly in `config.toml`.

The automated issuance shape differs by ACME CA:

- `zerossl` requests `base_domain` plus `*.base_domain`
- `actalis` requests only `stack_fqdn`

## Create the Cloudflare Token

Expand Down
2 changes: 2 additions & 0 deletions docs/home_assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ This is an installation method, not a post-install integration step. The add-on
- TLS settings:
- `tls_mode = provided` with explicit `cert_file` and `key_file`
- or `tls_mode = cloudflare_acme` with `tls_base_domain`, `tls_email`, and `cloudflare_token`
- optional ACME CA selection with `acme_server`
- if `acme_server = actalis`, also set `acme_eab_kid` and `acme_eab_hmac_key`.

5. Start the add-on.

Expand Down
4 changes: 3 additions & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ After the stack is running, continue with [Onboarding](onboarding.md) to pair a
- 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
- if you chose Cloudflare, the ACME account email and whether to use ZeroSSL or Actalis
- if you chose Actalis, the Actalis EAB KID and EAB HMAC key
- 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`. If you also chose `acme_server = actalis`, it writes `secrets/acme_eab_kid` and `secrets/acme_eab_hmac_key`.
Comment thread
Lash-L marked this conversation as resolved.

4. If you chose external MQTT, fill in `broker.host` in `config.toml` before starting the stack. See [Custom MQTT](custom_mqtt.md).

Expand Down
4 changes: 4 additions & 0 deletions docs/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ This is a standalone script — you can copy `start_onboarding.py` to any machin

If you omit the port, the CLI assumes the default local stack HTTPS port `555`. If your stack uses a custom HTTPS port, include it in `--server`, for example `api-roborock.example.com:8443`.

Onboarding has a hard `token.r` limit of 32 characters after normalization to the final `host[:port]/` value sent to the vacuum.

The guided CLI will:

1. Log into the main server with your admin password.
Expand Down Expand Up @@ -94,6 +96,8 @@ No CLI flags. All configuration happens in the browser form on first load.

Enter the same server host you use for `/admin`. If your stack runs on a custom HTTPS port, include it in the form, for example `api-roborock.example.com:8443`.

The same onboarding `token.r` limit applies in the GUI: the final `host[:port]/` value must be 32 characters or less.

The main reason to use the GUI version is that this flow makes you switch your machine between your normal Wi-Fi and the vacuum's Wi-Fi hotspot several times. A browser talking to `127.0.0.1` keeps working through those switches. The CLI version can get into a bad state if a blocking network call hits while you are still on the vacuum hotspot.

### What it does on startup
Expand Down
6 changes: 6 additions & 0 deletions roborock_local_server_addon/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ options:
tls_mode: "provided"
tls_base_domain: ""
tls_email: ""
acme_server: "zerossl"
acme_eab_kid: ""
acme_eab_hmac_key: ""
cloudflare_token: ""
cert_file: ""
key_file: ""
Expand All @@ -43,6 +46,9 @@ schema:
tls_mode: list(provided|cloudflare_acme)
tls_base_domain: str
tls_email: str
acme_server: list(zerossl|actalis)
acme_eab_kid: str
acme_eab_hmac_key: password
cloudflare_token: str
cert_file: str
key_file: str
Expand Down
4 changes: 4 additions & 0 deletions src/roborock_local_server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def main() -> int:
print(f"Wrote config: {result.config_file}")
if result.cloudflare_token_file is not None:
print(f"Wrote Cloudflare token file: {result.cloudflare_token_file}")
if result.actalis_eab_kid_file is not None:
print(f"Wrote Actalis EAB KID file: {result.actalis_eab_kid_file}")
if result.actalis_eab_hmac_key_file is not None:
print(f"Wrote Actalis EAB HMAC key file: {result.actalis_eab_hmac_key_file}")
if result.broker_template_needs_edit:
print("Fill in broker.host before starting the stack.")
return 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,32 @@ def _filter_home_data_to_runtime_devices(ctx: ServerContext, home_data: dict[str
return filtered_home


def _promote_shared_devices_to_main_devices(home_data: dict[str, Any]) -> dict[str, Any]:
promoted_home = dict(home_data)

merged_devices: list[dict[str, Any]] = []
seen_duids: set[str] = set()
for collection_key in ("devices", "receivedDevices", "received_devices"):
devices_value = promoted_home.get(collection_key)
if not isinstance(devices_value, list):
continue
for device in devices_value:
if not isinstance(device, dict):
continue
normalized_device = dict(device)
duid = str(get_value(normalized_device, "duid", "did", default="")).strip()
if duid and duid in seen_duids:
continue
if duid:
seen_duids.add(duid)
merged_devices.append(normalized_device)

promoted_home["devices"] = merged_devices
promoted_home["receivedDevices"] = []
promoted_home.pop("received_devices", None)
return promoted_home


def _home_data(ctx: ServerContext) -> dict[str, Any]:
inventory = load_inventory(ctx)
home_value = inventory.get("home")
Expand All @@ -150,6 +176,7 @@ def _home_data(ctx: ServerContext) -> dict[str, Any]:

home_data = enrich_home_data_with_cloud_snapshot(ctx, home_data)
home_data = _filter_home_data_to_runtime_devices(ctx, home_data)
home_data = _promote_shared_devices_to_main_devices(home_data)
home_data["id"] = resolve_home_id(home_data, home, default=default_home_id(ctx))
home_data["name"] = str(get_value(home_data, "name", "home_name", default=DEFAULT_HOME_NAME))
return home_data
Expand Down
113 changes: 95 additions & 18 deletions src/roborock_local_server/certs.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,78 @@ def _needs_refresh(self) -> bool:
except Exception:
return True
deadline = datetime.now(timezone.utc) + timedelta(days=self.config.tls.renew_days_before)
return cert.not_valid_after_utc <= deadline
if cert.not_valid_after_utc <= deadline:
return True
return not self._certificate_matches_configuration(cert)

def _read_cloudflare_token(self) -> str:
token = self.paths.cloudflare_token_file.read_text(encoding="utf-8").strip()
if not token:
raise RuntimeError(f"Cloudflare token file is empty: {self.paths.cloudflare_token_file}")
return token

@staticmethod
def _read_optional_secret_file(path: Path) -> str:
if not path.exists():
return ""
return path.read_text(encoding="utf-8").strip()

def _load_eab_credentials(self) -> tuple[str, str]:
kid = self.config.tls.acme_eab_kid or self._read_optional_secret_file(self.paths.acme_eab_kid_file)
hmac_key = self.config.tls.acme_eab_hmac_key or self._read_optional_secret_file(self.paths.acme_eab_hmac_key_file)
if bool(kid) != bool(hmac_key):
raise RuntimeError("ACME EAB credentials are incomplete; both KID and HMAC key are required")
if self.config.tls.acme_server == "actalis" and not kid:
raise RuntimeError(
"Actalis ACME requires EAB credentials. "
f"Checked inline config plus {self.paths.acme_eab_kid_file} and {self.paths.acme_eab_hmac_key_file}."
)
return kid, hmac_key

@staticmethod
def _redact_command(command: list[str]) -> str:
redacted: list[str] = []
redact_next = False
for part in command:
if redact_next:
redacted.append("<redacted>")
redact_next = False
continue
redacted.append(part)
if part in {"--eab-kid", "--eab-hmac-key"}:
redact_next = True
return " ".join(redacted)

@staticmethod
def _redact_text(text: str, sensitive_values: Iterable[str]) -> str:
redacted = text
for value in sorted({item for item in sensitive_values if item}, key=len, reverse=True):
redacted = redacted.replace(value, "<redacted>")
return redacted

@staticmethod
def _sensitive_values(command: list[str], env: dict[str, str]) -> list[str]:
sensitive_values: list[str] = []
for index, part in enumerate(command[:-1]):
if part in {"--eab-kid", "--eab-hmac-key"}:
sensitive_values.append(command[index + 1])
cloudflare_token = env.get("CF_Token", "").strip()
if cloudflare_token:
sensitive_values.append(cloudflare_token)
return sensitive_values

def _certificate_matches_configuration(self, cert: x509.Certificate) -> bool:
try:
san_names = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value.get_values_for_type(
x509.DNSName
)
except x509.ExtensionNotFound:
return False
actual_domains = {name.strip().lower() for name in san_names if name.strip()}
_primary_domain, issue_domains = self._certificate_domains()
expected_domains = {domain.strip().lower() for domain in issue_domains if domain.strip()}
return actual_domains == expected_domains

def _run_acme(self, args: Iterable[str]) -> None:
self.paths.acme_dir.mkdir(parents=True, exist_ok=True)
if not ACME_SH_PATH.exists():
Expand All @@ -78,7 +142,9 @@ def _run_acme(self, args: Iterable[str]) -> None:
"--server",
self.config.tls.acme_server,
]
LOG.info("Running ACME command: %s", " ".join(command))
display_command = self._redact_command(command)
sensitive_values = self._sensitive_values(command, env)
LOG.info("Running ACME command: %s", display_command)
result = subprocess.run(
command,
env=env,
Expand All @@ -88,32 +154,37 @@ def _run_acme(self, args: Iterable[str]) -> None:
check=False,
)
if result.stdout.strip():
LOG.info("ACME output:\n%s", result.stdout.strip())
LOG.info("ACME output:\n%s", self._redact_text(result.stdout.strip(), sensitive_values))
if result.returncode != 0:
raise RuntimeError(f"ACME command failed ({result.returncode}): {' '.join(command)}")
raise RuntimeError(f"ACME command failed ({result.returncode}): {display_command}")

def _provision_or_renew(self) -> None:
self.paths.certs_dir.mkdir(parents=True, exist_ok=True)
base_domain = self.config.tls.base_domain
self._run_acme(["--register-account", "-m", self.config.tls.email])
primary_domain, issue_domains = self._certificate_domains()
register_args = ["--register-account", "-m", self.config.tls.email]
eab_kid, eab_hmac_key = self._load_eab_credentials()
if eab_kid and eab_hmac_key:
register_args.extend(
[
"--eab-kid",
eab_kid,
"--eab-hmac-key",
eab_hmac_key,
Comment thread
Lash-L marked this conversation as resolved.
]
Comment thread
Lash-L marked this conversation as resolved.
)
self._run_acme(register_args)
issue_args = ["--issue", "--dns", "dns_cf"]
for domain in issue_domains:
issue_args.extend(["-d", domain])
issue_args.extend(["--keylength", "2048"])
self._run_acme(
[
"--issue",
"--dns",
"dns_cf",
"-d",
base_domain,
"-d",
f"*.{base_domain}",
"--keylength",
"2048",
]
issue_args
)
self._run_acme(
[
"--install-cert",
"-d",
base_domain,
primary_domain,
"--fullchain-file",
str(self.paths.cert_file),
"--key-file",
Expand All @@ -123,3 +194,9 @@ def _provision_or_renew(self) -> None:
if not self.paths.cert_file.exists() or not self.paths.key_file.exists():
raise RuntimeError("ACME completed without writing certificate files")

def _certificate_domains(self) -> tuple[str, list[str]]:
if self.config.tls.acme_server == "actalis":
stack_fqdn = self.config.network.stack_fqdn
return stack_fqdn, [stack_fqdn]
base_domain = self.config.tls.base_domain
return base_domain, [base_domain, f"*.{base_domain}"]
Comment thread
Lash-L marked this conversation as resolved.
40 changes: 39 additions & 1 deletion src/roborock_local_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ class TlsConfig:
renew_days_before: int
renew_check_seconds: int
acme_server: str
acme_eab_kid: str
acme_eab_hmac_key: str
acme_eab_kid_file: str
acme_eab_hmac_key_file: str
cert_file: str
key_file: str

Expand Down Expand Up @@ -85,6 +89,8 @@ class AppPaths:
http_jsonl_path: Path
mqtt_jsonl_path: Path
cloudflare_token_file: Path
acme_eab_kid_file: Path
acme_eab_hmac_key_file: Path
cert_file: Path
key_file: Path

Expand Down Expand Up @@ -127,6 +133,13 @@ def _normalize_hostname(value: object, field_name: str, *, require_api_prefix: b
return normalized


def _normalize_acme_server(value: object, field_name: str) -> str:
normalized = str(value or "").strip().lower() or "zerossl"
if normalized not in {"zerossl", "actalis"}:
raise ValueError(f"{field_name} must be 'zerossl' or 'actalis'")
return normalized


def _require_stack_fqdn(value: object, field_name: str) -> str:
return _normalize_hostname(value, field_name, require_api_prefix=True)

Expand Down Expand Up @@ -221,7 +234,11 @@ def load_config(path: str | Path) -> AppConfig:
cloudflare_token_file=str(tls.get("cloudflare_token_file", "")).strip(),
renew_days_before=_as_int(tls.get("renew_days_before"), "tls.renew_days_before", 30),
renew_check_seconds=_as_int(tls.get("renew_check_seconds"), "tls.renew_check_seconds", 43200),
acme_server=str(tls.get("acme_server", "zerossl")).strip() or "zerossl",
acme_server=_normalize_acme_server(tls.get("acme_server", "zerossl"), "tls.acme_server"),
acme_eab_kid=str(tls.get("acme_eab_kid", "")).strip(),
acme_eab_hmac_key=str(tls.get("acme_eab_hmac_key", "")).strip(),
acme_eab_kid_file=str(tls.get("acme_eab_kid_file", "")).strip(),
acme_eab_hmac_key_file=str(tls.get("acme_eab_hmac_key_file", "")).strip(),
cert_file=str(tls.get("cert_file", "")).strip(),
key_file=str(tls.get("key_file", "")).strip(),
),
Expand Down Expand Up @@ -250,6 +267,19 @@ def load_config(path: str | Path) -> AppConfig:
_normalize_hostname(config.tls.base_domain, "tls.base_domain")
_require_non_empty(config.tls.email, "tls.email")
_require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file")
has_kid = bool(config.tls.acme_eab_kid or config.tls.acme_eab_kid_file)
has_hmac = bool(config.tls.acme_eab_hmac_key or config.tls.acme_eab_hmac_key_file)
if has_kid != has_hmac:
raise ValueError(
"tls.acme_eab_kid/tls.acme_eab_kid_file and "
"tls.acme_eab_hmac_key/tls.acme_eab_hmac_key_file must be set together"
)
if config.tls.acme_server == "actalis":
if not has_kid:
raise ValueError(
"Actalis requires tls.acme_eab_kid or tls.acme_eab_kid_file, "
"and tls.acme_eab_hmac_key or tls.acme_eab_hmac_key_file"
)
Comment thread
Lash-L marked this conversation as resolved.
else:
_require_non_empty(config.tls.cert_file, "tls.cert_file")
_require_non_empty(config.tls.key_file, "tls.key_file")
Expand Down Expand Up @@ -278,6 +308,12 @@ def resolve_paths(config_file: str | Path, config: AppConfig) -> AppPaths:
cloudflare_token_file = Path(config.tls.cloudflare_token_file or state_dir / "cloudflare_token")
if not cloudflare_token_file.is_absolute():
cloudflare_token_file = (config_root / cloudflare_token_file).resolve()
acme_eab_kid_file = Path(config.tls.acme_eab_kid_file or state_dir / "acme_eab_kid")
if not acme_eab_kid_file.is_absolute():
acme_eab_kid_file = (config_root / acme_eab_kid_file).resolve()
acme_eab_hmac_key_file = Path(config.tls.acme_eab_hmac_key_file or state_dir / "acme_eab_hmac_key")
if not acme_eab_hmac_key_file.is_absolute():
acme_eab_hmac_key_file = (config_root / acme_eab_hmac_key_file).resolve()

return AppPaths(
config_file=config_path,
Expand All @@ -294,6 +330,8 @@ def resolve_paths(config_file: str | Path, config: AppConfig) -> AppPaths:
http_jsonl_path=runtime_dir / "decompiled_http.jsonl",
mqtt_jsonl_path=runtime_dir / "decompiled_mqtt.jsonl",
cloudflare_token_file=cloudflare_token_file,
acme_eab_kid_file=acme_eab_kid_file,
acme_eab_hmac_key_file=acme_eab_hmac_key_file,
cert_file=cert_file,
key_file=key_file,
)
Loading