Add Home Assistant Stelloauth app #1
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+290
@@ -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'
|
||||
@@ -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"
|
||||
] == [
|
||||
|
||||
Reference in New Issue
Block a user