diff --git a/.dockerignore b/.dockerignore index 3d4040f..4c697a4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,30 +1,7 @@ -# 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 +# 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 61% rename from .github/workflows/goreleaser.yml rename to .github/workflows/publish-image.yml index e9432a7..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,20 @@ 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 + persist-credentials: false - - name: Set up Go - uses: actions/setup-go@v7 - 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 @@ -48,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 97aee65..61078af 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,34 @@ 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. 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: + - 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 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 b/Dockerfile index c3ec213..c47a822 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,56 @@ -FROM golang:1.23-alpine AS builder - -WORKDIR /app - -# Copy go.mod and go.sum files +# 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 ./ - -# Download dependencies 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 . -# 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 +# 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 -# Copy the binary from the builder stage -COPY --from=builder /app/doormouse /app/ +WORKDIR /app -# Expose the default port. TCP routes listen on their own ports; with -# network_mode: host they are reachable directly, otherwise publish each one. +# 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 --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, +# 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 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 -# Run the application -ENTRYPOINT ["/app/doormouse", "/app/config.toml"] +ENTRYPOINT ["/usr/local/bin/doormouse", "/app/config.toml"] diff --git a/Dockerfile.release b/Dockerfile.release deleted file mode 100644 index c731652..0000000 --- a/Dockerfile.release +++ /dev/null @@ -1,18 +0,0 @@ -# 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. -FROM alpine:3.22 - -WORKDIR /app - -# GoReleaser dockers_v2 places each platform's binary under $TARGETPLATFORM/ -ARG TARGETPLATFORM -COPY ${TARGETPLATFORM}/doormouse /app/doormouse - -# 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. -EXPOSE 8080 - -ENTRYPOINT ["/app/doormouse", "/app/config.toml"] diff --git a/README.md b/README.md index 8145a7d..ccbbdf4 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,14 +384,59 @@ 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 . ``` +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, only Docker is required: + +```bash +docker build -t doormouse:local . +``` + +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 check both release architectures (amd64 and arm64), configure QEMU/binfmt +emulation for the runtime image's build steps, then run: + +```bash +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 new file mode 100644 index 0000000..0e00746 --- /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 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. + +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..d64213f --- /dev/null +++ b/e2e/harness_test.go @@ -0,0 +1,272 @@ +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 + 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: "..", BuildArgs: map[string]*string{ + "BUILDPLATFORM": &platform, "TARGETOS": &osName, "TARGETARCH": &arch, + }, + 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/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 // ---------------------------------------------------------------------------