diff --git a/stelloauth/Dockerfile b/stelloauth/Dockerfile new file mode 100644 index 0000000..50c735e --- /dev/null +++ b/stelloauth/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1.7 +FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS stelloauth-builder + +ARG TARGETARCH +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git patch \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +RUN git clone --filter=blob:none https://github.com/tamcore/stelloauth.git . \ + && git checkout --detach 367d4f8c02a3b072c59142c49dffc129edc8548b \ + && test "$(git rev-parse HEAD)" = "367d4f8c02a3b072c59142c49dffc129edc8548b" +COPY patches/stelloauth-security.patch /tmp/stelloauth-security.patch +RUN git apply --check /tmp/stelloauth-security.patch \ + && git apply /tmp/stelloauth-security.patch \ + && go test ./... \ + && CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" go build -trimpath -ldflags="-s -w" -o /out/stelloauth ./cmd/stelloauth + +FROM cloakhq/cloakbrowser:0.5.10@sha256:2ed5b2d047cbdde22cde7ef1a796526c716aadaa5bccbe1db5ade49282b64a76 + +ARG BUILD_ARCH +ARG BUILD_DATE +ARG BUILD_DESCRIPTION="Local OAuth worker for Stellantis Vehicles using CloakBrowser" +ARG BUILD_NAME="Stelloauth" +ARG BUILD_REF +ARG BUILD_REPOSITORY="https://git.radixadm.dk/dennis/homeassistant-stelloauth-addon" +ARG BUILD_VERSION="0.1.0" +LABEL io.hass.name="$BUILD_NAME" \ + io.hass.description="$BUILD_DESCRIPTION" \ + io.hass.arch="$BUILD_ARCH" \ + io.hass.type="addon" \ + io.hass.version="$BUILD_VERSION" \ + org.opencontainers.image.created="$BUILD_DATE" \ + org.opencontainers.image.revision="$BUILD_REF" \ + org.opencontainers.image.source="$BUILD_REPOSITORY" \ + org.opencontainers.image.version="$BUILD_VERSION" + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends patch \ + && rm -rf /var/lib/apt/lists/* +COPY patches/cloakserve-loopback.patch /tmp/cloakserve-loopback.patch +RUN patch --dry-run -p2 -d /usr/local/bin < /tmp/cloakserve-loopback.patch \ + && patch -p2 -d /usr/local/bin < /tmp/cloakserve-loopback.patch \ + && rm /tmp/cloakserve-loopback.patch \ + && apt-get purge -y --auto-remove patch \ + && rm -rf /var/lib/apt/lists/* +COPY --from=stelloauth-builder /out/stelloauth /usr/local/bin/stelloauth +COPY rootfs/ / +RUN chmod 0755 /usr/local/bin/stelloauth /usr/local/bin/addon-supervisor /usr/local/bin/cloakserve \ + && mkdir -p /data /tmp/cloakserve \ + && chmod 0700 /tmp/cloakserve + +EXPOSE 8080 +ENTRYPOINT [] +CMD ["/usr/local/bin/addon-supervisor"] diff --git a/stelloauth/rootfs/usr/local/bin/addon-supervisor b/stelloauth/rootfs/usr/local/bin/addon-supervisor index 5554ab1..4caa8b9 100755 --- a/stelloauth/rootfs/usr/local/bin/addon-supervisor +++ b/stelloauth/rootfs/usr/local/bin/addon-supervisor @@ -31,6 +31,7 @@ CLOAK_COMMAND = [ "--data-dir=/tmp/cloakserve", ] STELLOAUTH_COMMAND = ["/usr/local/bin/stelloauth"] +SHUTDOWN_GRACE_SECONDS = 9.0 class ConfigError(RuntimeError): @@ -297,7 +298,7 @@ class ProcessManager: def _begin_shutdown(self) -> None: if self._shutdown_deadline is None: - self._shutdown_deadline = self._monotonic() + 10.0 + self._shutdown_deadline = self._monotonic() + SHUTDOWN_GRACE_SECONDS if not self._term_sent: self._signal_groups(signal.SIGTERM) self._term_sent = True diff --git a/tests/test_addon_metadata.py b/tests/test_addon_metadata.py index 05111fa..090fce7 100644 --- a/tests/test_addon_metadata.py +++ b/tests/test_addon_metadata.py @@ -1,5 +1,6 @@ from __future__ import annotations import hashlib +import re from pathlib import Path import yaml @@ -54,3 +55,48 @@ def test_translations_cover_every_option() -> None: for entry in translation["configuration"].values(): assert set(entry) == {"name", "description"} assert all(isinstance(value, str) and value.strip() for value in entry.values()) + + +def test_dockerfile_uses_approved_pins_and_builds_patched_stelloauth() -> None: + dockerfile = (ROOT / "stelloauth/Dockerfile").read_text(encoding="utf-8") + assert ( + "golang:1.27.1-bookworm@sha256:" + "69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195" + ) in dockerfile + assert ( + "cloakhq/cloakbrowser:0.5.10@sha256:" + "2ed5b2d047cbdde22cde7ef1a796526c716aadaa5bccbe1db5ade49282b64a76" + ) in dockerfile + assert "367d4f8c02a3b072c59142c49dffc129edc8548b" in dockerfile + assert "go test ./..." in dockerfile + assert not re.search(r"^FROM\s+\S+:latest(?:\s|$)", dockerfile, re.MULTILINE) + + +def test_dockerfile_declares_home_assistant_runtime_contract() -> None: + dockerfile = (ROOT / "stelloauth/Dockerfile").read_text(encoding="utf-8") + for label in ( + "io.hass.name", + "io.hass.description", + "io.hass.arch", + "io.hass.type", + "io.hass.version", + ): + assert label in dockerfile + assert re.search(r"^EXPOSE 8080$", dockerfile, re.MULTILINE) + assert not re.search(r"^EXPOSE .*\b9222\b", dockerfile, re.MULTILINE) + assert "ENTRYPOINT []" in dockerfile + assert 'CMD ["/usr/local/bin/addon-supervisor"]' in dockerfile + + +def test_dockerfile_patches_parent_cloakserve_instead_of_copying_a_binary() -> None: + dockerfile = (ROOT / "stelloauth/Dockerfile").read_text(encoding="utf-8") + assert "COPY patches/cloakserve-loopback.patch" in dockerfile + for line in dockerfile.splitlines(): + if line.lstrip().startswith("COPY "): + source = line.split()[1] + assert Path(source).name != "cloakserve" + + +def test_runtime_accepts_docker_port_unpublished_status() -> None: + runtime_test = (ROOT / "tests/test_runtime.sh").read_text(encoding="utf-8") + assert 'docker port "$container" 9222/tcp 2>/dev/null || true' in runtime_test diff --git a/tests/test_runtime.sh b/tests/test_runtime.sh new file mode 100755 index 0000000..daa905c --- /dev/null +++ b/tests/test_runtime.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +image="homeassistant-stelloauth-addon:test" +run_id="$(date +%s)-$$" +first_container="stelloauth-runtime-${run_id}-first" +second_container="stelloauth-runtime-${run_id}-second" +tmp_dir="$(mktemp -d)" +artifacts_dir="${repo_root}/artifacts" +options_file="${tmp_dir}/options.json" + +sentinels=( + "sentinel-email@example.invalid" + "SENTINEL_PASSWORD_9a34" + "SENTINEL_COOKIE_7b21" + "SENTINEL_OAUTH_CODE_5c88" + "SENTINEL_ACCESS_TOKEN_1d62" + "SENTINEL_REFRESH_TOKEN_4e73" +) + +cleanup() { + set +e + for container in "$first_container" "$second_container"; do + if docker container inspect "$container" >/dev/null 2>&1; then + if [ "$(docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null)" = "true" ]; then + docker stop --time 10 "$container" >/dev/null 2>&1 + fi + docker rm "$container" >/dev/null 2>&1 + fi + done + rm -r "$tmp_dir" +} +trap cleanup EXIT + +fail() { + printf 'runtime test failed: %s\n' "$*" >&2 + exit 1 +} + +write_options() { + python3 - "$options_file" <<'PY' +import json +import pathlib +import sys + +options = { + "queue_timeout": "60s", + "rate_limit_count": 5, + "rate_limit_duration": "1h", +} +pathlib.Path(sys.argv[1]).write_text( + json.dumps(options, separators=(",", ":")) + "\n", + encoding="utf-8", +) +PY +} + +start_container() { + local container="$1" + docker run --detach \ + --name "$container" \ + --platform linux/amd64 \ + --mount "type=bind,src=${options_file},dst=/data/options.json,readonly" \ + --publish 127.0.0.1::8080 \ + "$image" >/dev/null +} + +host_port() { + local container="$1" + local mapping + mapping="$(docker port "$container" 8080/tcp)" + [ -n "$mapping" ] || fail "container 8080 has no host mapping" + printf '%s\n' "${mapping##*:}" +} + +probe_root() { + local port="$1" + python3 - "$port" <<'PY' +import sys +import urllib.request + +opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) +with opener.open(f"http://127.0.0.1:{sys.argv[1]}/", timeout=2) as response: + if response.status != 200: + raise SystemExit(f"root status {response.status}") + response.read() +PY +} + +wait_ready() { + local container="$1" + local port="$2" + local deadline=$((SECONDS + 90)) + while (( SECONDS < deadline )); do + if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != "true" ]; then + docker logs "$container" >&2 + fail "$container exited during readiness" + fi + if docker logs "$container" 2>&1 | grep -Fq "Stelloauth listening on 0.0.0.0:8080"; then + if probe_root "$port" >/dev/null 2>&1; then + return + fi + fi + sleep 1 + done + docker logs "$container" >&2 + fail "$container did not become ready within 90 seconds" +} + +assert_loopback_cdp_listener() { + local container="$1" + local artifact="$2" + docker exec "$container" cat /proc/net/tcp > "$artifact" + docker exec -i "$container" python3 - <<'PY' +expected = f"0100007F:{9222:04X}" +wildcard = f"00000000:{9222:04X}" +if expected != "0100007F:2406": + raise SystemExit(f"unexpected 9222 hexadecimal encoding: {expected}") + +listeners = set() +with open("/proc/net/tcp", encoding="ascii") as handle: + next(handle) + for line in handle: + fields = line.split() + if len(fields) >= 4 and fields[3] == "0A": + listeners.add(fields[1].upper()) + +if expected not in listeners: + raise SystemExit(f"missing loopback CDP listener {expected}: {sorted(listeners)}") +if wildcard in listeners: + raise SystemExit(f"wildcard CDP listener present: {wildcard}") +PY +} + +probe_and_close_cdp() { + local container="$1" + docker exec -i "$container" python3 - <<'PY' +import json +import urllib.request + +opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) +version_url = "http://127.0.0.1:9222/json/version?fingerprint=runtime-readiness" +close_url = "http://127.0.0.1:9222/fingerprint/runtime-readiness/close" +with opener.open(version_url, timeout=10) as response: + if response.status != 200: + raise SystemExit(f"CDP version status {response.status}") + document = json.load(response) +websocket_url = document.get("webSocketDebuggerUrl") +if not isinstance(websocket_url, str) or not websocket_url: + raise SystemExit("CDP response lacks webSocketDebuggerUrl") +request = urllib.request.Request(close_url, data=b"", method="POST") +with opener.open(request, timeout=10) as response: + if response.status != 200: + raise SystemExit(f"CDP close status {response.status}") + response.read() +PY +} + +post_invalid_worker() { + local port="$1" + local response_artifact="$2" + python3 - "$port" "$response_artifact" <<'PY' +import json +import pathlib +import sys +import urllib.error +import urllib.request + +port, artifact = sys.argv[1:] +body = { + "url": ( + "https://example.invalid/am/oauth2/authorize" + "?redirect_uri=sentinel%3A%2F%2Fcallback" + "&code=SENTINEL_OAUTH_CODE_5c88" + "&access_token=SENTINEL_ACCESS_TOKEN_1d62" + "&refresh_token=SENTINEL_REFRESH_TOKEN_4e73" + "&cookie=SENTINEL_COOKIE_7b21" + ), + "email": "sentinel-email@example.invalid", + "password": "SENTINEL_PASSWORD_9a34", + "cookie": "SENTINEL_COOKIE_7b21", + "oauth_code": "SENTINEL_OAUTH_CODE_5c88", + "access_token": "SENTINEL_ACCESS_TOKEN_1d62", + "refresh_token": "SENTINEL_REFRESH_TOKEN_4e73", +} +request = urllib.request.Request( + f"http://127.0.0.1:{port}/worker", + data=json.dumps(body, separators=(",", ":")).encode(), + headers={"Content-Type": "application/json"}, + method="POST", +) +opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) +try: + with opener.open(request, timeout=10) as response: + status = response.status + response_body = response.read() +except urllib.error.HTTPError as error: + status = error.code + response_body = error.read() +if status != 400: + raise SystemExit(f"invalid worker status {status}, want 400") +pathlib.Path(artifact).write_bytes(response_body) +PY +} + +assert_no_9222_mapping() { + local container="$1" + local mapping + mapping="$(docker port "$container" 9222/tcp 2>/dev/null || true)" + [ -z "$mapping" ] || fail "container 9222 is mapped: $mapping" +} + +scan_logs() { + local container="$1" + local log_file="${tmp_dir}/${container}.log" + docker logs "$container" > "$log_file" 2>&1 + for sentinel in "${sentinels[@]}"; do + if grep -Fq "$sentinel" "$log_file"; then + fail "$container logs contain sentinel $sentinel" + fi + done + if grep -Fq "worker OAuth request" "$log_file"; then + fail "$container began an OAuth flow for the rejected worker body" + fi +} + +stop_and_assert() { + local container="$1" + local timing_artifact="$2" + local started_ns ended_ns elapsed state + started_ns="$(python3 -c 'import time; print(time.monotonic_ns())')" + docker stop --time 10 "$container" >/dev/null + ended_ns="$(python3 -c 'import time; print(time.monotonic_ns())')" + elapsed="$(python3 - "$started_ns" "$ended_ns" <<'PY' +import sys +print((int(sys.argv[2]) - int(sys.argv[1])) / 1_000_000_000) +PY +)" + printf 'seconds=%s\n' "$elapsed" > "$timing_artifact" + python3 - "$elapsed" <<'PY' +import sys +if float(sys.argv[1]) > 10.0: + raise SystemExit(f"container stop exceeded 10 seconds: {sys.argv[1]}") +PY + state="$(docker inspect --format '{{.State.Status}} {{.State.ExitCode}}' "$container")" + [ "$state" = "exited 0" ] || fail "$container state is $state, want exited 0" +} + +mkdir -p "$artifacts_dir" +write_options + +if [ "${SKIP_BUILD:-0}" != "1" ]; then + docker buildx build \ + --platform linux/amd64 \ + --build-arg BUILD_ARCH=amd64 \ + --load \ + --tag "$image" \ + "$repo_root/stelloauth" +fi + +start_container "$first_container" +first_port="$(host_port "$first_container")" +wait_ready "$first_container" "$first_port" +assert_no_9222_mapping "$first_container" +assert_loopback_cdp_listener "$first_container" "${artifacts_dir}/runtime-proc-net-tcp.txt" +probe_and_close_cdp "$first_container" +post_invalid_worker "$first_port" "${artifacts_dir}/runtime-invalid-worker-response.json" +scan_logs "$first_container" +stop_and_assert "$first_container" "${artifacts_dir}/runtime-first-stop.txt" +scan_logs "$first_container" + +start_container "$second_container" +second_port="$(host_port "$second_container")" +wait_ready "$second_container" "$second_port" +assert_no_9222_mapping "$second_container" +assert_loopback_cdp_listener "$second_container" "${artifacts_dir}/runtime-restart-proc-net-tcp.txt" +probe_and_close_cdp "$second_container" + +sleep 5 +docker stats --no-stream "$second_container" > "${artifacts_dir}/runtime-docker-stats.txt" +docker top "$second_container" > "${artifacts_dir}/runtime-docker-top.txt" +docker image inspect "$image" --format '{{.Size}}' > "${artifacts_dir}/runtime-image-size-bytes.txt" +docker image inspect "$image" --format '{{json .Config.ExposedPorts}}' > "${artifacts_dir}/runtime-image-exposed-ports.json" +docker inspect "$second_container" --format '{{json .HostConfig.PortBindings}}' > "${artifacts_dir}/runtime-host-port-bindings.json" + +stop_and_assert "$second_container" "${artifacts_dir}/runtime-second-stop.txt" +scan_logs "$second_container" + +printf 'runtime acceptance PASS: root, CDP, loopback bind, invalid worker, redaction, stop, restart\n' diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index bec1a32..fb4d4ad 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -669,7 +669,7 @@ def test_exited_leader_with_live_descendants_still_uses_deadline_and_sigkill( monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None) assert harness.manager.run() == 7 - assert harness.clock.now == pytest.approx(10.25) + assert harness.clock.now == pytest.approx(9.25) assert ("signal", 1001, signal.SIGTERM) in harness.events assert ("signal", 1001, signal.SIGKILL) in harness.events assert ("signal", 1002, signal.SIGTERM) in harness.events @@ -742,7 +742,7 @@ def test_group_disappearing_at_deadline_is_rechecked_before_sigkill( deadline_checks = {1001: 0, 1002: 0} def group_alive(pgid: int) -> bool: - if harness.clock.now < 10.0: + if harness.clock.now < supervisor.SHUTDOWN_GRACE_SECONDS: return True deadline_checks[pgid] += 1 return deadline_checks[pgid] == 1 @@ -849,7 +849,7 @@ def test_signal_while_starting_child_still_terminates_new_process_group( assert [process.wait_calls for process in harness.processes] == [[None], [None]] -def test_shutdown_uses_one_ten_second_deadline_then_sigkills_remaining_groups( +def test_shutdown_reserves_time_before_outer_ten_second_stop_deadline( supervisor, tmp_path: Path, monkeypatch ) -> None: harness = manager_harness(supervisor, tmp_path, ignores_term=(True, True)) @@ -866,7 +866,8 @@ def test_shutdown_uses_one_ten_second_deadline_then_sigkills_remaining_groups( monkeypatch.setattr(supervisor.signal, "signal", lambda *_args: None) assert harness.manager.run() == 0 - assert harness.clock.now == pytest.approx(10.0) + assert harness.clock.now < 10.0 + assert harness.clock.now == pytest.approx(supervisor.SHUTDOWN_GRACE_SECONDS) assert [ (event[1], event[2]) for event in harness.events if event[0] == "signal" ] == [