From a8387775705fc3d71a00165512190a756a0e081b Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 13 Oct 2026 09:32:09 +0200 Subject: [PATCH 1/2] chore(master): run the master as nobody in the example stack (#25) --- CLAUDE.md | 2 ++ README.md | 14 +++++++++----- cmd/master/main.go | 30 +++++++++++++++++++++-------- cmd/master/main_test.go | 42 +++++++++++++++++++++++++++++++++++++++++ deploy/master.stack.yml | 6 +++++- 5 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 cmd/master/main_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 611bc02..e23419d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,8 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/ - **Swarm updates need manager-node API access** for the agent doing the update. - **The image's default entrypoint is `updater-agent`.** The master overrides it with `updater-master`. +- **The example stack runs the master as nobody (`user: "65534:65534"`).** Never add `USER` + to the Dockerfile: the agent uses the same image and needs root for the Docker socket. - **The webhook waits via a broker signal plus a store re-read, with a fallback poll** (the broker drops signals for slow subscribers). Marking and starting jobs runs on `context.WithoutCancel`, so a client that disconnects cannot leave a queued job no agent diff --git a/README.md b/README.md index fe1cc94..13776f9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ a time per service, and one job at a time per agent. Use [`deploy/master.stack.yml`](deploy/master.stack.yml) as a starting point (Swarm stack; needs Postgres and an OIDC provider). The master listens for the UI on `PORT` (default `8080`, put it behind your reverse proxy) and for agents on `AGENT_PORT` (default `8090`, -pinned TLS, reached directly on the internal network). +pinned TLS, reached directly on the internal network). The example runs the master as +`nobody` (`65534`); it needs no root. The agent runs as root because access to the Docker +socket is root on the host anyway. | Variable | Required | Default | Purpose | |---|---|---|---| @@ -145,12 +147,14 @@ multi-arch image the digest is the index digest, the same one `docker inspect` s "poll failed" badge on the overview and in the "Polling" row of the service page; other tags continue. A later success clears it. - **Credentials.** The master reads `$DOCKER_CONFIG/config.json` (a file written by - `docker login`), mounted read-only into the container; without it registries are accessed + `docker login`), mounted read-only into the container and readable by the master's user (`65534` in the + example stack); without it registries are accessed anonymously. Only inline `auths` entries are supported. A `credsStore` or `credHelpers` entry makes every lookup fail with `error getting credentials ... executable file not found in $PATH`, because the container has no credential helper binaries. The master logs - `registry_credentials` at startup with the directory and whether `config.json` exists, - never its contents. + `registry_credentials` at startup with the directory, `config_json_found` and + `config_json_readable`, never the contents. If the file exists but cannot be opened it also + logs a `registry_credentials_unreadable` warning with the error. - **Requests go wherever the image name points.** The master sends the lookup to the registry host in the image name, including a `GET /v2/` ping that follows redirects. Anyone who can start containers on a managed host can therefore make the master send @@ -199,7 +203,7 @@ the agent's token (shown once) and the master's key fingerprint; the same layout | `AGENT_TOKEN` | yes | — | Token shown once when the agent was added. | | `SELF_ID` | no | hostname | Container ID or name of the agent itself; used to read its mounts. | | `UPDATE_TIMEOUT` | no | `5m` | Per-job timeout (Go duration). | -| `DOCKER_CONFIG` | no | — | Directory with a `config.json` holding registry credentials. Only inline `auths` entries work; `credsStore`/`credHelpers` are not available in the container. | +| `DOCKER_CONFIG` | no | — | Directory with a `config.json` holding registry credentials, readable by the master's user (see the `config_json_readable` and `registry_credentials_unreadable` log fields). Only inline `auths` entries work; `credsStore`/`credHelpers` are not available in the container. | | `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. | `docker compose` runs inside the agent with only `PATH`, `HOME` and the `DOCKER_*` diff --git a/cmd/master/main.go b/cmd/master/main.go index 76cc04a..821c495 100644 --- a/cmd/master/main.go +++ b/cmd/master/main.go @@ -48,19 +48,29 @@ const pollFirstDelay = time.Minute const pollWorkers = 4 // registryCredentialsDir returns the directory the registry client reads -// credentials from (DOCKER_CONFIG, else ~/.docker) and whether a config.json -// is in it. Only the path is logged, never the file. -func registryCredentialsDir() (dir string, found bool) { +// credentials from (DOCKER_CONFIG, else ~/.docker), whether a config.json is +// in it, and the error from opening it when it exists but cannot be read (for +// example because the master runs as another user than the file owner). The +// file is opened and closed, never read; only the path is logged. +func registryCredentialsDir() (dir string, found bool, readErr error) { dir = os.Getenv("DOCKER_CONFIG") if dir == "" { home, err := os.UserHomeDir() if err != nil { - return "", false + return "", false, nil } dir = filepath.Join(home, ".docker") } - _, err := os.Stat(filepath.Join(dir, "config.json")) - return dir, err == nil + path := filepath.Join(dir, "config.json") + if _, err := os.Stat(path); err != nil { + return dir, false, nil + } + f, err := os.Open(path) + if err != nil { + return dir, true, err + } + _ = f.Close() + return dir, true, nil } func run() error { @@ -154,8 +164,12 @@ func run() error { BaseContext: baseContext, } - credDir, credFound := registryCredentialsDir() - logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound) + credDir, credFound, credReadErr := registryCredentialsDir() + logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound, + "config_json_readable", credFound && credReadErr == nil) + if credReadErr != nil { + logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr) + } pollCtx, stopPolling := context.WithCancel(ctx) defer stopPolling() registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{ diff --git a/cmd/master/main_test.go b/cmd/master/main_test.go new file mode 100644 index 0000000..9a38e41 --- /dev/null +++ b/cmd/master/main_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistryCredentialsDir(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) + got, found, readErr := registryCredentialsDir() + assert.Equal(t, dir, got) + assert.False(t, found) + assert.NoError(t, readErr) + }) + + t.Run("readable file", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o600)) + t.Setenv("DOCKER_CONFIG", dir) + _, found, readErr := registryCredentialsDir() + assert.True(t, found) + assert.NoError(t, readErr) + }) + + t.Run("unreadable file", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read any file") + } + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o000)) + t.Setenv("DOCKER_CONFIG", dir) + _, found, readErr := registryCredentialsDir() + assert.True(t, found) + assert.Error(t, readErr) + }) +} diff --git a/deploy/master.stack.yml b/deploy/master.stack.yml index d9a1741..8d67ac8 100644 --- a/deploy/master.stack.yml +++ b/deploy/master.stack.yml @@ -12,12 +12,16 @@ # The reverse proxy must allow a read timeout longer than UPDATE_TIMEOUT (default 5m). # 5. Optional, for registry polling of private images: put a config.json written by # `docker login` (inline "auths" only; no credsStore) in /path/to/docker-config and -# uncomment DOCKER_CONFIG and the volumes entry below. The directory must exist on every +# uncomment DOCKER_CONFIG and the volumes entry below. The master runs as 65534:65534, so +# config.json must be readable by that user: `chmod 0644` it, or set `user:` below to the +# file owner's uid:gid. The directory must exist on every # manager node (the master is placed on a manager), or Swarm rejects the task. Without # it registries are accessed anonymously. services: master: image: gitea.shcizo.se/shcizo/package-updater:latest + # Runs as nobody: the master needs no root (the agent does, for the Docker socket). + user: "65534:65534" entrypoint: ["/bin/sh", "-c", "DATABASE_URL=$$(cat /run/secrets/pu_database_url) OIDC_CLIENT_SECRET=$$(cat /run/secrets/pu_oidc_client_secret) UPDATER_API_KEY=$$(cat /run/secrets/pu_updater_api_key) exec /usr/local/bin/updater-master"] environment: PUBLIC_URL: https://updater.example.com -- 2.54.0 From 9b9b3f94805fec6dfef9b2a0fd7067e83e1bc5df Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 13 Oct 2026 09:32:58 +0200 Subject: [PATCH 2/2] docs: keep the master's registry credentials private when running as nobody (#25) --- README.md | 11 ++++++----- deploy/master.stack.yml | 9 +++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 13776f9..4024776 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ socket is root on the host anyway. | `UPDATER_API_KEY` | no | — | Bearer token for `POST /update`. Without it the webhook answers `503` and the master logs `webhook_disabled`. | | `UPDATE_TIMEOUT` | no | `5m` | How long one `POST /update` waits for its auto jobs (Go duration). | | `POLL_INTERVAL` | no | `6h` | Time between registry polling cycles (Go duration, must be positive). The first cycle runs one minute after start. | -| `DOCKER_CONFIG` | no | `~/.docker` | Directory with a `config.json` holding registry credentials for polling. Only inline `auths` entries work; see [Registry polling](#registry-polling). | +| `DOCKER_CONFIG` | no | `~/.docker` | Directory with a `config.json` holding registry credentials for polling, readable by the master's user. Only inline `auths` entries work; see [Registry polling](#registry-polling). | The master's other settings (`DATABASE_URL`, `PUBLIC_URL`, `AGENT_URL`, `OIDC_*`, `ALLOWED_GROUP`, `AGENT_IMAGE`, `PORT`, `AGENT_PORT`, `LOG_LEVEL`) are shown in @@ -147,9 +147,10 @@ multi-arch image the digest is the index digest, the same one `docker inspect` s "poll failed" badge on the overview and in the "Polling" row of the service page; other tags continue. A later success clears it. - **Credentials.** The master reads `$DOCKER_CONFIG/config.json` (a file written by - `docker login`), mounted read-only into the container and readable by the master's user (`65534` in the - example stack); without it registries are accessed - anonymously. Only inline `auths` entries are supported. A `credsStore` or `credHelpers` + `docker login`), mounted read-only into the container; without it registries are accessed + anonymously. The directory and file must be readable by the master's user (`65534` in the + example stack) without being world-readable: `chown -R 65534:65534` the directory and keep + `0700`/`0600`. Only inline `auths` entries are supported. A `credsStore` or `credHelpers` entry makes every lookup fail with `error getting credentials ... executable file not found in $PATH`, because the container has no credential helper binaries. The master logs `registry_credentials` at startup with the directory, `config_json_found` and @@ -203,7 +204,7 @@ the agent's token (shown once) and the master's key fingerprint; the same layout | `AGENT_TOKEN` | yes | — | Token shown once when the agent was added. | | `SELF_ID` | no | hostname | Container ID or name of the agent itself; used to read its mounts. | | `UPDATE_TIMEOUT` | no | `5m` | Per-job timeout (Go duration). | -| `DOCKER_CONFIG` | no | — | Directory with a `config.json` holding registry credentials, readable by the master's user (see the `config_json_readable` and `registry_credentials_unreadable` log fields). Only inline `auths` entries work; `credsStore`/`credHelpers` are not available in the container. | +| `DOCKER_CONFIG` | no | — | Directory with a `config.json` holding registry credentials. Only inline `auths` entries work; `credsStore`/`credHelpers` are not available in the container. | | `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. | `docker compose` runs inside the agent with only `PATH`, `HOME` and the `DOCKER_*` diff --git a/deploy/master.stack.yml b/deploy/master.stack.yml index 8d67ac8..a651ab5 100644 --- a/deploy/master.stack.yml +++ b/deploy/master.stack.yml @@ -13,10 +13,11 @@ # 5. Optional, for registry polling of private images: put a config.json written by # `docker login` (inline "auths" only; no credsStore) in /path/to/docker-config and # uncomment DOCKER_CONFIG and the volumes entry below. The master runs as 65534:65534, so -# config.json must be readable by that user: `chmod 0644` it, or set `user:` below to the -# file owner's uid:gid. The directory must exist on every -# manager node (the master is placed on a manager), or Swarm rejects the task. Without -# it registries are accessed anonymously. +# the directory and config.json must be readable by that user while staying private: +# `chown -R 65534:65534 /path/to/docker-config` (keep 0700/0600), or set `user:` below +# to the owner's uid:gid. Do not make config.json world-readable; it holds registry +# passwords. The directory must exist on every manager node (the master is placed on a +# manager), or Swarm rejects the task. Without it registries are accessed anonymously. services: master: image: gitea.shcizo.se/shcizo/package-updater:latest -- 2.54.0