diff --git a/TESTING.md b/TESTING.md index 2a6d9f2..62d8147 100644 --- a/TESTING.md +++ b/TESTING.md @@ -473,3 +473,10 @@ without the application's external Go dependencies: ```bash go test ./internal/customer/docker.go ./internal/customer/docker_test.go ``` + +## V4.2.5 worker identity-volume migration check + +After publishing the new worker image, start a hosted worker that already owns +an identity volume from an older release. It must start without `permission +denied`, keep the same client identity, and remain writable at +`/identity/identity.json`. Do not delete the named volume for this test. diff --git a/V4.2.5_WORKER_IDENTITY_PERMISSION_FIX.md b/V4.2.5_WORKER_IDENTITY_PERMISSION_FIX.md new file mode 100644 index 0000000..24f435b --- /dev/null +++ b/V4.2.5_WORKER_IDENTITY_PERMISSION_FIX.md @@ -0,0 +1,24 @@ +# V4.2.5 – Worker identity volume permission fix + +Managed workers persist `/identity/identity.json` in a Docker named volume. A +new named volume is normally root-owned, while identities from older releases +may already be mode `0600` and owned by a different UID. With the hardened +worker capability set this could make the client fail immediately with: + +``` +open /identity/identity.json: permission denied +``` + +V4.2.5 makes the worker image normalize only `/identity` and the fixed +`/identity/identity.json` path before startup. The entrypoint starts as root, +fixes ownership, and immediately executes `/app/neuralhunt-client` as the +unprivileged `app` user via `su-exec`. + +Customer Service still drops all Linux capabilities and adds back only the four +bootstrap capabilities needed by that entrypoint: `CHOWN`, `DAC_OVERRIDE`, +`SETUID`, and `SETGID`. Workers still receive no Docker socket and retain a +read-only root filesystem. + +Existing worker identity volumes are intentionally kept. Rebuilding/pulling the +worker image and restarting an affected worker is sufficient; deleting the +worker/volume would destroy its identity and is not required. diff --git a/docker/worker-entrypoint.sh b/docker/worker-entrypoint.sh new file mode 100644 index 0000000..d2d42dd --- /dev/null +++ b/docker/worker-entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +# Docker named volumes are normally created as root:root. Older Neural Hunt +# worker versions could also leave identity.json owned by a different UID. +# Normalize only the dedicated identity mount and the fixed identity file, then +# permanently drop to the unprivileged app user before the Go client starts. +if [ "$(id -u)" = "0" ]; then + if [ -L /identity/identity.json ]; then + echo "refusing symlink at /identity/identity.json" >&2 + exit 1 + fi + + chown app:app /identity + if [ -e /identity/identity.json ]; then + chown app:app /identity/identity.json + fi + + exec su-exec app:app /app/neuralhunt-client "$@" +fi + +exec /app/neuralhunt-client "$@" diff --git a/internal/customer/docker.go b/internal/customer/docker.go index fece814..f203765 100644 --- a/internal/customer/docker.go +++ b/internal/customer/docker.go @@ -210,10 +210,10 @@ func (d *DockerClient) CreateWorker(ctx context.Context, c WorkerContainerConfig name := url.QueryEscape(c.Name) body := map[string]any{ "Image": c.Image, - // Named Docker volumes are root-owned when first mounted. Managed workers - // therefore run uid 0 only inside their own locked-down container so they - // can create the 0600 identity file. They receive no Docker socket, all - // Linux capabilities are dropped and the image root filesystem is read-only. + // Named Docker volumes are root-owned when first mounted, and identities + // created by older releases may be owned by a different UID. The dedicated + // worker image starts as uid 0 only for its tiny ownership-normalization + // entrypoint and immediately drops to the unprivileged app user. "User": "0:0", "Cmd": []string{"-url", c.GameURL, "-identity", "/identity/identity.json", "-non-interactive", "-quiet", "-task", c.TaskID, "-beacon-path", c.BeaconPath}, "Env": []string{ @@ -228,10 +228,13 @@ func (d *DockerClient) CreateWorker(ctx context.Context, c WorkerContainerConfig "NetworkMode": c.Network, "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, - "SecurityOpt": []string{"no-new-privileges"}, - "PidsLimit": 128, - "Memory": 256 * 1024 * 1024, - "NanoCpus": int64(1_000_000_000), + // Bootstrap-only capabilities: the image entrypoint fixes ownership of + // /identity and then su-exec permanently switches to uid/gid app. + "CapAdd": []string{"CHOWN", "DAC_OVERRIDE", "SETUID", "SETGID"}, + "SecurityOpt": []string{"no-new-privileges"}, + "PidsLimit": 128, + "Memory": 256 * 1024 * 1024, + "NanoCpus": int64(1_000_000_000), }, } // A dedicated worker image already declares /app/neuralhunt-client as its