From 97919347a4cb0593c11693382e7e5113f26463ca Mon Sep 17 00:00:00 2001 From: darksworm <9987548+darksworm@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:22:43 +0200 Subject: [PATCH 1/3] feat!: run container images as a non-root user --- .dockerignore | 30 ------ .github/workflows/goreleaser.yml | 6 ++ .github/workflows/test.yml | 41 ++++++++ Dockerfile | 30 ------ Dockerfile.release | 50 +++++++-- README.md | 78 +++++++++++++- main.go | 32 ++++++ main_test.go | 115 ++++++++++++++++++++ scripts/build-container.sh | 18 ++++ scripts/smoke-container.sh | 174 +++++++++++++++++++++++++++++++ 10 files changed, 504 insertions(+), 70 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile create mode 100644 scripts/build-container.sh create mode 100644 scripts/smoke-container.sh diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 3d4040f..0000000 --- a/.dockerignore +++ /dev/null @@ -1,30 +0,0 @@ -# Git -.git -.gitignore - -# GitHub -.github/ - -# Docker -Dockerfile -.dockerignore - -# Build artifacts -*.exe -*.exe~ -*.dll -*.so -*.dylib -*.test -*.out - -# IDE files -.idea/ -.vscode/ -*.swp -*.swo - -# Misc -README.md -assets/ -LICENSE diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index e9432a7..f30dced 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -38,6 +38,12 @@ jobs: with: go-version-file: go.mod + # The release image has RUN steps (it creates the runtime user and stamps + # the port-binding capability on the binary), so building the arm64 image + # on an amd64 runner needs emulation for those layers. + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97aee65..9ef1b0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + # Nothing here needs git credentials, and pull_request runs code from + # the branch, so keep the token out of .git/config. + persist-credentials: false # setup-go caches the module and build caches by default, keyed on go.sum. - name: Set up Go @@ -47,3 +51,40 @@ jobs: # -shuffle=on stops tests depending on declaration order. - name: Test run: go test -race -shuffle=on ./... + + # The release image sets a file capability in a RUN step, which on arm64 runs + # under emulation on an amd64 runner. Build it here so a release is not the + # first place that breaks. Exercise the native image as well: a build alone + # cannot check runtime permissions, privileged ports or config migration. + image: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + # Nothing here needs git credentials, and pull_request runs code from + # the branch, so keep the token out of .git/config. + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build the arm64 image + run: | + mkdir -p arm64-context/linux/arm64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o arm64-context/linux/arm64/doormouse . + docker buildx build --platform linux/arm64 -f Dockerfile.release \ + --output type=cacheonly arm64-context + + - name: Build and test the native image + run: | + bash scripts/build-container.sh doormouse:test + bash scripts/smoke-container.sh doormouse:test diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index c3ec213..0000000 --- a/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -FROM golang:1.23-alpine AS builder - -WORKDIR /app - -# Copy go.mod and go.sum files -COPY go.mod go.sum ./ - -# Download dependencies -RUN go mod download - -# Copy the source code -COPY . . - -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -o doormouse . - -# Create a minimal runtime image -FROM alpine:3.22 - -WORKDIR /app - -# Copy the binary from the builder stage -COPY --from=builder /app/doormouse /app/ - -# Expose the default port. TCP routes listen on their own ports; with -# network_mode: host they are reachable directly, otherwise publish each one. -EXPOSE 8080 - -# Run the application -ENTRYPOINT ["/app/doormouse", "/app/config.toml"] diff --git a/Dockerfile.release b/Dockerfile.release index c731652..6b4affb 100644 --- a/Dockerfile.release +++ b/Dockerfile.release @@ -1,18 +1,50 @@ -# Runtime image for released versions. Unlike the top-level Dockerfile, which -# compiles from source for local builds, this one only packages the binary -# GoReleaser has already cross-compiled — so there is no RUN step and no -# emulation cost when building the arm64 image on an amd64 runner. +# Runtime image for released versions. It only packages the binary GoReleaser +# has already cross-compiled, so the RUN steps below are all the arm64 build has +# to run under emulation on an amd64 runner. FROM alpine:3.22 +# Everything that does not need the binary happens before it is copied in, so a +# new release busts as little cache as possible. Only setcap has to come after. +# +# The account is dedicated and unprivileged. UID and GID are pinned to 1000, the +# first user on most Linux hosts, so an SSH key that is 0600 and owned by the +# host user stays readable through a bind mount with no chown. +# +# libcap-setcap rather than libcap: it is the only piece needed here and pulls +# two packages instead of five. It stays in the image, which costs about 60 kB. +# Removing it after the COPY would save nothing, since the files would still sit +# in this layer with only a whiteout on top, and it would put an apk fetch back +# on the path every release rebuilds. +RUN addgroup -g 1000 doormouse \ + && adduser -D -u 1000 -G doormouse doormouse \ + && apk add --no-cache libcap-setcap + WORKDIR /app # GoReleaser dockers_v2 places each platform's binary under $TARGETPLATFORM/ ARG TARGETPLATFORM -COPY ${TARGETPLATFORM}/doormouse /app/doormouse +# The binary lives outside /app so that /app can be bind-mounted as a whole +# writable config directory without handing the runtime user its own binary. +COPY ${TARGETPLATFORM}/doormouse /usr/local/bin/doormouse + +# doormouse runs as a non-root user, and the kernel would otherwise stop it from +# binding ports below 1024. This file capability grants that one bind permission, +# so `port = ":443"` works with neither root nor a host sysctl. +# CAP_NET_BIND_SERVICE is in Docker's default set, so no cap_add is needed. Drop +# it and the container will not start at all: the kernel refuses to exec a file +# whose capability it cannot grant. +# +# The one step that cannot move above the COPY: setcap stamps the binary, so the +# binary has to be there. It is a single local syscall, with nothing to fetch. +RUN setcap cap_net_bind_service=+ep /usr/local/bin/doormouse + +# Numeric, not the name: Kubernetes cannot verify runAsNonRoot against a +# username and refuses to start the pod, so the number has to be on the image. +USER 1000:1000 -# Same contract as the source-built image: the default port, and a config -# mounted at /app/config.toml. TCP routes listen on their own ports; with -# network_mode: host they are reachable directly, otherwise publish each one. +# Same contract as before: the default port, and a config mounted at +# /app/config.toml. TCP routes listen on their own ports; with network_mode: +# host they are reachable directly, otherwise publish each one. EXPOSE 8080 -ENTRYPOINT ["/app/doormouse", "/app/config.toml"] +ENTRYPOINT ["/usr/local/bin/doormouse", "/app/config.toml"] diff --git a/README.md b/README.md index 8145a7d..20195a1 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,13 @@ services: docker compose up -d ``` +> [!NOTE] +> doormouse runs in the container as UID 1000, the first user on most Linux +> hosts. A `0600` key owned by you is therefore readable as it is. If `id -u` +> says you are not 1000, either `chown 1000:1000 ssh_key` or set `user:` on the +> service to your own IDs from `id -u` and `id -g`. doormouse warns at startup if +> it cannot read the key. + Point `photos.example.com` at the doormouse host, then open `http://photos.example.com:8080`. The NAS wakes. @@ -281,6 +288,10 @@ ssh_key_path = "/app/ssh_key" shutdown_command = "sudo systemctl suspend" ``` +The key has to be readable by the user doormouse runs as, which in the container +is UID 1000. doormouse checks the key at startup and warns if it cannot open it, +because the key itself is only used much later, when the machine goes idle. + **Over HTTP:** ```toml @@ -328,6 +339,35 @@ or checked into a config repo. A config may use one format or the other, never both. +For migration output on the host, create a config directory before starting +Compose and copy your existing config into it: + +```bash +mkdir -p conf +cp config.toml conf/config.toml +``` + +Mount that directory in place of the single config file: + +```yaml +volumes: + - ./conf:/app + - ./ssh_key:/app/ssh_key:ro +``` + +The directory must be writable and the config readable by the container user. +If your host UID is 1000, files created by the commands above already have the +right owner. Otherwise, set `user: ":"` on the service, replacing the +placeholders with `id -u` and `id -g`. Alternatively, use +`sudo chown 1000:1000 conf conf/config.toml` and ensure the directory has owner +write permission. The migrated file is mode `0600`, owned by the container UID; +using your own UID lets you read and replace it without `sudo`. + +With the quick-start single-file mount, `/app` is not writable by the container +user. doormouse logs the complete migrated config instead; retrieve it with +`docker compose logs doormouse`. The same fallback applies if a mounted config +directory is read-only or lacks write permission. + ## Container images Every release publishes an image to `ghcr.io/darksworm/doormouse`, built for @@ -344,12 +384,48 @@ Every release publishes an image to `ghcr.io/darksworm/doormouse`, built for care about, pin the exact version or the minor line, so an upgrade happens when you choose it. +The image runs as UID 1000, not root, so anything you mount in has to be readable +by that user. On most Linux hosts you are 1000 already. If not, `chown 1000:1000` +the file or set `user:` on the service to your own IDs. + +Ports below 1024 still work, because the binary carries the +`CAP_NET_BIND_SERVICE` capability that Docker grants by default. If you drop +capabilities, keep that one or doormouse will not start. + +The file capability cannot grant privileges when `no-new-privileges` is enabled +(see the [kernel documentation](https://www.kernel.org/doc/html/latest/userspace-api/no_new_privs.html)). +For that setup, use ports at or above 1024, or arrange for the runtime to grant +`NET_BIND_SERVICE` to the process before execution. This also applies to +Kubernetes configurations with +[`allowPrivilegeEscalation: false`](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/). + +Images up to and including 1.0.0 ran as root, so check both of those when you +upgrade past it. + ## Building from source ```bash go build -o doormouse . go test -race ./... -docker build -t doormouse . +``` + +To build a local container image, use Go and Docker: + +```bash +bash scripts/build-container.sh doormouse:local +bash scripts/smoke-container.sh doormouse:local +``` + +The build script compiles for your native architecture and packages the binary +with `Dockerfile.release`, so local and published images share the same non-root +runtime. Set `GOARCH=arm64` or `GOARCH=amd64` to cross-build; executing image build +steps for another architecture requires QEMU/binfmt emulation. + +To build both release architectures with [GoReleaser](https://goreleaser.com), +install Docker Buildx and configure QEMU/binfmt emulation first: + +```bash +goreleaser release --config .goreleaser.release.yml --snapshot --clean ``` ## Similar projects diff --git a/main.go b/main.go index a774c09..f7040fe 100644 --- a/main.go +++ b/main.go @@ -1728,6 +1728,36 @@ func (l *StdLogger) Error(msg string, args ...interface{}) { log.Printf("[ERROR] "+msg, args...) } +// warnUnreadableSSHKeys reports SSH keys the process cannot open. The key is +// otherwise read only when a machine has gone idle, so a permissions mistake +// stays invisible until the first shutdown silently fails, up to an +// inactivity_threshold later. Only a warning: a machine may never go idle, and +// refusing to start would take the proxy down over a path it may never use. +func warnUnreadableSSHKeys(config *ProxyConfig, logger Logger) { + for name, machine := range config.Machines { + path := machine.Config.SSHKeyPath + if path == "" || machine.Config.ShutdownHTTPUrl != "" { + continue + } + // Non-blocking open prevents a FIFO from hanging startup, even if the + // path is replaced concurrently. Check the opened file, not the path. + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) + if err == nil { + var info os.FileInfo + info, err = file.Stat() + file.Close() + if err == nil && !info.Mode().IsRegular() { + err = fmt.Errorf("not a regular file") + } + } + if err != nil { + logger.Error("Machine %s: cannot read ssh_key_path %s (%v). Shutting this machine "+ + "down over SSH will fail until the file is readable by the user doormouse runs as.", name, path, err) + continue + } + } +} + // Main function func main() { if len(os.Args) < 2 { @@ -1747,6 +1777,8 @@ func main() { log.Fatalf("Failed to load config: %v", err) } + warnUnreadableSSHKeys(config, logger) + // Initialize dependencies healthChecker := NewEndpointHealthChecker(logger, clock) wolSender := NewUDPWOLSender(logger) diff --git a/main_test.go b/main_test.go index 784a173..c2dba46 100644 --- a/main_test.go +++ b/main_test.go @@ -2472,6 +2472,121 @@ func TestMigrateConfigFile_UnwritableDirectoryLogsTheConfigInstead(t *testing.T) } } +func TestWarnUnreadableSSHKeys_WarnsWhenTheKeyCannotBeRead(t *testing.T) { + dir := t.TempDir() + key := filepath.Join(dir, "ssh_key") + if err := os.WriteFile(key, []byte("not-a-real-key"), 0o000); err != nil { + t.Fatal(err) + } + + // A root process ignores the file mode, so there would be nothing to warn about. + if _, err := os.ReadFile(key); err == nil { + t.Skip("this user can read a 0000 file; the warning is unreachable here") + } + + logger := &recordingLogger{} + warnUnreadableSSHKeys(sshKeyConfig(key), logger) + + if !strings.Contains(logger.all(), "cannot read ssh_key_path") { + t.Errorf("an unreadable key should be reported at startup, got: %s", logger.all()) + } +} + +func TestWarnUnreadableSSHKeys_SaysNothingWhenTheKeyIsReadable(t *testing.T) { + key := filepath.Join(t.TempDir(), "ssh_key") + if err := os.WriteFile(key, []byte("not-a-real-key"), 0o600); err != nil { + t.Fatal(err) + } + + logger := &recordingLogger{} + warnUnreadableSSHKeys(sshKeyConfig(key), logger) + + if logged := logger.all(); logged != "" { + t.Errorf("a readable key should log nothing, got: %s", logged) + } +} + +func TestWarnUnreadableSSHKeys_SkipsMachinesWithoutAKey(t *testing.T) { + logger := &recordingLogger{} + warnUnreadableSSHKeys(sshKeyConfig(""), logger) + + if logged := logger.all(); logged != "" { + t.Errorf("a machine that shuts down over HTTP has no key to check, got: %s", logged) + } +} + +func TestWarnUnreadableSSHKeys_ReportsMissingFilesAndDirectories(t *testing.T) { + for _, path := range []string{filepath.Join(t.TempDir(), "missing"), t.TempDir()} { + logger := &recordingLogger{} + warnUnreadableSSHKeys(sshKeyConfig(path), logger) + if logged := logger.all(); !strings.Contains(logged, "cannot read ssh_key_path "+path) { + t.Errorf("invalid key path should be reported, got: %s", logged) + } + } +} + +func TestWarnUnreadableSSHKeys_DoesNotBlockOnFIFO(t *testing.T) { + path := filepath.Join(t.TempDir(), "ssh_key") + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + logger := &recordingLogger{} + done := make(chan struct{}) + go func() { + warnUnreadableSSHKeys(sshKeyConfig(path), logger) + close(done) + }() + select { + case <-done: + if !strings.Contains(logger.all(), "not a regular file") { + t.Errorf("a FIFO should be reported, got: %s", logger.all()) + } + case <-time.After(time.Second): + // Release a blocked open before failing, so the regression does not + // leave a goroutine behind or hang the rest of the suite. + file, err := os.OpenFile(path, os.O_RDWR|syscall.O_NONBLOCK, 0) + if err != nil { + t.Fatal(err) + } + defer file.Close() + <-done + t.Fatal("checking a FIFO blocked startup") + } +} + +func TestWarnUnreadableSSHKeys_AllowsSymlinksToRegularFiles(t *testing.T) { + dir := t.TempDir() + key := filepath.Join(dir, "key") + if err := os.WriteFile(key, []byte("not-a-real-key"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(key, link); err != nil { + t.Fatal(err) + } + logger := &recordingLogger{} + warnUnreadableSSHKeys(sshKeyConfig(link), logger) + if logged := logger.all(); logged != "" { + t.Errorf("a readable symlink should log nothing, got: %s", logged) + } +} + +func TestWarnUnreadableSSHKeys_SkipsHTTPShutdown(t *testing.T) { + config := sshKeyConfig(filepath.Join(t.TempDir(), "unused-key")) + config.Machines["nas"].Config.ShutdownHTTPUrl = "http://nas.local/shutdown" + logger := &recordingLogger{} + warnUnreadableSSHKeys(config, logger) + if logged := logger.all(); logged != "" { + t.Errorf("HTTP shutdown does not use the SSH key, got: %s", logged) + } +} + +func sshKeyConfig(keyPath string) *ProxyConfig { + return &ProxyConfig{Machines: map[string]*Machine{ + "nas": {Name: "nas", Config: &MachineConfig{SSHKeyPath: keyPath}}, + }} +} + // --------------------------------------------------------------------------- // HTTPHealthChecker tests // --------------------------------------------------------------------------- diff --git a/scripts/build-container.sh b/scripts/build-container.sh new file mode 100644 index 0000000..501d676 --- /dev/null +++ b/scripts/build-container.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build from source with the same runtime Dockerfile used by GoReleaser. +set -euo pipefail +cd "$(dirname "$0")/.." + +image=${1:-doormouse:local} +arch=${GOARCH:-$(go env GOARCH)} +case "$arch" in + amd64|arm64) ;; + *) echo "Unsupported container architecture: $arch" >&2; exit 1 ;; +esac +context=$(mktemp -d) +trap 'rm -rf "$context"' EXIT +mkdir -p "$context/linux/$arch" +CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath \ + -o "$context/linux/$arch/doormouse" . +docker build --platform "linux/$arch" --build-arg "TARGETPLATFORM=linux/$arch" \ + -f Dockerfile.release -t "$image" "$context" diff --git a/scripts/smoke-container.sh b/scripts/smoke-container.sh new file mode 100644 index 0000000..b8afeac --- /dev/null +++ b/scripts/smoke-container.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# Exercise the actual image: file capabilities and bind mounts cannot be +# checked by Go unit tests. Requires Go and Linux Docker; publishes no host ports. +set -euo pipefail +image=${1:-doormouse:local} +work=$(mktemp -d) +prefix="doormouse-smoke-$(basename "$work")" +backend="$prefix-backend" +proxy="$prefix-proxy" +negative="$prefix-negative" + +cleanup() { + status=$? + if (( status != 0 )); then + docker logs "$proxy" >&2 || true + docker logs "$backend" >&2 || true + fi + docker rm -f "$proxy" "$backend" "$negative" >/dev/null 2>&1 || true + # Fixtures can be owned by a different UID, just like real bind mounts. + docker run --rm --user 0:0 --entrypoint sh -v "$work:/fixtures" "$image" \ + -c 'rm -rf /fixtures/*' >/dev/null 2>&1 || true + rm -rf "$work" + exit "$status" +} +trap cleanup EXIT + +fail() { echo "$*" >&2; exit 1; } + +wait_http() { + local port=$1 response + for ((attempt=0; attempt<50; attempt++)); do + response=$(docker exec "$backend" wget -q -T 1 -O - \ + --header 'Host: smoke.local' "http://127.0.0.1:$port/" 2>/dev/null) || true + if [[ "$response" == "doormouse-smoke-ok" ]]; then return; fi + sleep 0.1 + done + fail "No proxied response on port $port" +} + +start_proxy() { + docker run -d --name "$proxy" --network "container:$backend" \ + "$@" "$image" >/dev/null + wait_http 443 +} + +stop_proxy() { docker rm -f "$proxy" >/dev/null; } + +[[ $(docker image inspect -f '{{.Config.User}}' "$image") == '1000:1000' ]] || fail 'Wrong image user' +[[ $(docker image inspect -f '{{json .Config.Entrypoint}}' "$image") == \ + '["/usr/local/bin/doormouse","/app/config.toml"]' ]] || fail 'Wrong entrypoint' +[[ $(docker run --rm --entrypoint sh "$image" -c 'echo "$(id -u):$(id -g):$(id -un)"') == \ + '1000:1000:doormouse' ]] || fail 'Missing non-root account' + +# Force privileged-port enforcement in this isolated network namespace, so +# Docker's usual ip_unprivileged_port_start=0 cannot mask a missing capability. +cat > "$work/backend.go" <<'EOF' +package main + +import ( + "fmt" + "log" + "net/http" +) + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "doormouse-smoke-ok") + }) + log.Fatal(http.ListenAndServe(":18080", nil)) +} +EOF +CGO_ENABLED=0 GOOS=linux GOARCH=$(docker image inspect -f '{{.Architecture}}' "$image") \ + go build -o "$work/backend" "$work/backend.go" +docker run -d --name "$backend" --sysctl net.ipv4.ip_unprivileged_port_start=1024 \ + -v "$work/backend:/smoke-backend:ro" --entrypoint /smoke-backend "$image" >/dev/null +wait_http 18080 + +cat > "$work/config.toml" <<'EOF' +port = ":443" +timeout = "2s" +poll_interval = "100ms" +health_check_interval = "1s" +health_cache_duration = "100ms" +[[machines]] +name = "backend" +health_check = "tcp://127.0.0.1:18080" +inactivity_threshold = "24h" +ssh_host = "127.0.0.1:1" +ssh_user = "test" +ssh_key_path = "/app/ssh_key" +shutdown_command = "true" +[[routes]] +machine = "backend" +hostname = "smoke.local" +destination = "http://127.0.0.1:18080" +[[routes]] +machine = "backend" +listen_port = 993 +destination = "127.0.0.1:18080" +EOF +cat > "$work/legacy.toml" <<'EOF' +port = ":443" +timeout = "2s" +poll_interval = "100ms" +health_check_interval = "1s" +health_cache_duration = "100ms" +[[targets]] +name = "backend" +hostname = "smoke.local" +destination = "http://127.0.0.1:18080" +health_endpoint = "tcp://127.0.0.1:18080" +inactivity_threshold = "24h" +EOF +# Populate real host bind mounts with both the default and an overridden UID. +docker run --rm --user 0:0 --entrypoint sh -v "$work:/fixtures" "$image" -c ' + for uid in 1000 1001; do + mkdir "/fixtures/$uid" + cp /fixtures/config.toml "/fixtures/$uid/config.toml" + echo readable-key > "/fixtures/$uid/ssh_key" + chmod 600 "/fixtures/$uid/ssh_key" + chown -R "$uid:$uid" "/fixtures/$uid" + done + mkdir /fixtures/migration + cp /fixtures/legacy.toml /fixtures/migration/config.toml + chown -R 1000:1000 /fixtures/migration + chmod 644 /fixtures/legacy.toml +' + +for uid in 1000 1001; do + user_args=() + if [[ "$uid" != 1000 ]]; then + user_args=(--user "$uid:$uid" --cap-drop ALL --cap-add NET_BIND_SERVICE) + fi + start_proxy "${user_args[@]}" -v "$work/$uid:/app:ro" + wait_http 993 + docker exec "$proxy" awk -v uid="$uid" ' + /^Uid:|^Gid:/ { for (i=2; i<=5; i++) if ($i != uid) exit 1; seen++ } + END { if (seen != 2) exit 1 } + ' /proc/1/status || fail 'Proxy process has incorrect IDs' + [[ $(docker exec "$proxy" cat /app/ssh_key) == readable-key ]] || fail 'Key is unreadable' + if docker logs "$proxy" 2>&1 | grep -q 'cannot read ssh_key_path'; then fail 'Readable key warned'; fi + stop_proxy +done +echo 'ok: runtime user, privileged HTTP/TCP forwarding, 0600 keys and user override' + +start_proxy --user 1001:1001 -v "$work/1000:/app:ro" +docker logs "$proxy" 2>&1 | grep -q 'cannot read ssh_key_path' || fail 'Unreadable key did not warn' +stop_proxy +echo 'ok: unreadable key warns without preventing startup' + +# Without the capability, exec itself must fail, even before opening config. +if timeout 10 docker run --name "$negative" --network "container:$backend" --cap-drop ALL \ + -v "$work/1000:/app:ro" "$image" > "$work/negative.log" 2>&1; then + fail 'Container started without NET_BIND_SERVICE' +fi +grep -qi 'operation not permitted' "$work/negative.log" || fail 'Negative control failed for an unrelated reason' +echo 'ok: dropping NET_BIND_SERVICE prevents execution' + +start_proxy -v "$work/migration:/app" +docker exec "$proxy" sh -c ' + test "$(stat -c %u:%g:%a /app/config.migrated.toml)" = 1000:1000:600 && + grep -q "\[\[machines\]\]" /app/config.migrated.toml && + grep -q "\[\[routes\]\]" /app/config.migrated.toml +' || fail 'Migrated config missing or has incorrect permissions' +docker exec "$proxy" cmp /app/config.toml /app/config.migrated.toml >/dev/null 2>&1 && fail 'Migration did not translate the config' +stop_proxy +cmp "$work/legacy.toml" "$work/migration/config.toml" || fail 'Original config was modified' + +start_proxy -v "$work/legacy.toml:/app/config.toml:ro" +logs=$(docker logs "$proxy" 2>&1) +[[ "$logs" == *'the migrated config follows'* && "$logs" == *'[[machines]]'* && "$logs" == *'[[routes]]'* ]] || fail 'Missing migration fallback' +docker exec "$proxy" test ! -e /app/config.migrated.toml || fail 'Unexpected migration output' +stop_proxy +echo 'ok: writable-directory migration and read-only single-file fallback' From 30011ce153fbee9400b13ed95defe0b7c3194e7e Mon Sep 17 00:00:00 2001 From: darksworm <9987548+darksworm@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:23:01 +0200 Subject: [PATCH 2/3] test: cover container lifecycle with Go end-to-end tests --- .github/workflows/test.yml | 10 +- README.md | 8 +- e2e/README.md | 36 +++++ e2e/container_test.go | 232 +++++++++++++++++++++++++++++ e2e/go.mod | 71 +++++++++ e2e/go.sum | 194 +++++++++++++++++++++++++ e2e/harness_test.go | 274 +++++++++++++++++++++++++++++++++++ e2e/testdata/backend/main.go | 164 +++++++++++++++++++++ scripts/smoke-container.sh | 174 ---------------------- 9 files changed, 982 insertions(+), 181 deletions(-) create mode 100644 e2e/README.md create mode 100644 e2e/container_test.go create mode 100644 e2e/go.mod create mode 100644 e2e/go.sum create mode 100644 e2e/harness_test.go create mode 100644 e2e/testdata/backend/main.go delete mode 100644 scripts/smoke-container.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ef1b0d..f1802ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,8 +54,8 @@ jobs: # The release image sets a file capability in a RUN step, which on arm64 runs # under emulation on an amd64 runner. Build it here so a release is not the - # first place that breaks. Exercise the native image as well: a build alone - # cannot check runtime permissions, privileged ports or config migration. + # first place that breaks. Go end-to-end tests exercise the native release + # image's runtime permissions and the wake/proxy/shutdown lifecycle. image: runs-on: ubuntu-latest steps: @@ -84,7 +84,5 @@ jobs: docker buildx build --platform linux/arm64 -f Dockerfile.release \ --output type=cacheonly arm64-context - - name: Build and test the native image - run: | - bash scripts/build-container.sh doormouse:test - bash scripts/smoke-container.sh doormouse:test + - name: Test container end to end + run: go -C e2e test -race -count=1 -timeout=5m -v ./... diff --git a/README.md b/README.md index 20195a1..ee43863 100644 --- a/README.md +++ b/README.md @@ -409,11 +409,17 @@ go build -o doormouse . go test -race ./... ``` +The [container end-to-end suite](e2e/README.md) checks the release image's +wake/proxy/SSH-shutdown lifecycle, runtime permissions, and config migration: + +```bash +go -C e2e test -race -count=1 -timeout=5m -v ./... +``` + To build a local container image, use Go and Docker: ```bash bash scripts/build-container.sh doormouse:local -bash scripts/smoke-container.sh doormouse:local ``` The build script compiles for your native architecture and packages the binary diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..1546e8e --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,36 @@ +# Container end-to-end tests + +Run from the repository root with Go and a local Linux Docker daemon: + +```console +go -C e2e test -race -count=1 -timeout=5m -v ./... +``` + +The suite compiles the application, builds `Dockerfile.release` once, and uses +Testcontainers to manage isolated containers and networks. No prebuilt application +image, shell script, privileged host ports, or root test runner is required. +Test dependencies live in this module; ordinary `go test ./...` stays Docker-free. + +Five proxy runs cover three scenarios: + +| Scenario | Cases | What a failure catches | +| --- | --- | --- | +| Runtime lifecycle | Default UID 1000; overridden UID 1001 with only `NET_BIND_SERVICE` | Wrong runtime user/account, broken HTTP or raw TCP forwarding on ports 443/993, missing WOL packets, unreadable `0600` keys, failed authenticated SSH shutdown, unclean SIGTERM exit | +| Legacy migration | Writable config directory; read-only config file with a key owned by another UID | Wrong migration location/ownership/mode, modified original config, incomplete log fallback, missing startup warning, proxy failing to serve the legacy config | +| Capability boundary | Drop all capabilities | Executable starts without its required file capability, or fails for an unrelated reason | + +The runtime cases start with different protocols so both HTTP and TCP must wake +the backend. The kernel's privileged-port threshold is explicitly set to 1024 +inside each proxy container, preventing Docker defaults from hiding a missing +capability. Idle shutdown is observed on the backend, using the real ten-second +monitor interval; tests do not change production timing or call application +internals. Container logs are attached to failed tests and resources are cleaned +up by Testcontainers and Go test cleanup hooks. + +The Go backend fixture implements real HTTP, TCP, UDP and SSH sockets. It accepts +only the expected magic packet and the test's public key, and records an SSH +`suspend` request as a transition to asleep. This verifies the application's +network behavior; physical NIC wake support and LAN broadcast delivery remain +deployment checks. + +Pure configuration and SSH-path edge cases stay in the application unit tests. diff --git a/e2e/container_test.go b/e2e/container_test.go new file mode 100644 index 0000000..ac56d50 --- /dev/null +++ b/e2e/container_test.go @@ -0,0 +1,232 @@ +package e2e + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/BurntSushi/toml" + "github.com/docker/docker/api/types/container" + tc "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +func TestContainerImage(t *testing.T) { + suite := buildSuite(t) + + t.Run("runtime", func(t *testing.T) { + for _, uid := range []int{1000, 1001} { + t.Run(strconv.Itoa(uid), func(t *testing.T) { + t.Parallel() + env := suite.machine(t, modernConfig, uid, uid) + proxy := startContainer(t, suite.image, env.proxyOptions(false, uid)...) + assertProcessUID(t, proxy, uid) + if uid == 1000 && strings.TrimSpace(execIn(t, proxy, "id", "-un")) != "doormouse" { + t.Fatal("UID 1000 must resolve to the dedicated doormouse account") + } + if state := env.state(t); state.Awake || state.Wakes != 0 { + t.Fatalf("backend should initially be asleep: %+v", state) + } + + // Start with HTTP for one UID and raw TCP for the other. Both wake + // paths are covered without adding another container scenario. + if uid == 1000 { + assertHTTP(t, proxy) + assertTCP(t, proxy) + } else { + assertTCP(t, proxy) + assertHTTP(t, proxy) + } + if state := env.state(t); !state.Awake || state.Wakes == 0 { + t.Fatalf("traffic did not wake the backend: %+v", state) + } + + // Poll the backend directly so observing shutdown does not reset + // the proxy's inactivity timer. Authentication accepts only the + // public key paired with this test's mounted 0600 private key. + eventually(t, 25*time.Second, func() bool { + state := env.state(t) + return !state.Awake && state.Shutdowns > 0 + }) + if logs := containerLogs(t, proxy); strings.Contains(logs, "cannot read ssh_key_path") { + t.Fatalf("readable key was rejected: %s", logs) + } + grace := 3 * time.Second + must(t, proxy.Stop(context.Background(), &grace)) + state, err := proxy.State(context.Background()) + must(t, err) + if state.ExitCode != 0 { + t.Fatalf("SIGTERM should shut down cleanly, exit code %d", state.ExitCode) + } + }) + } + }) + + t.Run("migration", func(t *testing.T) { + for _, readOnly := range []bool{false, true} { + name := "writable_directory" + if readOnly { + name = "read_only_file_and_unreadable_key" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + keyUID := 1000 + if readOnly { + keyUID = 1001 + } + env := suite.machine(t, legacyConfig, 1000, keyUID) + proxy := startContainer(t, suite.image, env.proxyOptions(readOnly, 1000)...) + assertHTTP(t, proxy) + original, err := os.ReadFile(filepath.Join(env.dir, "config.toml")) + must(t, err) + if string(original) != legacyConfig { + t.Fatal("migration modified the original config") + } + logs := containerLogs(t, proxy) + if readOnly { + if !strings.Contains(logs, "cannot read ssh_key_path /app/ssh_key") { + t.Fatal("unreadable key must warn without preventing requests") + } + const marker = "the migrated config follows — save it yourself:\n" + _, migrated, found := strings.Cut(logs, marker) + if !found { + t.Fatalf("missing migration fallback: %s", logs) + } + // The next timestamped log line ends the TOML block. + lines := strings.Split(migrated, "\n") + for i, line := range lines { + if len(line) >= 5 && line[4] == '/' { + lines = lines[:i] + break + } + } + assertMigratedConfig(t, strings.Join(lines, "\n")) + if code, _, err := proxy.Exec(context.Background(), []string{"test", "!", "-e", "/app/config.migrated.toml"}); err != nil || code != 0 { + t.Fatalf("unexpected sidecar in read-only setup: code=%d, error=%v", code, err) + } + return + } + file := filepath.Join(env.dir, "config.migrated.toml") + info, err := os.Stat(file) + must(t, err) + stat := info.Sys().(*syscall.Stat_t) + if info.Mode().Perm() != 0o600 || stat.Uid != 1000 || stat.Gid != 1000 { + t.Fatalf("migration permissions: mode=%o owner=%d:%d", info.Mode().Perm(), stat.Uid, stat.Gid) + } + assertMigratedConfig(t, execIn(t, proxy, "cat", "/app/config.migrated.toml")) + }) + } + }) + + t.Run("capability_required", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + proxy, err := tc.Run(ctx, suite.image, tc.WithHostConfigModifier(func(h *container.HostConfig) { + h.CapDrop = []string{"ALL"} + })) + tc.CleanupContainer(t, proxy) + // Runtimes may report exec failure from Start, or start the container + // and report it through stderr and a nonzero process exit instead. + var failure string + if err != nil { + failure = err.Error() + } else { + must(t, wait.ForExit().WaitUntilReady(ctx, proxy)) + state, err := proxy.State(ctx) + must(t, err) + failure = containerLogs(t, proxy) + if state.ExitCode == 0 { + t.Fatalf("missing capability should cause a nonzero exit: %s", failure) + } + } + if !strings.Contains(strings.ToLower(failure), "operation not permitted") { + t.Fatalf("exec must fail specifically because the file capability was dropped, got: %s", failure) + } + }) +} + +func assertProcessUID(t *testing.T, proxy tc.Container, uid int) { + t.Helper() + status := execIn(t, proxy, "cat", "/proc/1/status") + seen := 0 + for _, line := range strings.Split(status, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || (fields[0] != "Uid:" && fields[0] != "Gid:") { + continue + } + seen++ + if len(fields) != 5 { + t.Fatalf("malformed process identity: %s", line) + } + for _, value := range fields[1:] { + if value != strconv.Itoa(uid) { + t.Fatalf("process must run as %d:%d, got %s", uid, uid, line) + } + } + } + if seen != 2 { + t.Fatalf("missing UID/GID in process status: %s", status) + } +} + +func assertHTTP(t *testing.T, proxy tc.Container) { + t.Helper() + url, err := proxy.PortEndpoint(context.Background(), "443/tcp", "http") + must(t, err) + payload := "http request through " + t.Name() + request, err := http.NewRequest(http.MethodPost, url+"/echo", strings.NewReader(payload)) + must(t, err) + request.Host = "service.test" + response, err := httpClient.Do(request) + must(t, err) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + must(t, err) + if response.StatusCode != http.StatusCreated || response.Header.Get("X-Backend") != "doormouse-e2e" || string(body) != payload { + t.Fatalf("HTTP forwarding: status=%d headers=%v body=%q", response.StatusCode, response.Header, body) + } +} + +func assertTCP(t *testing.T, proxy tc.Container) { + t.Helper() + address, err := proxy.PortEndpoint(context.Background(), "993/tcp", "") + must(t, err) + conn, err := net.DialTimeout("tcp", address, 5*time.Second) + must(t, err) + defer conn.Close() + must(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + payload := []byte("raw TCP\x00through " + t.Name()) + _, err = conn.Write(payload) + must(t, err) + must(t, conn.(*net.TCPConn).CloseWrite()) + body, err := io.ReadAll(conn) + must(t, err) + if !bytes.Equal(body, append([]byte("tcp:"), payload...)) { + t.Fatalf("TCP forwarding after half-close: got %q", body) + } +} + +func assertMigratedConfig(t *testing.T, data string) { + t.Helper() + var config struct { + Machines []struct{ Name, SSHKeyPath string } `toml:"machines"` + Routes []struct{ Machine, Hostname, Destination string } `toml:"routes"` + Targets []any `toml:"targets"` + } + _, err := toml.Decode(data, &config) + must(t, err) + if len(config.Targets) != 0 || len(config.Machines) != 1 || len(config.Routes) != 1 || + config.Machines[0].Name != "backend" || config.Routes[0].Machine != "backend" || + config.Routes[0].Hostname != "service.test" || config.Routes[0].Destination != "http://backend:18080" { + t.Fatalf("incorrect migration: %s", data) + } +} diff --git a/e2e/go.mod b/e2e/go.mod new file mode 100644 index 0000000..fa075f4 --- /dev/null +++ b/e2e/go.mod @@ -0,0 +1,71 @@ +module github.com/darksworm/doormouse/e2e + +go 1.23.0 + +require ( + github.com/BurntSushi/toml v1.2.1 + github.com/docker/docker v28.2.2+incompatible + github.com/testcontainers/testcontainers-go v0.38.0 + golang.org/x/crypto v0.39.0 +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shirou/gopsutil/v4 v4.25.5 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.31.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + golang.org/x/sys v0.33.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +// Docker's +incompatible module omits these constraints from its module graph. +// Match its vendor.mod so tidy does not select a newer Go toolchain. +require ( + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect +) diff --git a/e2e/go.sum b/e2e/go.sum new file mode 100644 index 0000000..7a7c975 --- /dev/null +++ b/e2e/go.sum @@ -0,0 +1,194 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= +github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc= +github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw= +github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= +google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= +google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/e2e/harness_test.go b/e2e/harness_test.go new file mode 100644 index 0000000..e2270d1 --- /dev/null +++ b/e2e/harness_test.go @@ -0,0 +1,274 @@ +package e2e + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "testing" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + tc "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" + "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" + "golang.org/x/crypto/ssh" +) + +var httpClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{DisableKeepAlives: true}, +} + +type suite struct { + image, backend string + privateKey []byte + publicKey string +} + +func buildSuite(t *testing.T) suite { + t.Helper() + if runtime.GOOS != "linux" { + t.Fatal("these bind-mount ownership tests require a local Linux Docker daemon") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + dir := t.TempDir() + platform := "linux/" + runtime.GOARCH + must(t, os.MkdirAll(filepath.Join(dir, platform), 0o755)) + compileGo(t, "..", ".", filepath.Join(dir, platform, "doormouse")) + dockerfile, err := os.ReadFile("../Dockerfile.release") + must(t, err) + must(t, os.WriteFile(filepath.Join(dir, "Dockerfile"), dockerfile, 0o644)) + var buildLog bytes.Buffer + // Create without starting to build the release image once. Keeping this + // container until all subtests finish also gives Testcontainers ownership + // of image cleanup, including when a test fails. + image, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{ + ContainerRequest: tc.ContainerRequest{FromDockerfile: tc.FromDockerfile{ + Context: dir, BuildArgs: map[string]*string{"TARGETPLATFORM": &platform}, + BuildLogWriter: &buildLog, + }}, + }) + tc.CleanupContainer(t, image) + if err != nil { + t.Fatalf("release image build: %v\n%s", err, &buildLog) + } + backend := filepath.Join(dir, "backend") + compileGo(t, ".", "./testdata/backend", backend) + _, private, err := ed25519.GenerateKey(rand.Reader) + must(t, err) + key, err := ssh.MarshalPrivateKey(private, "doormouse e2e") + must(t, err) + signer, err := ssh.NewSignerFromKey(private) + must(t, err) + return suite{ + image: image.(*tc.DockerContainer).Image, backend: backend, + privateKey: pem.EncodeToMemory(key), publicKey: string(ssh.MarshalAuthorizedKey(signer.PublicKey())), + } +} + +func compileGo(t *testing.T, dir, pkg, output string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", output, pkg) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+runtime.GOARCH) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("compile %s: %v\n%s", pkg, err, output) + } +} + +type machine struct { + dir, network string + backend tc.Container +} + +func (s suite) machine(t *testing.T, config string, configUID, keyUID int) machine { + t.Helper() + ctx := context.Background() + dir := t.TempDir() + must(t, os.WriteFile(filepath.Join(dir, "config.toml"), []byte(config), 0o644)) + must(t, os.WriteFile(filepath.Join(dir, "ssh_key"), s.privateKey, 0o600)) + net, err := network.New(ctx) + must(t, err) + tc.CleanupNetwork(t, net) + backend := startContainer(t, s.image, + tc.WithEntrypoint("/backend"), tc.WithCmd(), + tc.WithConfigModifier(func(c *container.Config) { c.User = "0:0" }), + tc.WithFiles(tc.ContainerFile{HostFilePath: s.backend, ContainerFilePath: "/backend", FileMode: 0o755}), + tc.WithEnv(map[string]string{ + "CONFIG_UID": strconv.Itoa(configUID), "KEY_UID": strconv.Itoa(keyUID), "SSH_PUBLIC_KEY": s.publicKey, + }), + tc.WithHostConfigModifier(func(h *container.HostConfig) { + h.Mounts = []mount.Mount{{Type: mount.TypeBind, Source: dir, Target: "/fixtures"}} + }), + network.WithNetwork([]string{"backend"}, net), + tc.WithExposedPorts("18080/tcp"), + tc.WithWaitStrategy(wait.ForHTTP("/state").WithPort("18080/tcp")), + ) + // Restore ownership before Testcontainers removes the backend and before + // TempDir cleanup runs. The host runner can have any UID, including 1001 + // on GitHub Actions. No sudo or host-wide permission changes are needed. + t.Cleanup(func() { + execIn(t, backend, "chown", "-R", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), "/fixtures") + }) + return machine{dir: dir, network: net.Name, backend: backend} +} + +func (m machine) proxyOptions(readOnly bool, uid int) []tc.ContainerCustomizer { + return []tc.ContainerCustomizer{ + tc.WithConfigModifier(func(c *container.Config) { + if uid != 1000 { + c.User = fmt.Sprintf("%d:%d", uid, uid) + } + }), + tc.WithHostConfigModifier(func(h *container.HostConfig) { + // Enforce the kernel boundary explicitly; Docker often defaults to + // zero, which would let an image with no capability pass these tests. + h.Sysctls = map[string]string{"net.ipv4.ip_unprivileged_port_start": "1024"} + h.Mounts = []mount.Mount{{Type: mount.TypeBind, Source: m.dir, Target: "/app"}} + if readOnly { + h.Mounts = []mount.Mount{ + {Type: mount.TypeBind, Source: filepath.Join(m.dir, "config.toml"), Target: "/app/config.toml", ReadOnly: true}, + {Type: mount.TypeBind, Source: filepath.Join(m.dir, "ssh_key"), Target: "/app/ssh_key", ReadOnly: true}, + } + } + if uid != 1000 { + h.CapDrop = []string{"ALL"} + h.CapAdd = []string{"NET_BIND_SERVICE"} + } + }), + network.WithNetworkName(nil, m.network), + tc.WithExposedPorts("443/tcp", "993/tcp"), + tc.WithWaitStrategy(wait.ForListeningPort("443/tcp")), + } +} + +func startContainer(t *testing.T, image string, options ...tc.ContainerCustomizer) *tc.DockerContainer { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + c, err := tc.Run(ctx, image, options...) + tc.CleanupContainer(t, c) + if c != nil { + t.Cleanup(func() { + if t.Failed() { + t.Logf("container %s logs:\n%s", c.GetContainerID(), containerLogs(t, c)) + } + }) + } + must(t, err) + return c +} + +type machineState struct { + Awake bool + Wakes, Shutdowns int +} + +func (m machine) state(t *testing.T) machineState { + t.Helper() + url, err := m.backend.PortEndpoint(context.Background(), "18080/tcp", "http") + must(t, err) + response, err := httpClient.Get(url + "/state") + must(t, err) + defer response.Body.Close() + var state machineState + must(t, json.NewDecoder(response.Body).Decode(&state)) + return state +} + +func eventually(t *testing.T, timeout time.Duration, condition func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("condition not met within %s", timeout) +} + +func execIn(t *testing.T, c tc.Container, args ...string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + code, reader, err := c.Exec(ctx, args, tcexec.Multiplexed()) + must(t, err) + output, err := io.ReadAll(reader) + must(t, err) + if code != 0 { + t.Fatalf("container command %v exited %d: %s", args, code, output) + } + return string(output) +} + +func containerLogs(t *testing.T, c tc.Container) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + reader, err := c.Logs(ctx) + must(t, err) + defer reader.Close() + output, err := io.ReadAll(reader) + must(t, err) + return string(output) +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +const configHeader = `port = ":443" +timeout = "5s" +poll_interval = "100ms" +health_check_interval = "100ms" +health_cache_duration = "100ms" +` + +const machineFields = `name = "backend" +mac_address = "02:00:00:00:00:01" +broadcast_ip = "backend" +wol_port = 9009 +ssh_host = "backend:2222" +ssh_user = "doormouse" +ssh_key_path = "/app/ssh_key" +shutdown_command = "suspend" +` + +const modernConfig = configHeader + "[[machines]]\n" + machineFields + ` +health_check = "http://backend:18080/health" +inactivity_threshold = "1s" +[[routes]] +machine = "backend" +hostname = "service.test" +destination = "http://backend:18080" +[[routes]] +machine = "backend" +listen_port = 993 +destination = "backend:19090" +` + +const legacyConfig = configHeader + "[[targets]]\n" + machineFields + ` +health_endpoint = "http://backend:18080/health" +hostname = "service.test" +destination = "http://backend:18080" +` diff --git a/e2e/testdata/backend/main.go b/e2e/testdata/backend/main.go new file mode 100644 index 0000000..4cf5e80 --- /dev/null +++ b/e2e/testdata/backend/main.go @@ -0,0 +1,164 @@ +// The backend simulates a sleeping machine using real HTTP, TCP, UDP and SSH +// sockets. It wakes only on the expected magic packet and sleeps only after an +// authenticated SSH exec request. It never executes commands on the test host. +package main + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "strconv" + "sync/atomic" + "time" + + "golang.org/x/crypto/ssh" +) + +var awake atomic.Bool +var wakes, shutdowns atomic.Int32 + +func main() { + // These are actual host bind mounts. Ownership must be prepared inside the + // container because the test runner need not be root or have UID 1000. + for _, entry := range []struct { + path, owner string + mode os.FileMode + }{ + {"/fixtures", "CONFIG_UID", 0o755}, + {"/fixtures/config.toml", "CONFIG_UID", 0o644}, + {"/fixtures/ssh_key", "KEY_UID", 0o600}, + } { + uid, err := strconv.Atoi(os.Getenv(entry.owner)) + must(err) + must(os.Chown(entry.path, uid, uid)) + must(os.Chmod(entry.path, entry.mode)) + } + + udp, err := net.ListenPacket("udp", ":9009") + must(err) + go func() { + mac := []byte{0x02, 0, 0, 0, 0, 1} + want := append(bytes.Repeat([]byte{0xff}, 6), bytes.Repeat(mac, 16)...) + packet := make([]byte, 2048) + for { + n, _, err := udp.ReadFrom(packet) + must(err) + if bytes.Equal(packet[:n], want) { + wakes.Add(1) + awake.Store(true) + } + } + }() + + tcp, err := net.Listen("tcp", ":19090") + must(err) + go func() { + for { + conn, err := tcp.Accept() + must(err) + go func() { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + body, err := io.ReadAll(conn) + if err == nil && awake.Load() { + _, _ = fmt.Fprintf(conn, "tcp:%s", body) + } + }() + } + }() + + allowed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(os.Getenv("SSH_PUBLIC_KEY"))) + must(err) + _, private, err := ed25519.GenerateKey(rand.Reader) + must(err) + signer, err := ssh.NewSignerFromKey(private) + must(err) + sshConfig := &ssh.ServerConfig{ + PublicKeyCallback: func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if meta.User() == "doormouse" && bytes.Equal(key.Marshal(), allowed.Marshal()) { + return nil, nil + } + return nil, fmt.Errorf("unexpected SSH identity") + }, + } + sshConfig.AddHostKey(signer) + sshListener, err := net.Listen("tcp", ":2222") + must(err) + go func() { + for { + conn, err := sshListener.Accept() + must(err) + go serveSSH(conn, sshConfig) + } + }() + + http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + if !awake.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + } + }) + http.HandleFunc("/state", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "awake": awake.Load(), "wakes": wakes.Load(), "shutdowns": shutdowns.Load(), + }) + }) + http.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) { + if !awake.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("X-Backend", "doormouse-e2e") + w.WriteHeader(http.StatusCreated) + _, _ = io.Copy(w, r.Body) + }) + server := &http.Server{Addr: ":18080", ReadHeaderTimeout: 5 * time.Second} + log.Print("backend ready") + must(server.ListenAndServe()) +} + +func serveSSH(conn net.Conn, config *ssh.ServerConfig) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + server, channels, requests, err := ssh.NewServerConn(conn, config) + if err != nil { + return + } + defer server.Close() + go ssh.DiscardRequests(requests) + for channel := range channels { + if channel.ChannelType() != "session" { + _ = channel.Reject(ssh.UnknownChannelType, "session required") + continue + } + session, requests, err := channel.Accept() + if err != nil { + return + } + for request := range requests { + var command struct{ Command string } + if request.Type != "exec" || ssh.Unmarshal(request.Payload, &command) != nil || command.Command != "suspend" { + _ = request.Reply(false, nil) + continue + } + awake.Store(false) + shutdowns.Add(1) + _ = request.Reply(true, nil) + _, _ = session.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{0})) + break + } + _ = session.Close() + } +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/scripts/smoke-container.sh b/scripts/smoke-container.sh deleted file mode 100644 index b8afeac..0000000 --- a/scripts/smoke-container.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -# Exercise the actual image: file capabilities and bind mounts cannot be -# checked by Go unit tests. Requires Go and Linux Docker; publishes no host ports. -set -euo pipefail -image=${1:-doormouse:local} -work=$(mktemp -d) -prefix="doormouse-smoke-$(basename "$work")" -backend="$prefix-backend" -proxy="$prefix-proxy" -negative="$prefix-negative" - -cleanup() { - status=$? - if (( status != 0 )); then - docker logs "$proxy" >&2 || true - docker logs "$backend" >&2 || true - fi - docker rm -f "$proxy" "$backend" "$negative" >/dev/null 2>&1 || true - # Fixtures can be owned by a different UID, just like real bind mounts. - docker run --rm --user 0:0 --entrypoint sh -v "$work:/fixtures" "$image" \ - -c 'rm -rf /fixtures/*' >/dev/null 2>&1 || true - rm -rf "$work" - exit "$status" -} -trap cleanup EXIT - -fail() { echo "$*" >&2; exit 1; } - -wait_http() { - local port=$1 response - for ((attempt=0; attempt<50; attempt++)); do - response=$(docker exec "$backend" wget -q -T 1 -O - \ - --header 'Host: smoke.local' "http://127.0.0.1:$port/" 2>/dev/null) || true - if [[ "$response" == "doormouse-smoke-ok" ]]; then return; fi - sleep 0.1 - done - fail "No proxied response on port $port" -} - -start_proxy() { - docker run -d --name "$proxy" --network "container:$backend" \ - "$@" "$image" >/dev/null - wait_http 443 -} - -stop_proxy() { docker rm -f "$proxy" >/dev/null; } - -[[ $(docker image inspect -f '{{.Config.User}}' "$image") == '1000:1000' ]] || fail 'Wrong image user' -[[ $(docker image inspect -f '{{json .Config.Entrypoint}}' "$image") == \ - '["/usr/local/bin/doormouse","/app/config.toml"]' ]] || fail 'Wrong entrypoint' -[[ $(docker run --rm --entrypoint sh "$image" -c 'echo "$(id -u):$(id -g):$(id -un)"') == \ - '1000:1000:doormouse' ]] || fail 'Missing non-root account' - -# Force privileged-port enforcement in this isolated network namespace, so -# Docker's usual ip_unprivileged_port_start=0 cannot mask a missing capability. -cat > "$work/backend.go" <<'EOF' -package main - -import ( - "fmt" - "log" - "net/http" -) - -func main() { - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "doormouse-smoke-ok") - }) - log.Fatal(http.ListenAndServe(":18080", nil)) -} -EOF -CGO_ENABLED=0 GOOS=linux GOARCH=$(docker image inspect -f '{{.Architecture}}' "$image") \ - go build -o "$work/backend" "$work/backend.go" -docker run -d --name "$backend" --sysctl net.ipv4.ip_unprivileged_port_start=1024 \ - -v "$work/backend:/smoke-backend:ro" --entrypoint /smoke-backend "$image" >/dev/null -wait_http 18080 - -cat > "$work/config.toml" <<'EOF' -port = ":443" -timeout = "2s" -poll_interval = "100ms" -health_check_interval = "1s" -health_cache_duration = "100ms" -[[machines]] -name = "backend" -health_check = "tcp://127.0.0.1:18080" -inactivity_threshold = "24h" -ssh_host = "127.0.0.1:1" -ssh_user = "test" -ssh_key_path = "/app/ssh_key" -shutdown_command = "true" -[[routes]] -machine = "backend" -hostname = "smoke.local" -destination = "http://127.0.0.1:18080" -[[routes]] -machine = "backend" -listen_port = 993 -destination = "127.0.0.1:18080" -EOF -cat > "$work/legacy.toml" <<'EOF' -port = ":443" -timeout = "2s" -poll_interval = "100ms" -health_check_interval = "1s" -health_cache_duration = "100ms" -[[targets]] -name = "backend" -hostname = "smoke.local" -destination = "http://127.0.0.1:18080" -health_endpoint = "tcp://127.0.0.1:18080" -inactivity_threshold = "24h" -EOF -# Populate real host bind mounts with both the default and an overridden UID. -docker run --rm --user 0:0 --entrypoint sh -v "$work:/fixtures" "$image" -c ' - for uid in 1000 1001; do - mkdir "/fixtures/$uid" - cp /fixtures/config.toml "/fixtures/$uid/config.toml" - echo readable-key > "/fixtures/$uid/ssh_key" - chmod 600 "/fixtures/$uid/ssh_key" - chown -R "$uid:$uid" "/fixtures/$uid" - done - mkdir /fixtures/migration - cp /fixtures/legacy.toml /fixtures/migration/config.toml - chown -R 1000:1000 /fixtures/migration - chmod 644 /fixtures/legacy.toml -' - -for uid in 1000 1001; do - user_args=() - if [[ "$uid" != 1000 ]]; then - user_args=(--user "$uid:$uid" --cap-drop ALL --cap-add NET_BIND_SERVICE) - fi - start_proxy "${user_args[@]}" -v "$work/$uid:/app:ro" - wait_http 993 - docker exec "$proxy" awk -v uid="$uid" ' - /^Uid:|^Gid:/ { for (i=2; i<=5; i++) if ($i != uid) exit 1; seen++ } - END { if (seen != 2) exit 1 } - ' /proc/1/status || fail 'Proxy process has incorrect IDs' - [[ $(docker exec "$proxy" cat /app/ssh_key) == readable-key ]] || fail 'Key is unreadable' - if docker logs "$proxy" 2>&1 | grep -q 'cannot read ssh_key_path'; then fail 'Readable key warned'; fi - stop_proxy -done -echo 'ok: runtime user, privileged HTTP/TCP forwarding, 0600 keys and user override' - -start_proxy --user 1001:1001 -v "$work/1000:/app:ro" -docker logs "$proxy" 2>&1 | grep -q 'cannot read ssh_key_path' || fail 'Unreadable key did not warn' -stop_proxy -echo 'ok: unreadable key warns without preventing startup' - -# Without the capability, exec itself must fail, even before opening config. -if timeout 10 docker run --name "$negative" --network "container:$backend" --cap-drop ALL \ - -v "$work/1000:/app:ro" "$image" > "$work/negative.log" 2>&1; then - fail 'Container started without NET_BIND_SERVICE' -fi -grep -qi 'operation not permitted' "$work/negative.log" || fail 'Negative control failed for an unrelated reason' -echo 'ok: dropping NET_BIND_SERVICE prevents execution' - -start_proxy -v "$work/migration:/app" -docker exec "$proxy" sh -c ' - test "$(stat -c %u:%g:%a /app/config.migrated.toml)" = 1000:1000:600 && - grep -q "\[\[machines\]\]" /app/config.migrated.toml && - grep -q "\[\[routes\]\]" /app/config.migrated.toml -' || fail 'Migrated config missing or has incorrect permissions' -docker exec "$proxy" cmp /app/config.toml /app/config.migrated.toml >/dev/null 2>&1 && fail 'Migration did not translate the config' -stop_proxy -cmp "$work/legacy.toml" "$work/migration/config.toml" || fail 'Original config was modified' - -start_proxy -v "$work/legacy.toml:/app/config.toml:ro" -logs=$(docker logs "$proxy" 2>&1) -[[ "$logs" == *'the migrated config follows'* && "$logs" == *'[[machines]]'* && "$logs" == *'[[routes]]'* ]] || fail 'Missing migration fallback' -docker exec "$proxy" test ! -e /app/config.migrated.toml || fail 'Unexpected migration output' -stop_proxy -echo 'ok: writable-directory migration and read-only single-file fallback' From c10513e69e9a8813ad1e8abec61616acf3dd8b53 Mon Sep 17 00:00:00 2001 From: darksworm <9987548+darksworm@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:40:32 +0200 Subject: [PATCH 3/3] chore: replace GoReleaser with Docker Bake --- .dockerignore | 7 +++ .../{goreleaser.yml => publish-image.yml} | 44 ++++++++------ .github/workflows/release-pipeline.yml | 8 +-- .github/workflows/test.yml | 8 +-- .gitignore | 2 +- .goreleaser.release.yml | 60 ------------------- Dockerfile.release => Dockerfile | 18 ++++-- README.md | 21 ++++--- docker-bake.hcl | 22 +++++++ e2e/README.md | 2 +- e2e/harness_test.go | 10 ++-- scripts/build-container.sh | 18 ------ 12 files changed, 92 insertions(+), 128 deletions(-) create mode 100644 .dockerignore rename .github/workflows/{goreleaser.yml => publish-image.yml} (70%) delete mode 100644 .goreleaser.release.yml rename Dockerfile.release => Dockerfile (83%) create mode 100644 docker-bake.hcl delete mode 100644 scripts/build-container.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4c697a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Only application source and module metadata belong in the build context. +** +!Dockerfile +!go.mod +!go.sum +!*.go +*_test.go diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/publish-image.yml similarity index 70% rename from .github/workflows/goreleaser.yml rename to .github/workflows/publish-image.yml index f30dced..608eda9 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/publish-image.yml @@ -1,4 +1,4 @@ -name: goreleaser +name: publish-image # Builds the release artifacts for an existing tag and pushes the container # image. Called by release-pipeline.yml right after release-please cuts a tag, @@ -22,21 +22,14 @@ permissions: packages: write jobs: - goreleaser: + publish-image: runs-on: ubuntu-latest steps: - name: Checkout tag uses: actions/checkout@v7 with: ref: ${{ inputs.tag }} - # GoReleaser derives the version from the tag, so a shallow checkout - # without tags would make it fall back to a snapshot version. - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: go.mod + persist-credentials: false # The release image has RUN steps (it creates the runtime user and stamps # the port-binding capability on the binary), so building the arm64 image @@ -54,14 +47,31 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + - name: Image metadata + id: meta + uses: docker/metadata-action@v6 with: - distribution: goreleaser - version: "~> v2.18" - args: release --config .goreleaser.release.yml --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + context: git + images: ghcr.io/darksworm/doormouse + tags: | + type=semver,pattern={{version}},value=${{ inputs.tag }} + type=semver,pattern={{major}}.{{minor}},value=${{ inputs.tag }} + type=semver,pattern={{major}},value=${{ inputs.tag }} + labels: | + org.opencontainers.image.title=doormouse + org.opencontainers.image.description=A reverse proxy that wakes your servers when someone knocks + org.opencontainers.image.licenses=GPL-3.0-or-later + + - name: Build and push images + uses: docker/bake-action@v7 + with: + # Use the checked-out tag, including during manual retries. + source: . + files: | + ./docker-bake.hcl + cwd://${{ steps.meta.outputs.bake-file }} + targets: release + push: true # release-please creates the release as a draft so it only becomes visible # once the image it describes is actually pullable. diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml index e170e77..ac3b20b 100644 --- a/.github/workflows/release-pipeline.yml +++ b/.github/workflows/release-pipeline.yml @@ -2,7 +2,7 @@ name: release-pipeline # On every push to main, release-please keeps a release PR up to date from the # conventional-commit history. Merging that PR is what cuts a release: it tags -# the commit, drafts the release notes, and hands the tag to goreleaser, which +# the commit, drafts the release notes, and hands the tag to publish-image, which # builds and pushes the versioned image before the release goes public. on: push: @@ -45,11 +45,11 @@ jobs: # # If this job fails, the tag and the draft release already exist, so a later # push to main will not retry it — release-please only reports - # release_created once. Re-run the goreleaser workflow directly instead; it + # release_created once. Re-run the publish-image workflow directly instead; it # takes the tag as a workflow_dispatch input for exactly this case. - goreleaser: + publish-image: needs: [release-please, test] if: ${{ needs.release-please.outputs.release_created == 'true' }} - uses: ./.github/workflows/goreleaser.yml + uses: ./.github/workflows/publish-image.yml with: tag: ${{ needs.release-please.outputs.tag_name }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f1802ad..61078af 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,12 +77,8 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Build the arm64 image - run: | - mkdir -p arm64-context/linux/arm64 - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o arm64-context/linux/arm64/doormouse . - docker buildx build --platform linux/arm64 -f Dockerfile.release \ - --output type=cacheonly arm64-context + - name: Build both release architectures + run: docker buildx bake release --set release.output=type=cacheonly - name: Test container end to end run: go -C e2e test -race -count=1 -timeout=5m -v ./... diff --git a/.gitignore b/.gitignore index 751b0cd..b27050c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,5 @@ doormouse go-wol-proxy *.migrated.toml -# goreleaser output +# release build output dist/ diff --git a/.goreleaser.release.yml b/.goreleaser.release.yml deleted file mode 100644 index 949245c..0000000 --- a/.goreleaser.release.yml +++ /dev/null @@ -1,60 +0,0 @@ -version: 2 - -project_name: doormouse - -before: - hooks: - - go mod download - -builds: - - id: doormouse - main: . - binary: doormouse - env: - - CGO_ENABLED=0 - flags: - - -trimpath - ldflags: - - -s -w - # doormouse is a Linux daemon that has to sit in the target's broadcast - # domain, and the container image is the only release channel, so there is - # nothing to gain from darwin/windows builds. - goos: [linux] - goarch: [amd64, arm64] - -# The container image is the only published artifact, so goreleaser neither -# builds archives nor touches the GitHub release. release-please owns the -# release and its notes; the workflow undrafts it once the image is pushed. -archives: - - formats: [binary] - -release: - disable: true - -changelog: - disable: true - -dockers_v2: - - id: image - dockerfile: Dockerfile.release - ids: [doormouse] - images: - - ghcr.io/darksworm/doormouse - # Rolling tags let a compose file track a major or minor line and still get - # patch updates. :latest stays for the quick start in the README. - tags: - - "{{ .Version }}" - - "{{ .Major }}.{{ .Minor }}" - - "{{ .Major }}" - - latest - platforms: - - linux/amd64 - - linux/arm64 - labels: - org.opencontainers.image.created: "{{ .Date }}" - org.opencontainers.image.title: "{{ .ProjectName }}" - org.opencontainers.image.description: "A reverse proxy that wakes your servers when someone knocks" - org.opencontainers.image.revision: "{{ .FullCommit }}" - org.opencontainers.image.version: "{{ .Version }}" - org.opencontainers.image.licenses: "GPL-3.0-or-later" - org.opencontainers.image.source: "https://github.com/darksworm/doormouse" diff --git a/Dockerfile.release b/Dockerfile similarity index 83% rename from Dockerfile.release rename to Dockerfile index 6b4affb..c47a822 100644 --- a/Dockerfile.release +++ b/Dockerfile @@ -1,6 +1,14 @@ -# Runtime image for released versions. It only packages the binary GoReleaser -# has already cross-compiled, so the RUN steps below are all the arm64 build has -# to run under emulation on an amd64 runner. +# Compile on the builder's native architecture, even for cross-platform images. +ARG BUILDPLATFORM +FROM --platform=$BUILDPLATFORM golang:1.24.3-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go ./ +ARG TARGETOS=linux +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/doormouse . + FROM alpine:3.22 # Everything that does not need the binary happens before it is copied in, so a @@ -21,11 +29,9 @@ RUN addgroup -g 1000 doormouse \ WORKDIR /app -# GoReleaser dockers_v2 places each platform's binary under $TARGETPLATFORM/ -ARG TARGETPLATFORM # The binary lives outside /app so that /app can be bind-mounted as a whole # writable config directory without handing the runtime user its own binary. -COPY ${TARGETPLATFORM}/doormouse /usr/local/bin/doormouse +COPY --from=build /out/doormouse /usr/local/bin/doormouse # doormouse runs as a non-root user, and the kernel would otherwise stop it from # binding ports below 1024. This file capability grants that one bind permission, diff --git a/README.md b/README.md index ee43863..ccbbdf4 100644 --- a/README.md +++ b/README.md @@ -416,24 +416,27 @@ wake/proxy/SSH-shutdown lifecycle, runtime permissions, and config migration: go -C e2e test -race -count=1 -timeout=5m -v ./... ``` -To build a local container image, use Go and Docker: +To build a local container image, only Docker is required: ```bash -bash scripts/build-container.sh doormouse:local +docker build -t doormouse:local . ``` -The build script compiles for your native architecture and packages the binary -with `Dockerfile.release`, so local and published images share the same non-root -runtime. Set `GOARCH=arm64` or `GOARCH=amd64` to cross-build; executing image build -steps for another architecture requires QEMU/binfmt emulation. +The multi-stage `Dockerfile` compiles Go inside Docker and packages the binary +in the same non-root runtime used for releases. No host Go installation is needed. +With Docker Buildx, `docker buildx bake` also builds and loads `doormouse:local`. -To build both release architectures with [GoReleaser](https://goreleaser.com), -install Docker Buildx and configure QEMU/binfmt emulation first: +To check both release architectures (amd64 and arm64), configure QEMU/binfmt +emulation for the runtime image's build steps, then run: ```bash -goreleaser release --config .goreleaser.release.yml --snapshot --clean +docker buildx bake release --set release.output=type=cacheonly ``` +The release workflow uses that same Bake target to publish version, minor, +major, and `latest` tags to GHCR. The GitHub release stays a draft until the +images have been pushed successfully. + ## Similar projects - [traefik-wol](https://github.com/MarkusJx/traefik-wol), a Traefik plugin. diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000..577478b --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,22 @@ +group "default" { + targets = ["local"] +} + +// The release workflow supplies version tags and OCI labels here. +target "docker-metadata-action" {} + +target "image" { + context = "." + dockerfile = "Dockerfile" +} + +target "local" { + inherits = ["image"] + tags = ["doormouse:local"] + output = ["type=docker"] +} + +target "release" { + inherits = ["image", "docker-metadata-action"] + platforms = ["linux/amd64", "linux/arm64"] +} diff --git a/e2e/README.md b/e2e/README.md index 1546e8e..0e00746 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -6,7 +6,7 @@ Run from the repository root with Go and a local Linux Docker daemon: go -C e2e test -race -count=1 -timeout=5m -v ./... ``` -The suite compiles the application, builds `Dockerfile.release` once, and uses +The suite builds the application from source using `Dockerfile` once, and uses Testcontainers to manage isolated containers and networks. No prebuilt application image, shell script, privileged host ports, or root test runner is required. Test dependencies live in this module; ordinary `go test ./...` stays Docker-free. diff --git a/e2e/harness_test.go b/e2e/harness_test.go index e2270d1..d64213f 100644 --- a/e2e/harness_test.go +++ b/e2e/harness_test.go @@ -47,18 +47,16 @@ func buildSuite(t *testing.T) suite { defer cancel() dir := t.TempDir() platform := "linux/" + runtime.GOARCH - must(t, os.MkdirAll(filepath.Join(dir, platform), 0o755)) - compileGo(t, "..", ".", filepath.Join(dir, platform, "doormouse")) - dockerfile, err := os.ReadFile("../Dockerfile.release") - must(t, err) - must(t, os.WriteFile(filepath.Join(dir, "Dockerfile"), dockerfile, 0o644)) + osName, arch := "linux", runtime.GOARCH var buildLog bytes.Buffer // Create without starting to build the release image once. Keeping this // container until all subtests finish also gives Testcontainers ownership // of image cleanup, including when a test fails. image, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{ ContainerRequest: tc.ContainerRequest{FromDockerfile: tc.FromDockerfile{ - Context: dir, BuildArgs: map[string]*string{"TARGETPLATFORM": &platform}, + Context: "..", BuildArgs: map[string]*string{ + "BUILDPLATFORM": &platform, "TARGETOS": &osName, "TARGETARCH": &arch, + }, BuildLogWriter: &buildLog, }}, }) diff --git a/scripts/build-container.sh b/scripts/build-container.sh deleted file mode 100644 index 501d676..0000000 --- a/scripts/build-container.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build from source with the same runtime Dockerfile used by GoReleaser. -set -euo pipefail -cd "$(dirname "$0")/.." - -image=${1:-doormouse:local} -arch=${GOARCH:-$(go env GOARCH)} -case "$arch" in - amd64|arm64) ;; - *) echo "Unsupported container architecture: $arch" >&2; exit 1 ;; -esac -context=$(mktemp -d) -trap 'rm -rf "$context"' EXIT -mkdir -p "$context/linux/$arch" -CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath \ - -o "$context/linux/$arch/doormouse" . -docker build --platform "linux/$arch" --build-arg "TARGETPLATFORM=linux/$arch" \ - -f Dockerfile.release -t "$image" "$context"