diff --git a/README.md b/README.md index 7a0e02c..9ec0e23 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compose.yaml b/compose.yaml index d69edd8..7835068 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/docs/cloudflare_setup.md b/docs/cloudflare_setup.md index d30ef65..05678b9 100644 --- a/docs/cloudflare_setup.md +++ b/docs/cloudflare_setup.md @@ -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 diff --git a/docs/home_assistant.md b/docs/home_assistant.md index 514f444..91efbff 100644 --- a/docs/home_assistant.md +++ b/docs/home_assistant.md @@ -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. diff --git a/docs/installation.md b/docs/installation.md index 227cfae..655699d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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`. 4. If you chose external MQTT, fill in `broker.host` in `config.toml` before starting the stack. See [Custom MQTT](custom_mqtt.md). diff --git a/docs/onboarding.md b/docs/onboarding.md index f1ebdc3..5e7e237 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -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. @@ -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 diff --git a/roborock_local_server_addon/config.yaml b/roborock_local_server_addon/config.yaml index a7833be..e7b22f5 100644 --- a/roborock_local_server_addon/config.yaml +++ b/roborock_local_server_addon/config.yaml @@ -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: "" @@ -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 diff --git a/src/roborock_local_server/__main__.py b/src/roborock_local_server/__main__.py index 2aabff3..a865107 100644 --- a/src/roborock_local_server/__main__.py +++ b/src/roborock_local_server/__main__.py @@ -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 diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/user/homes/service.py b/src/roborock_local_server/bundled_backend/https_server/routes/user/homes/service.py index dc58239..becd33c 100644 --- a/src/roborock_local_server/bundled_backend/https_server/routes/user/homes/service.py +++ b/src/roborock_local_server/bundled_backend/https_server/routes/user/homes/service.py @@ -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") @@ -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 diff --git a/src/roborock_local_server/certs.py b/src/roborock_local_server/certs.py index d4e193a..964076c 100644 --- a/src/roborock_local_server/certs.py +++ b/src/roborock_local_server/certs.py @@ -56,7 +56,9 @@ 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() @@ -64,6 +66,68 @@ def _read_cloudflare_token(self) -> str: 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("") + 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, "") + 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(): @@ -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, @@ -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, + ] + ) + 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", @@ -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}"] diff --git a/src/roborock_local_server/config.py b/src/roborock_local_server/config.py index 43a1190..07eeb6d 100644 --- a/src/roborock_local_server/config.py +++ b/src/roborock_local_server/config.py @@ -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 @@ -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 @@ -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) @@ -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(), ), @@ -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" + ) else: _require_non_empty(config.tls.cert_file, "tls.cert_file") _require_non_empty(config.tls.key_file, "tls.key_file") @@ -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, @@ -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, ) diff --git a/src/roborock_local_server/configure.py b/src/roborock_local_server/configure.py index d99657c..703edb3 100644 --- a/src/roborock_local_server/configure.py +++ b/src/roborock_local_server/configure.py @@ -36,6 +36,8 @@ def hash_password(password: str, *, iterations: int = 600_000) -> str: _HOST_RE = re.compile(r"^[a-z0-9.-]+$") _CLOUDFLARE_TOKEN_CONTAINER_PATH = "/run/secrets/cloudflare_token" +_ACTALIS_EAB_KID_CONTAINER_PATH = "/run/secrets/acme_eab_kid" +_ACTALIS_EAB_HMAC_KEY_CONTAINER_PATH = "/run/secrets/acme_eab_hmac_key" @dataclass(frozen=True) @@ -47,6 +49,9 @@ class ConfigureAnswers: tls_mode: str base_domain: str email: str + acme_server: str + acme_eab_kid: str + acme_eab_hmac_key: str cloudflare_token: str password_hash: str session_secret: str @@ -58,6 +63,8 @@ class ConfigureAnswers: class ConfigureResult: config_file: Path cloudflare_token_file: Path | None + actalis_eab_kid_file: Path | None + actalis_eab_hmac_key_file: Path | None broker_template_needs_edit: bool @@ -98,6 +105,14 @@ def _prompt_non_empty(prompt: str) -> str: print("A value is required.") +def _prompt_non_empty_secret(prompt: str) -> str: + while True: + value = getpass(prompt).strip() + if value: + return value + print("A value is required.") + + def _prompt_hostname(prompt: str, *, field_name: str) -> str: while True: raw_value = _prompt_non_empty(prompt) @@ -140,11 +155,7 @@ def _prompt_yes_no(prompt: str, *, default: bool) -> bool: def _prompt_password() -> str: - while True: - password = getpass("Admin password (input hidden): ") - if password: - return password - print("A password is required.") + return _prompt_non_empty_secret("Admin password (input hidden): ") def _prompt_protocol_login_email() -> str: @@ -177,6 +188,37 @@ def _prompt_protocol_login_pin() -> str: return normalized_pin +def _normalize_acme_server(value: str) -> str: + normalized = str(value or "").strip().lower() or "zerossl" + if normalized not in {"zerossl", "actalis"}: + raise ValueError("acme_server must be 'zerossl' or 'actalis'") + return normalized + + +def _validated_answers(answers: ConfigureAnswers) -> ConfigureAnswers: + normalized_acme_server = _normalize_acme_server(answers.acme_server) + if answers.tls_mode == "cloudflare_acme" and normalized_acme_server == "actalis": + if not answers.acme_eab_kid.strip() or not answers.acme_eab_hmac_key.strip(): + raise ValueError("Actalis requires both acme_eab_kid and acme_eab_hmac_key") + return ConfigureAnswers( + stack_fqdn=answers.stack_fqdn, + https_port=answers.https_port, + mqtt_tls_port=answers.mqtt_tls_port, + broker_mode=answers.broker_mode, + tls_mode=answers.tls_mode, + base_domain=answers.base_domain, + email=answers.email, + acme_server=normalized_acme_server, + acme_eab_kid=answers.acme_eab_kid, + acme_eab_hmac_key=answers.acme_eab_hmac_key, + cloudflare_token=answers.cloudflare_token, + password_hash=answers.password_hash, + session_secret=answers.session_secret, + protocol_login_email=answers.protocol_login_email, + protocol_login_pin_hash=answers.protocol_login_pin_hash, + ) + + def collect_configure_answers() -> ConfigureAnswers: print("This writes a small config.toml with opinionated defaults.") stack_fqdn = _prompt_hostname( @@ -193,38 +235,48 @@ def collect_configure_answers() -> ConfigureAnswers: base_domain = "" email = "" + acme_server = "zerossl" + acme_eab_kid = "" + acme_eab_hmac_key = "" cloudflare_token = "" if use_cloudflare_acme: base_domain = _prompt_hostname( - "Base domain for the wildcard certificate (example.com): ", + "Base domain / DNS zone for ACME DNS validation (example.com): ", field_name="tls.base_domain", ) email = _prompt_non_empty("Email for the ACME account: ") - cloudflare_token = getpass("Cloudflare API token (input hidden): ").strip() - while not cloudflare_token: - print("A Cloudflare API token is required.") - cloudflare_token = getpass("Cloudflare API token (input hidden): ").strip() + acme_server = "actalis" if _prompt_yes_no("Use Actalis instead of ZeroSSL as the ACME CA?", default=False) else "zerossl" + if acme_server == "actalis": + acme_eab_kid = _prompt_non_empty("Actalis EAB KID: ") + acme_eab_hmac_key = _prompt_non_empty_secret("Actalis EAB HMAC key (input hidden): ") + cloudflare_token = _prompt_non_empty_secret("Cloudflare API token (input hidden): ") password = _prompt_password() protocol_login_email = _prompt_protocol_login_email() protocol_login_pin = _prompt_protocol_login_pin() - return ConfigureAnswers( - stack_fqdn=stack_fqdn, - https_port=https_port, - mqtt_tls_port=mqtt_tls_port, - broker_mode=broker_mode, - tls_mode=tls_mode, - base_domain=base_domain, - email=email, - cloudflare_token=cloudflare_token, - password_hash=hash_password(password), - session_secret=secrets.token_urlsafe(32), - protocol_login_email=protocol_login_email, - protocol_login_pin_hash=hash_password(protocol_login_pin), + return _validated_answers( + ConfigureAnswers( + stack_fqdn=stack_fqdn, + https_port=https_port, + mqtt_tls_port=mqtt_tls_port, + broker_mode=broker_mode, + tls_mode=tls_mode, + base_domain=base_domain, + email=email, + acme_server=acme_server, + acme_eab_kid=acme_eab_kid, + acme_eab_hmac_key=acme_eab_hmac_key, + cloudflare_token=cloudflare_token, + password_hash=hash_password(password), + session_secret=secrets.token_urlsafe(32), + protocol_login_email=protocol_login_email, + protocol_login_pin_hash=hash_password(protocol_login_pin), + ) ) def render_config_toml(answers: ConfigureAnswers) -> str: + answers = _validated_answers(answers) lines = [ "[network]", f"stack_fqdn = {_toml_string(answers.stack_fqdn)}", @@ -274,7 +326,11 @@ def render_config_toml(answers: ConfigureAnswers) -> str: f"cloudflare_token_file = {_toml_string(_CLOUDFLARE_TOKEN_CONTAINER_PATH)}", "renew_days_before = 30", "renew_check_seconds = 43200", - 'acme_server = "zerossl"', + f"acme_server = {_toml_string(answers.acme_server)}", + 'acme_eab_kid = ""', + 'acme_eab_hmac_key = ""', + f"acme_eab_kid_file = {_toml_string(_ACTALIS_EAB_KID_CONTAINER_PATH if answers.acme_server == 'actalis' else '')}", + f"acme_eab_hmac_key_file = {_toml_string(_ACTALIS_EAB_HMAC_KEY_CONTAINER_PATH if answers.acme_server == 'actalis' else '')}", ] ) else: @@ -285,7 +341,11 @@ def render_config_toml(answers: ConfigureAnswers) -> str: 'cloudflare_token_file = ""', "renew_days_before = 30", "renew_check_seconds = 43200", - 'acme_server = "zerossl"', + f"acme_server = {_toml_string(answers.acme_server)}", + 'acme_eab_kid = ""', + 'acme_eab_hmac_key = ""', + 'acme_eab_kid_file = ""', + 'acme_eab_hmac_key_file = ""', 'cert_file = "/data/certs/fullchain.pem"', 'key_file = "/data/certs/privkey.pem"', ] @@ -313,12 +373,17 @@ def write_config_setup( answers: ConfigureAnswers, force: bool = False, ) -> ConfigureResult: + answers = _validated_answers(answers) config_path = Path(config_file).resolve() token_path = config_path.parent / "secrets" / "cloudflare_token" + actalis_kid_path = config_path.parent / "secrets" / "acme_eab_kid" + actalis_hmac_path = config_path.parent / "secrets" / "acme_eab_hmac_key" protected_paths = [config_path] if answers.tls_mode == "cloudflare_acme": protected_paths.append(token_path) + if answers.acme_server == "actalis": + protected_paths.extend([actalis_kid_path, actalis_hmac_path]) if not force: existing = [path for path in protected_paths if path.exists()] @@ -330,16 +395,36 @@ def write_config_setup( config_path.write_text(render_config_toml(answers), encoding="utf-8") written_token_path: Path | None = None + written_actalis_kid_path: Path | None = None + written_actalis_hmac_path: Path | None = None if answers.tls_mode == "cloudflare_acme": token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_text(answers.cloudflare_token, encoding="utf-8") if os.name != "nt": token_path.chmod(0o600) written_token_path = token_path + if answers.acme_server == "actalis": + actalis_kid_path.write_text(answers.acme_eab_kid, encoding="utf-8") + actalis_hmac_path.write_text(answers.acme_eab_hmac_key, encoding="utf-8") + if os.name != "nt": + actalis_kid_path.chmod(0o600) + actalis_hmac_path.chmod(0o600) + written_actalis_kid_path = actalis_kid_path + written_actalis_hmac_path = actalis_hmac_path + else: + for stale_path in (actalis_kid_path, actalis_hmac_path): + if stale_path.exists(): + stale_path.unlink() + else: + for stale_path in (token_path, actalis_kid_path, actalis_hmac_path): + if stale_path.exists(): + stale_path.unlink() return ConfigureResult( config_file=config_path, cloudflare_token_file=written_token_path, + actalis_eab_kid_file=written_actalis_kid_path, + actalis_eab_hmac_key_file=written_actalis_hmac_path, broker_template_needs_edit=answers.broker_mode == "external", ) diff --git a/src/roborock_local_server/ha_addon.py b/src/roborock_local_server/ha_addon.py index 7e8950c..97688ef 100644 --- a/src/roborock_local_server/ha_addon.py +++ b/src/roborock_local_server/ha_addon.py @@ -21,6 +21,9 @@ "tls_mode": "provided", "tls_base_domain": "", "tls_email": "", + "acme_server": "zerossl", + "acme_eab_kid": "", + "acme_eab_hmac_key": "", "cloudflare_token": "", "cert_file": "", "key_file": "", @@ -33,6 +36,8 @@ DEFAULT_OPTIONS_PATH = Path("/data/options.json") DEFAULT_CONFIG_PATH = Path("/data/config.toml") DEFAULT_CLOUDFLARE_TOKEN_PATH = Path("/run/secrets/cloudflare_token") +DEFAULT_ACME_EAB_KID_PATH = Path("/run/secrets/acme_eab_kid") +DEFAULT_ACME_EAB_HMAC_KEY_PATH = Path("/run/secrets/acme_eab_hmac_key") def _toml_string(value: str) -> str: @@ -101,6 +106,13 @@ def _require_pin(value: object, *, field_name: str) -> str: return text +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 _load_options(path: Path) -> dict[str, Any]: if not path.exists(): return {} @@ -129,7 +141,9 @@ def _render_config_toml( options: dict[str, Any], config_path: Path, cloudflare_token_path: Path, -) -> tuple[str, str]: + acme_eab_kid_path: Path, + acme_eab_hmac_key_path: Path, +) -> tuple[str, dict[Path, str]]: merged = dict(DEFAULT_OPTIONS) merged.update(options) @@ -156,6 +170,9 @@ def _render_config_toml( 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() + acme_server = _normalize_acme_server(merged.get("acme_server", "zerossl"), field_name="acme_server") + acme_eab_kid = str(merged.get("acme_eab_kid", "") or "").strip() + acme_eab_hmac_key = str(merged.get("acme_eab_hmac_key", "") or "").strip() cloudflare_token = str(merged.get("cloudflare_token", "") or "").strip() cert_file = str(merged.get("cert_file", "") or "").strip() key_file = str(merged.get("key_file", "") or "").strip() @@ -165,6 +182,11 @@ def _render_config_toml( _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") + if (acme_eab_kid and not acme_eab_hmac_key) or (acme_eab_hmac_key and not acme_eab_kid): + raise ValueError("acme_eab_kid and acme_eab_hmac_key must be set together") + if acme_server == "actalis": + _require_non_empty(acme_eab_kid, field_name="acme_eab_kid") + _require_non_empty(acme_eab_hmac_key, field_name="acme_eab_hmac_key") else: _require_non_empty(cert_file, field_name="cert_file") _require_non_empty(key_file, field_name="key_file") @@ -186,6 +208,8 @@ def _render_config_toml( password_hash = hash_password(admin_password) protocol_login_pin_hash = hash_password(protocol_login_pin) cloudflare_token_file = str(cloudflare_token_path) + acme_eab_kid_file = str(acme_eab_kid_path) if acme_server == "actalis" else "" + acme_eab_hmac_key_file = str(acme_eab_hmac_key_path) if acme_server == "actalis" else "" lines = [ "[network]", @@ -216,7 +240,11 @@ def _render_config_toml( f"cloudflare_token_file = {_toml_string(cloudflare_token_file)}", "renew_days_before = 30", "renew_check_seconds = 43200", - 'acme_server = "zerossl"', + f"acme_server = {_toml_string(acme_server)}", + 'acme_eab_kid = ""', + 'acme_eab_hmac_key = ""', + f"acme_eab_kid_file = {_toml_string(acme_eab_kid_file)}", + f"acme_eab_hmac_key_file = {_toml_string(acme_eab_hmac_key_file)}", ] ) else: @@ -227,7 +255,11 @@ def _render_config_toml( 'cloudflare_token_file = ""', "renew_days_before = 30", "renew_check_seconds = 43200", - 'acme_server = "zerossl"', + f"acme_server = {_toml_string(acme_server)}", + 'acme_eab_kid = ""', + 'acme_eab_hmac_key = ""', + 'acme_eab_kid_file = ""', + 'acme_eab_hmac_key_file = ""', f"cert_file = {_toml_string(cert_file)}", f"key_file = {_toml_string(key_file)}", ] @@ -245,7 +277,13 @@ def _render_config_toml( "", ] ) - return "\n".join(lines), cloudflare_token if effective_tls_mode == "cloudflare_acme" else "" + secrets_to_write: dict[Path, str] = {} + if effective_tls_mode == "cloudflare_acme": + secrets_to_write[cloudflare_token_path] = cloudflare_token + if acme_server == "actalis": + secrets_to_write[acme_eab_kid_path] = acme_eab_kid + secrets_to_write[acme_eab_hmac_key_path] = acme_eab_hmac_key + return "\n".join(lines), secrets_to_write def write_config_from_home_assistant_options( @@ -253,22 +291,28 @@ 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, + acme_eab_kid_path: Path = DEFAULT_ACME_EAB_KID_PATH, + acme_eab_hmac_key_path: Path = DEFAULT_ACME_EAB_HMAC_KEY_PATH, ) -> Path: options = _load_options(options_path) - config_text, cloudflare_token = _render_config_toml( + config_text, secrets_to_write = _render_config_toml( options=options, config_path=config_path, cloudflare_token_path=cloudflare_token_path, + acme_eab_kid_path=acme_eab_kid_path, + acme_eab_hmac_key_path=acme_eab_hmac_key_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") + managed_secret_paths = (cloudflare_token_path, acme_eab_kid_path, acme_eab_hmac_key_path) + for path, contents in secrets_to_write.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") if os.name != "nt": - cloudflare_token_path.chmod(0o600) - elif cloudflare_token_path.exists(): - cloudflare_token_path.unlink() + path.chmod(0o600) + for path in managed_secret_paths: + if path not in secrets_to_write and path.exists(): + path.unlink() return config_path diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py index deeda1f..df30aed 100644 --- a/src/roborock_local_server/server.py +++ b/src/roborock_local_server/server.py @@ -1758,7 +1758,7 @@ def build_arg_parser() -> argparse.ArgumentParser: configure.add_argument( "--force", action="store_true", - help="Overwrite an existing config.toml and Cloudflare token file.", + help="Overwrite an existing config.toml and any generated ACME secret files.", ) repair_identities = subparsers.add_parser( diff --git a/start_onboarding.py b/start_onboarding.py index 207ca17..df5348a 100644 --- a/start_onboarding.py +++ b/start_onboarding.py @@ -41,6 +41,7 @@ DEFAULT_COUNTRY_DOMAIN = "us" DEFAULT_TIMEZONE = "America/New_York" DEFAULT_STACK_HTTPS_PORT = 555 +MAX_STACK_SERVER_LENGTH = 32 POLL_INTERVAL_SECONDS = 5.0 POLL_TIMEOUT_SECONDS = 300.0 @@ -175,7 +176,13 @@ def sanitize_stack_server(url: str) -> str: authority = _format_authority(host, port=port, default_port=443) if not authority: raise ValueError("A server host is required.") - return f"{authority}/" + stack_server = f"{authority}/" + if len(stack_server) > MAX_STACK_SERVER_LENGTH: + raise ValueError( + f"Server host is too long for onboarding: token.r must be at most " + f"{MAX_STACK_SERVER_LENGTH} characters, got {len(stack_server)} ({stack_server})." + ) + return stack_server def normalize_api_base_url(url: str) -> str: diff --git a/start_onboarding_gui.py b/start_onboarding_gui.py index 21d2380..90b48a5 100644 --- a/start_onboarding_gui.py +++ b/start_onboarding_gui.py @@ -51,6 +51,7 @@ DEFAULT_COUNTRY_DOMAIN = "us" DEFAULT_TIMEZONE = "America/New_York" DEFAULT_STACK_HTTPS_PORT = 555 +MAX_STACK_SERVER_LENGTH = 32 POLL_INTERVAL_SECONDS = 5.0 POLL_TIMEOUT_SECONDS = 300.0 @@ -185,7 +186,13 @@ def sanitize_stack_server(url: str) -> str: authority = _format_authority(host, port=port, default_port=443) if not authority: raise ValueError("A server host is required.") - return f"{authority}/" + stack_server = f"{authority}/" + if len(stack_server) > MAX_STACK_SERVER_LENGTH: + raise ValueError( + f"Server host is too long for onboarding: token.r must be at most " + f"{MAX_STACK_SERVER_LENGTH} characters, got {len(stack_server)} ({stack_server})." + ) + return stack_server def normalize_api_base_url(url: str) -> str: diff --git a/tests/test_certs.py b/tests/test_certs.py new file mode 100644 index 0000000..127d599 --- /dev/null +++ b/tests/test_certs.py @@ -0,0 +1,330 @@ +from datetime import datetime, timedelta, timezone +from pathlib import Path +import logging +import subprocess + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +import pytest +from roborock_local_server.certs import CertificateManager +from roborock_local_server.config import load_config, resolve_paths + + +def _write_certificate( + cert_path: Path, + *, + common_name: str, + san_names: list[str], +) -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.now(timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])) + .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=30)) + .add_extension(x509.SubjectAlternativeName([x509.DNSName(name) for name in san_names]), critical=False) + .sign(private_key=private_key, algorithm=hashes.SHA256()) + ) + cert_path.write_bytes(certificate.public_bytes(encoding=serialization.Encoding.PEM)) + + +def test_certificate_manager_passes_actalis_eab_to_register_account(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "example.com" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "actalis" +acme_eab_kid_file = "secrets/acme_eab_kid" +acme_eab_hmac_key_file = "secrets/acme_eab_hmac_key" + +[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) + paths = resolve_paths(config_file, config) + paths.cloudflare_token_file.parent.mkdir(parents=True, exist_ok=True) + paths.cloudflare_token_file.write_text("cloudflare-token", encoding="utf-8") + paths.acme_eab_kid_file.parent.mkdir(parents=True, exist_ok=True) + paths.acme_eab_kid_file.write_text("kid-123", encoding="utf-8") + paths.acme_eab_hmac_key_file.write_text("hmac-456", encoding="utf-8") + manager = CertificateManager(config=config, paths=paths) + calls: list[list[str]] = [] + + def fake_run_acme(args: list[str]) -> None: + calls.append(list(args)) + if "--install-cert" in args: + paths.cert_file.parent.mkdir(parents=True, exist_ok=True) + paths.key_file.parent.mkdir(parents=True, exist_ok=True) + paths.cert_file.write_text("cert", encoding="utf-8") + paths.key_file.write_text("key", encoding="utf-8") + + manager._run_acme = fake_run_acme # type: ignore[method-assign] + manager._provision_or_renew() + + assert calls[0] == [ + "--register-account", + "-m", + "acme@example.com", + "--eab-kid", + "kid-123", + "--eab-hmac-key", + "hmac-456", + ] + assert calls[1] == [ + "--issue", + "--dns", + "dns_cf", + "-d", + "api-roborock.example.com", + "--keylength", + "2048", + ] + assert calls[2][:3] == ["--install-cert", "-d", "api-roborock.example.com"] + + +def test_certificate_manager_rejects_missing_actalis_eab_files(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "example.com" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "actalis" +acme_eab_kid_file = "secrets/acme_eab_kid" +acme_eab_hmac_key_file = "secrets/acme_eab_hmac_key" + +[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) + paths = resolve_paths(config_file, config) + paths.cloudflare_token_file.parent.mkdir(parents=True, exist_ok=True) + paths.cloudflare_token_file.write_text("cloudflare-token", encoding="utf-8") + manager = CertificateManager(config=config, paths=paths) + + try: + manager._provision_or_renew() + except RuntimeError as exc: + assert "Actalis ACME requires EAB credentials" in str(exc) + else: + raise AssertionError("Expected RuntimeError for missing Actalis EAB files") + + +def test_certificate_manager_keeps_wildcard_shape_for_zerossl(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "example.com" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "zerossl" + +[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) + paths = resolve_paths(config_file, config) + paths.cloudflare_token_file.parent.mkdir(parents=True, exist_ok=True) + paths.cloudflare_token_file.write_text("cloudflare-token", encoding="utf-8") + manager = CertificateManager(config=config, paths=paths) + calls: list[list[str]] = [] + + def fake_run_acme(args: list[str]) -> None: + calls.append(list(args)) + if "--install-cert" in args: + paths.cert_file.parent.mkdir(parents=True, exist_ok=True) + paths.key_file.parent.mkdir(parents=True, exist_ok=True) + paths.cert_file.write_text("cert", encoding="utf-8") + paths.key_file.write_text("key", encoding="utf-8") + + manager._run_acme = fake_run_acme # type: ignore[method-assign] + manager._provision_or_renew() + + assert calls[1] == [ + "--issue", + "--dns", + "dns_cf", + "-d", + "example.com", + "-d", + "*.example.com", + "--keylength", + "2048", + ] + assert calls[2][:3] == ["--install-cert", "-d", "example.com"] + + +def test_run_acme_redacts_eab_credentials_in_logs_and_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "example.com" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "actalis" +acme_eab_kid = "kid-123" +acme_eab_hmac_key = "hmac-456" + +[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) + paths = resolve_paths(config_file, config) + paths.cloudflare_token_file.parent.mkdir(parents=True, exist_ok=True) + paths.cloudflare_token_file.write_text("cloudflare-token", encoding="utf-8") + manager = CertificateManager(config=config, paths=paths) + + def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=args[0], + returncode=1, + stdout="failed with kid-123 hmac-456 and cloudflare-token", + ) + + monkeypatch.setattr("roborock_local_server.certs.ACME_SH_PATH", tmp_path / "acme.sh") + (tmp_path / "acme.sh").write_text("#!/bin/sh\n", encoding="utf-8") + monkeypatch.setattr("roborock_local_server.certs.subprocess.run", fake_run) + + with caplog.at_level(logging.INFO, logger="roborock_local_server.certs"): + with pytest.raises(RuntimeError) as excinfo: + manager._run_acme( + [ + "--register-account", + "-m", + "acme@example.com", + "--eab-kid", + "kid-123", + "--eab-hmac-key", + "hmac-456", + ] + ) + + combined_logs = "\n".join(caplog.messages) + assert "kid-123" not in combined_logs + assert "hmac-456" not in combined_logs + assert "cloudflare-token" not in combined_logs + assert "" in combined_logs + assert "kid-123" not in str(excinfo.value) + assert "hmac-456" not in str(excinfo.value) + + +def test_certificate_manager_refreshes_when_cert_domains_do_not_match_config(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "example.com" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "actalis" +acme_eab_kid = "kid-123" +acme_eab_hmac_key = "hmac-456" + +[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) + paths = resolve_paths(config_file, config) + paths.cert_file.parent.mkdir(parents=True, exist_ok=True) + paths.key_file.parent.mkdir(parents=True, exist_ok=True) + _write_certificate(paths.cert_file, common_name="example.com", san_names=["example.com", "*.example.com"]) + paths.key_file.write_text("key", encoding="utf-8") + manager = CertificateManager(config=config, paths=paths) + called = {"value": False} + + def fake_provision() -> None: + called["value"] = True + + manager._provision_or_renew = fake_provision # type: ignore[method-assign] + + assert manager.ensure_certificate() is True + assert called["value"] is True diff --git a/tests/test_config.py b/tests/test_config.py index 44701c7..3489635 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -154,6 +154,7 @@ def test_load_config_normalizes_stack_fqdn_and_validates_cloudflare_base_domain( base_domain = "https://Example.com/path" email = "acme@example.com" cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "zerossl" [admin] password_hash = "pbkdf2_sha256$600000$abc$def" @@ -170,6 +171,150 @@ def test_load_config_normalizes_stack_fqdn_and_validates_cloudflare_base_domain( assert config.tls.base_domain == "example.com" +def test_load_config_requires_actalis_eab(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "example.com" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "actalis" + +[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="Actalis requires"): + load_config(config_file) + + +def test_load_config_accepts_actalis_eab_file_paths(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "example.com" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "actalis" +acme_eab_kid_file = "secrets/acme_eab_kid" +acme_eab_hmac_key_file = "secrets/acme_eab_hmac_key" + +[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) + paths = resolve_paths(config_file, config) + assert config.tls.acme_server == "actalis" + assert config.tls.acme_eab_kid == "" + assert config.tls.acme_eab_hmac_key == "" + assert paths.acme_eab_kid_file == (tmp_path / "secrets" / "acme_eab_kid").resolve() + assert paths.acme_eab_hmac_key_file == (tmp_path / "secrets" / "acme_eab_hmac_key").resolve() + + +def test_load_config_accepts_legacy_inline_actalis_eab(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "example.com" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "actalis" +acme_eab_kid = "kid-123" +acme_eab_hmac_key = "hmac-456" + +[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.tls.acme_eab_kid == "kid-123" + assert config.tls.acme_eab_hmac_key == "hmac-456" + + +def test_load_config_normalizes_mixed_case_actalis(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[network] +stack_fqdn = "api-roborock.example.com" + +[broker] +mode = "embedded" + +[storage] +data_dir = "data" + +[tls] +mode = "cloudflare_acme" +base_domain = "example.com" +email = "acme@example.com" +cloudflare_token_file = "secrets/cloudflare_token" +acme_server = "Actalis" +acme_eab_kid = "kid-123" +acme_eab_hmac_key = "hmac-456" + +[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.tls.acme_server == "actalis" + + def test_load_config_rejects_invalid_ports(tmp_path: Path) -> None: config_file = tmp_path / "config.toml" config_file.write_text( diff --git a/tests/test_configure.py b/tests/test_configure.py index af6485a..7d830fd 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -2,8 +2,9 @@ import pytest +import roborock_local_server.configure as configure_module from roborock_local_server.config import load_config -from roborock_local_server.configure import ConfigureAnswers, _validate_protocol_login_pin, write_config_setup +from roborock_local_server.configure import ConfigureAnswers, _validate_protocol_login_pin, collect_configure_answers, write_config_setup def _answers( @@ -12,6 +13,9 @@ def _answers( mqtt_tls_port: int = 8881, broker_mode: str = "embedded", tls_mode: str = "cloudflare_acme", + acme_server: str = "zerossl", + acme_eab_kid: str = "", + acme_eab_hmac_key: str = "", ) -> ConfigureAnswers: return ConfigureAnswers( stack_fqdn="api-roborock.example.com", @@ -21,6 +25,9 @@ def _answers( tls_mode=tls_mode, base_domain="example.com" if tls_mode == "cloudflare_acme" else "", email="you@example.com" if tls_mode == "cloudflare_acme" else "", + acme_server=acme_server, + acme_eab_kid=acme_eab_kid, + acme_eab_hmac_key=acme_eab_hmac_key, cloudflare_token="cloudflare-token" if tls_mode == "cloudflare_acme" else "", password_hash="pbkdf2_sha256$600000$abc$def", session_secret="abcdefghijklmnopqrstuvwxyz123456", @@ -37,6 +44,8 @@ def test_write_config_setup_embedded_cloudflare(tmp_path: Path) -> None: assert result.config_file == config_file.resolve() assert result.cloudflare_token_file == (tmp_path / "secrets" / "cloudflare_token").resolve() assert result.cloudflare_token_file.read_text(encoding="utf-8") == "cloudflare-token" + assert result.actalis_eab_kid_file is None + assert result.actalis_eab_hmac_key_file is None assert not result.broker_template_needs_edit config = load_config(result.config_file) @@ -48,6 +57,7 @@ def test_write_config_setup_embedded_cloudflare(tmp_path: Path) -> None: assert config.broker.port == 18830 assert config.tls.mode == "cloudflare_acme" assert config.tls.cloudflare_token_file == "/run/secrets/cloudflare_token" + assert config.tls.acme_server == "zerossl" assert config.admin.protocol_auth_enabled is True assert config.admin.protocol_login_email == "user@example.com" @@ -102,3 +112,78 @@ def test_validate_protocol_login_pin_requires_exactly_six_digits() -> None: with pytest.raises(ValueError, match="exactly 6 digits"): _validate_protocol_login_pin("12345a") + + +def test_write_config_setup_embedded_actalis(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + + result = write_config_setup( + config_file=config_file, + answers=_answers(acme_server="actalis", acme_eab_kid="kid-123", acme_eab_hmac_key="hmac-456"), + ) + + config = load_config(result.config_file) + assert config.tls.acme_server == "actalis" + assert config.tls.acme_eab_kid == "" + assert config.tls.acme_eab_hmac_key == "" + assert config.tls.acme_eab_kid_file == "/run/secrets/acme_eab_kid" + assert config.tls.acme_eab_hmac_key_file == "/run/secrets/acme_eab_hmac_key" + assert result.actalis_eab_kid_file == (tmp_path / "secrets" / "acme_eab_kid").resolve() + assert result.actalis_eab_hmac_key_file == (tmp_path / "secrets" / "acme_eab_hmac_key").resolve() + assert result.actalis_eab_kid_file.read_text(encoding="utf-8") == "kid-123" + assert result.actalis_eab_hmac_key_file.read_text(encoding="utf-8") == "hmac-456" + + +def test_write_config_setup_rejects_blank_actalis_credentials(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + + with pytest.raises(ValueError, match="Actalis requires both"): + write_config_setup( + config_file=config_file, + answers=_answers(acme_server="actalis"), + ) + + +def test_collect_configure_answers_hides_actalis_hmac_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + prompts: list[str] = [] + input_values = iter( + [ + "api-roborock.example.com", + "", + "", + "", + "", + "example.com", + "acme@example.com", + "y", + "kid-123", + "user@example.com", + ] + ) + secret_values = iter( + [ + "hmac-456", + "cloudflare-token", + "admin-password", + "123456", + "123456", + ] + ) + + def fake_input(prompt: str) -> str: + prompts.append(prompt) + return next(input_values) + + def fake_getpass(prompt: str) -> str: + prompts.append(prompt) + return next(secret_values) + + monkeypatch.setattr("builtins.input", fake_input) + monkeypatch.setattr(configure_module, "getpass", fake_getpass) + + answers = collect_configure_answers() + + assert answers.acme_server == "actalis" + assert answers.acme_eab_kid == "kid-123" + assert answers.acme_eab_hmac_key == "hmac-456" + assert "Actalis EAB HMAC key (input hidden): " in prompts diff --git a/tests/test_ha_addon.py b/tests/test_ha_addon.py index 36edd56..73232f7 100644 --- a/tests/test_ha_addon.py +++ b/tests/test_ha_addon.py @@ -185,6 +185,7 @@ def test_write_config_from_home_assistant_options_cloudflare(tmp_path: Path) -> "tls_mode": "cloudflare_acme", "tls_base_domain": "example.com", "tls_email": "acme@example.com", + "acme_server": "zerossl", "cloudflare_token": "cloudflare-token-123", "admin_password": "secret", "protocol_login_email": "user@example.com", @@ -202,6 +203,7 @@ def test_write_config_from_home_assistant_options_cloudflare(tmp_path: Path) -> assert parsed["tls"]["mode"] == "cloudflare_acme" assert parsed["tls"]["base_domain"] == "example.com" assert parsed["tls"]["email"] == "acme@example.com" + assert parsed["tls"]["acme_server"] == "zerossl" assert parsed["tls"]["cloudflare_token_file"] == str(token_path) assert token_path.read_text(encoding="utf-8") == "cloudflare-token-123" @@ -218,6 +220,7 @@ def test_write_config_from_home_assistant_options_infers_cloudflare_acme_from_to "tls_mode": "provided", "tls_base_domain": "example.com", "tls_email": "acme@example.com", + "acme_server": "zerossl", "cloudflare_token": "cloudflare-token-123", "cert_file": "", "key_file": "", @@ -237,12 +240,106 @@ def test_write_config_from_home_assistant_options_infers_cloudflare_acme_from_to assert parsed["tls"]["mode"] == "cloudflare_acme" assert parsed["tls"]["base_domain"] == "example.com" assert parsed["tls"]["email"] == "acme@example.com" + assert parsed["tls"]["acme_server"] == "zerossl" 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_actalis_requires_eab(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": "cloudflare_acme", + "tls_base_domain": "example.com", + "tls_email": "acme@example.com", + "acme_server": "actalis", + "cloudflare_token": "cloudflare-token-123", + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + with pytest.raises(ValueError, match="acme_eab_kid is required"): + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + +def test_write_config_from_home_assistant_options_actalis_writes_eab(tmp_path: Path) -> None: + options_path = tmp_path / "options.json" + config_path = tmp_path / "config.toml" + token_path = tmp_path / "run" / "secrets" / "cloudflare_token" + kid_path = tmp_path / "run" / "secrets" / "acme_eab_kid" + hmac_path = tmp_path / "run" / "secrets" / "acme_eab_hmac_key" + + _write_options( + options_path, + { + "stack_fqdn": "api-roborock.example.com", + "tls_mode": "cloudflare_acme", + "tls_base_domain": "example.com", + "tls_email": "acme@example.com", + "acme_server": "actalis", + "acme_eab_kid": "kid-123", + "acme_eab_hmac_key": "hmac-456", + "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, + acme_eab_kid_path=kid_path, + acme_eab_hmac_key_path=hmac_path, + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["acme_server"] == "actalis" + assert parsed["tls"]["acme_eab_kid"] == "" + assert parsed["tls"]["acme_eab_hmac_key"] == "" + assert parsed["tls"]["acme_eab_kid_file"] == str(kid_path) + assert parsed["tls"]["acme_eab_hmac_key_file"] == str(hmac_path) + assert kid_path.read_text(encoding="utf-8") == "kid-123" + assert hmac_path.read_text(encoding="utf-8") == "hmac-456" + + +def test_write_config_from_home_assistant_options_rejects_invalid_acme_server(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", + "acme_server": "bad-ca", + "cert_file": "/ssl/fullchain.pem", + "key_file": "/ssl/privkey.pem", + "admin_password": "secret", + "protocol_login_email": "user@example.com", + "protocol_login_pin": "654321", + }, + ) + + with pytest.raises(ValueError, match="acme_server must be 'zerossl' or 'actalis'"): + write_config_from_home_assistant_options( + options_path=options_path, + config_path=config_path, + ) + + 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" @@ -316,8 +413,12 @@ def test_write_config_from_home_assistant_options_removes_stale_cloudflare_token options_path = tmp_path / "options.json" config_path = tmp_path / "config.toml" token_path = tmp_path / "run" / "secrets" / "cloudflare_token" + kid_path = tmp_path / "run" / "secrets" / "acme_eab_kid" + hmac_path = tmp_path / "run" / "secrets" / "acme_eab_hmac_key" token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_text("stale-token", encoding="utf-8") + kid_path.write_text("stale-kid", encoding="utf-8") + hmac_path.write_text("stale-hmac", encoding="utf-8") _write_options( options_path, @@ -336,6 +437,10 @@ def test_write_config_from_home_assistant_options_removes_stale_cloudflare_token options_path=options_path, config_path=config_path, cloudflare_token_path=token_path, + acme_eab_kid_path=kid_path, + acme_eab_hmac_key_path=hmac_path, ) assert token_path.exists() is False + assert kid_path.exists() is False + assert hmac_path.exists() is False diff --git a/tests/test_home_data_online.py b/tests/test_home_data_online.py index b9902b5..6475cda 100644 --- a/tests/test_home_data_online.py +++ b/tests/test_home_data_online.py @@ -229,10 +229,14 @@ def test_home_data_response_does_not_emit_empty_snake_case_received_devices(tmp_ payload = response.json() assert "received_devices" not in payload["data"] assert "received_devices" not in payload["result"] + assert payload["data"]["receivedDevices"] == [] + assert [device["duid"] for device in payload["data"]["devices"]] == ["6HL2zfniaoYYV01CkVuhkO"] parsed_home = HomeData.from_dict(payload["result"]) assert parsed_home is not None - assert len(parsed_home.received_devices) == 1 + assert len(parsed_home.devices) == 1 + assert parsed_home.devices[0].duid == "6HL2zfniaoYYV01CkVuhkO" + assert parsed_home.received_devices == [] assert len(parsed_home.device_products) == 1 assert "6HL2zfniaoYYV01CkVuhkO" in parsed_home.device_products diff --git a/tests/test_onboarding_cli.py b/tests/test_onboarding_cli.py index c8914c0..7e92708 100644 --- a/tests/test_onboarding_cli.py +++ b/tests/test_onboarding_cli.py @@ -415,6 +415,12 @@ def test_onboarding_server_normalization_defaults_to_port_555() -> None: assert sanitize_stack_server("https://api-roborock.example.com") == "roborock.example.com:555/" +def test_onboarding_server_normalization_enforces_32_char_limit() -> None: + assert sanitize_stack_server("abcdefghijklmno.example.com:555") == "abcdefghijklmno.example.com:555/" + with pytest.raises(ValueError, match="token.r must be at most 32 characters, got 33"): + sanitize_stack_server("abcdefghijklmnop.example.com:555") + + def test_remote_onboarding_api_uses_custom_port_base_url() -> None: opener = _RecordingOpener() api = RemoteOnboardingApi( diff --git a/tests/test_onboarding_gui.py b/tests/test_onboarding_gui.py index 57b5adb..34f34ee 100644 --- a/tests/test_onboarding_gui.py +++ b/tests/test_onboarding_gui.py @@ -37,3 +37,9 @@ def test_gui_server_normalization_supports_default_and_custom_ports( def test_gui_server_normalization_rejects_non_numeric_port() -> None: with pytest.raises(ValueError, match="Server port must be numeric."): normalize_api_base_url("api-roborock.example.com:not-a-port") + + +def test_gui_server_normalization_enforces_32_char_limit() -> None: + assert sanitize_stack_server("abcdefghijklmno.example.com:555") == "abcdefghijklmno.example.com:555/" + with pytest.raises(ValueError, match="token.r must be at most 32 characters, got 33"): + sanitize_stack_server("abcdefghijklmnop.example.com:555")