Skip to content

Commit d66f260

Browse files
committed
address copilot comments
1 parent 38d8cac commit d66f260

6 files changed

Lines changed: 153 additions & 8 deletions

File tree

src/roborock_local_server/certs.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ def _needs_refresh(self) -> bool:
5656
except Exception:
5757
return True
5858
deadline = datetime.now(timezone.utc) + timedelta(days=self.config.tls.renew_days_before)
59-
return cert.not_valid_after_utc <= deadline
59+
if cert.not_valid_after_utc <= deadline:
60+
return True
61+
return not self._certificate_matches_configuration(cert)
6062

6163
def _read_cloudflare_token(self) -> str:
6264
token = self.paths.cloudflare_token_file.read_text(encoding="utf-8").strip()
@@ -96,6 +98,36 @@ def _redact_command(command: list[str]) -> str:
9698
redact_next = True
9799
return " ".join(redacted)
98100

101+
@staticmethod
102+
def _redact_text(text: str, sensitive_values: Iterable[str]) -> str:
103+
redacted = text
104+
for value in sorted({item for item in sensitive_values if item}, key=len, reverse=True):
105+
redacted = redacted.replace(value, "<redacted>")
106+
return redacted
107+
108+
@staticmethod
109+
def _sensitive_values(command: list[str], env: dict[str, str]) -> list[str]:
110+
sensitive_values: list[str] = []
111+
for index, part in enumerate(command[:-1]):
112+
if part in {"--eab-kid", "--eab-hmac-key"}:
113+
sensitive_values.append(command[index + 1])
114+
cloudflare_token = env.get("CF_Token", "").strip()
115+
if cloudflare_token:
116+
sensitive_values.append(cloudflare_token)
117+
return sensitive_values
118+
119+
def _certificate_matches_configuration(self, cert: x509.Certificate) -> bool:
120+
try:
121+
san_names = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value.get_values_for_type(
122+
x509.DNSName
123+
)
124+
except x509.ExtensionNotFound:
125+
return False
126+
actual_domains = {name.strip().lower() for name in san_names if name.strip()}
127+
_primary_domain, issue_domains = self._certificate_domains()
128+
expected_domains = {domain.strip().lower() for domain in issue_domains if domain.strip()}
129+
return actual_domains == expected_domains
130+
99131
def _run_acme(self, args: Iterable[str]) -> None:
100132
self.paths.acme_dir.mkdir(parents=True, exist_ok=True)
101133
if not ACME_SH_PATH.exists():
@@ -111,6 +143,7 @@ def _run_acme(self, args: Iterable[str]) -> None:
111143
self.config.tls.acme_server,
112144
]
113145
display_command = self._redact_command(command)
146+
sensitive_values = self._sensitive_values(command, env)
114147
LOG.info("Running ACME command: %s", display_command)
115148
result = subprocess.run(
116149
command,
@@ -121,7 +154,7 @@ def _run_acme(self, args: Iterable[str]) -> None:
121154
check=False,
122155
)
123156
if result.stdout.strip():
124-
LOG.info("ACME output:\n%s", result.stdout.strip())
157+
LOG.info("ACME output:\n%s", self._redact_text(result.stdout.strip(), sensitive_values))
125158
if result.returncode != 0:
126159
raise RuntimeError(f"ACME command failed ({result.returncode}): {display_command}")
127160

src/roborock_local_server/ha_addon.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ def _require_pin(value: object, *, field_name: str) -> str:
106106
return text
107107

108108

109+
def _normalize_acme_server(value: object, *, field_name: str) -> str:
110+
normalized = str(value or "").strip().lower() or "zerossl"
111+
if normalized not in {"zerossl", "actalis"}:
112+
raise ValueError(f"{field_name} must be 'zerossl' or 'actalis'")
113+
return normalized
114+
115+
109116
def _load_options(path: Path) -> dict[str, Any]:
110117
if not path.exists():
111118
return {}
@@ -163,7 +170,7 @@ def _render_config_toml(
163170
raise ValueError("tls_mode must be 'provided' or 'cloudflare_acme'")
164171
tls_base_domain = str(merged.get("tls_base_domain", "") or "").strip()
165172
tls_email = str(merged.get("tls_email", "") or "").strip()
166-
acme_server = str(merged.get("acme_server", "zerossl") or "zerossl").strip().lower() or "zerossl"
173+
acme_server = _normalize_acme_server(merged.get("acme_server", "zerossl"), field_name="acme_server")
167174
acme_eab_kid = str(merged.get("acme_eab_kid", "") or "").strip()
168175
acme_eab_hmac_key = str(merged.get("acme_eab_hmac_key", "") or "").strip()
169176
cloudflare_token = str(merged.get("cloudflare_token", "") or "").strip()

tests/test_certs.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,39 @@
1+
from datetime import datetime, timedelta, timezone
12
from pathlib import Path
23
import logging
34
import subprocess
45

6+
from cryptography import x509
7+
from cryptography.hazmat.primitives import hashes, serialization
8+
from cryptography.hazmat.primitives.asymmetric import rsa
9+
from cryptography.x509.oid import NameOID
510
import pytest
611
from roborock_local_server.certs import CertificateManager
712
from roborock_local_server.config import load_config, resolve_paths
813

914

15+
def _write_certificate(
16+
cert_path: Path,
17+
*,
18+
common_name: str,
19+
san_names: list[str],
20+
) -> None:
21+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
22+
now = datetime.now(timezone.utc)
23+
certificate = (
24+
x509.CertificateBuilder()
25+
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]))
26+
.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]))
27+
.public_key(private_key.public_key())
28+
.serial_number(x509.random_serial_number())
29+
.not_valid_before(now - timedelta(days=1))
30+
.not_valid_after(now + timedelta(days=30))
31+
.add_extension(x509.SubjectAlternativeName([x509.DNSName(name) for name in san_names]), critical=False)
32+
.sign(private_key=private_key, algorithm=hashes.SHA256())
33+
)
34+
cert_path.write_bytes(certificate.public_bytes(encoding=serialization.Encoding.PEM))
35+
36+
1037
def test_certificate_manager_passes_actalis_eab_to_register_account(tmp_path: Path) -> None:
1138
config_file = tmp_path / "config.toml"
1239
config_file.write_text(
@@ -222,7 +249,11 @@ def test_run_acme_redacts_eab_credentials_in_logs_and_errors(
222249
manager = CertificateManager(config=config, paths=paths)
223250

224251
def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
225-
return subprocess.CompletedProcess(args=args[0], returncode=1, stdout="failed")
252+
return subprocess.CompletedProcess(
253+
args=args[0],
254+
returncode=1,
255+
stdout="failed with kid-123 hmac-456 and cloudflare-token",
256+
)
226257

227258
monkeypatch.setattr("roborock_local_server.certs.ACME_SH_PATH", tmp_path / "acme.sh")
228259
(tmp_path / "acme.sh").write_text("#!/bin/sh\n", encoding="utf-8")
@@ -245,6 +276,55 @@ def fake_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str
245276
combined_logs = "\n".join(caplog.messages)
246277
assert "kid-123" not in combined_logs
247278
assert "hmac-456" not in combined_logs
279+
assert "cloudflare-token" not in combined_logs
248280
assert "<redacted>" in combined_logs
249281
assert "kid-123" not in str(excinfo.value)
250282
assert "hmac-456" not in str(excinfo.value)
283+
284+
285+
def test_certificate_manager_refreshes_when_cert_domains_do_not_match_config(tmp_path: Path) -> None:
286+
config_file = tmp_path / "config.toml"
287+
config_file.write_text(
288+
"""
289+
[network]
290+
stack_fqdn = "api-roborock.example.com"
291+
292+
[broker]
293+
mode = "embedded"
294+
295+
[storage]
296+
data_dir = "data"
297+
298+
[tls]
299+
mode = "cloudflare_acme"
300+
base_domain = "example.com"
301+
email = "acme@example.com"
302+
cloudflare_token_file = "secrets/cloudflare_token"
303+
acme_server = "actalis"
304+
acme_eab_kid = "kid-123"
305+
acme_eab_hmac_key = "hmac-456"
306+
307+
[admin]
308+
password_hash = "pbkdf2_sha256$600000$abc$def"
309+
session_secret = "abcdefghijklmnopqrstuvwxyz123456"
310+
protocol_login_email = "user@example.com"
311+
protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl"
312+
""".strip(),
313+
encoding="utf-8",
314+
)
315+
config = load_config(config_file)
316+
paths = resolve_paths(config_file, config)
317+
paths.cert_file.parent.mkdir(parents=True, exist_ok=True)
318+
paths.key_file.parent.mkdir(parents=True, exist_ok=True)
319+
_write_certificate(paths.cert_file, common_name="example.com", san_names=["example.com", "*.example.com"])
320+
paths.key_file.write_text("key", encoding="utf-8")
321+
manager = CertificateManager(config=config, paths=paths)
322+
called = {"value": False}
323+
324+
def fake_provision() -> None:
325+
called["value"] = True
326+
327+
manager._provision_or_renew = fake_provision # type: ignore[method-assign]
328+
329+
assert manager.ensure_certificate() is True
330+
assert called["value"] is True

tests/test_ha_addon.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,31 @@ def test_write_config_from_home_assistant_options_actalis_writes_eab(tmp_path: P
315315
assert hmac_path.read_text(encoding="utf-8") == "hmac-456"
316316

317317

318+
def test_write_config_from_home_assistant_options_rejects_invalid_acme_server(tmp_path: Path) -> None:
319+
options_path = tmp_path / "options.json"
320+
config_path = tmp_path / "config.toml"
321+
322+
_write_options(
323+
options_path,
324+
{
325+
"stack_fqdn": "api-roborock.example.com",
326+
"tls_mode": "provided",
327+
"acme_server": "bad-ca",
328+
"cert_file": "/ssl/fullchain.pem",
329+
"key_file": "/ssl/privkey.pem",
330+
"admin_password": "secret",
331+
"protocol_login_email": "user@example.com",
332+
"protocol_login_pin": "654321",
333+
},
334+
)
335+
336+
with pytest.raises(ValueError, match="acme_server must be 'zerossl' or 'actalis'"):
337+
write_config_from_home_assistant_options(
338+
options_path=options_path,
339+
config_path=config_path,
340+
)
341+
342+
318343
def test_write_config_from_home_assistant_options_rejects_external_tls(tmp_path: Path) -> None:
319344
options_path = tmp_path / "options.json"
320345
config_path = tmp_path / "config.toml"

tests/test_onboarding_cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,9 @@ def test_onboarding_server_normalization_defaults_to_port_555() -> None:
416416

417417

418418
def test_onboarding_server_normalization_enforces_32_char_limit() -> None:
419-
assert sanitize_stack_server("roborockss.luke-lashley.com:555") == "roborockss.luke-lashley.com:555/"
419+
assert sanitize_stack_server("abcdefghijklmno.example.com:555") == "abcdefghijklmno.example.com:555/"
420420
with pytest.raises(ValueError, match="token.r must be at most 32 characters, got 33"):
421-
sanitize_stack_server("roborocksss.luke-lashley.com:555")
421+
sanitize_stack_server("abcdefghijklmnop.example.com:555")
422422

423423

424424
def test_remote_onboarding_api_uses_custom_port_base_url() -> None:

tests/test_onboarding_gui.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ def test_gui_server_normalization_rejects_non_numeric_port() -> None:
4040

4141

4242
def test_gui_server_normalization_enforces_32_char_limit() -> None:
43-
assert sanitize_stack_server("roborockss.luke-lashley.com:555") == "roborockss.luke-lashley.com:555/"
43+
assert sanitize_stack_server("abcdefghijklmno.example.com:555") == "abcdefghijklmno.example.com:555/"
4444
with pytest.raises(ValueError, match="token.r must be at most 32 characters, got 33"):
45-
sanitize_stack_server("roborocksss.luke-lashley.com:555")
45+
sanitize_stack_server("abcdefghijklmnop.example.com:555")

0 commit comments

Comments
 (0)