Add private registry credentials, and stop update checks lying (0.56.0)
CI / check (push) Successful in 12m33s
CI / build-and-push (push) Successful in 2m1s

This was on the gap list as a missing feature, but it was a bug first. The
update checker asks the registry for a tag's digest over HTTP itself, and could
only do it anonymously. A private repository answers 401, remote_digest returned
None, and None already meant "could not reach registry" — so a private image was
indistinguishable from a network blip. The Images page showed nothing and a
stack pinned to a six-month-old image looked up to date indefinitely.

So AuthRequired is now its own exception, separate from unreachable, and the
error names the registry and which of the two problems it is: "ghcr.io needs
credentials" when there are none, "ghcr.io rejected the stored credentials" when
there are and they are wrong. Those are different fixes, and the message should
say which one you need. The plain unreachable message survives unchanged, with a
test pinning it, because not every failure is an auth failure.

Two consumers need the credentials and they need them in completely different
shapes, which is why this is its own service rather than a field on something
else. StackPilot's own checker wants (user, password) inside async code that has
no database session, so the rows are mirrored into an in-memory cache that
reload() refills on startup and after every write. The Docker CLI wants a
config.json, so reload() writes one into ${DATA_DIR}/docker and compose runs with
DOCKER_CONFIG pointed at it. Generating it from the database every time is what
makes deletion real: removing a registry in the UI revokes the CLI's login
instead of leaving a stale one in ~/.docker.

Host normalization is the join that makes any of it work, and it is easy to
underestimate. parse_ref only ever produces registry-1.docker.io, nobody types
that, and the CLI wants the whole thing under https://index.docker.io/v1/ — three
spellings of one registry across three layers. canonical_host settles on what
parse_ref produces, the config writer translates on the way out, and a bare
nginx:alpine finds credentials entered as "docker.io". Verified end to end:
typed as the v1 URL, stored as registry-1.docker.io, written as the v1 URL.

The password is encrypted at rest with the same key as backup destinations and
never leaves the server, not even masked — the API returns has_password, which
is all the form needs to offer "leave blank to keep". A row that cannot be
decrypted after a SECRET_KEY change is skipped with a warning rather than taking
every other registry down with it. Everything here is admin-only including the
reads, because even masked the rows say which registries this install talks to
and under what account.

The Test button asks the registry rather than validating a string, following the
Bearer challenge with credentials attached the way a real client does. Only an
outright 401 counts as wrong credentials; anything else means reachable and
talking, which is as much as a credentials check can honestly claim. Checked
against the live Docker Hub token endpoint with deliberately wrong credentials.

33 tests: the normalization table, the cache, the generated config.json down to
its 0600 mode and the Docker Hub key, encryption at rest, that no password field
appears in any response, and the 401-is-reported behaviour that started this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-09-18 00:42:27 +02:00
co-authored by Claude Opus 5
parent a2adb59526
commit 95e03f031f
13 changed files with 1277 additions and 14 deletions
+54
View File
@@ -13,6 +13,41 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
> complete.
## Upgrading to 0.56.0 — private registries, and a silent bug fixed
**Update checks on private images were lying.** StackPilot asks the registry for
a tag's current digest itself, and it could only do that anonymously. A private
repository answers `401`, the check gave up, and the result was indistinguishable
from a network blip — so the Images page said nothing and a stack sitting on a
six-month-old image looked up to date. It now says
`ghcr.io needs credentials`, or `ghcr.io rejected the stored credentials` when
there are some and they are wrong.
**Settings → Private registries** takes a login per registry (Docker Hub,
ghcr.io, or your own), with a *Test* button that actually asks the registry. Add
one and the update check starts working for those images.
The same credentials also reach `docker compose pull`. StackPilot writes a
Docker CLI config into `${DATA_DIR}/docker/config.json` (mode 0600, regenerated
from the database on every change) and runs compose with `DOCKER_CONFIG` pointed
at it — so pulling a private image works without anyone running `docker login`
inside the container, and removing a registry in the UI actually revokes the
CLI's access instead of leaving a stale login behind.
Passwords are encrypted at rest with the same key as backup destinations, and
are never sent to the browser — not even masked. Editing a registry with the
password field left blank keeps the stored one. A registry whose password
cannot be decrypted (a changed `SECRET_KEY`) is skipped with a warning rather
than taking the others down with it.
Host spellings are normalized, which is the join that makes the whole thing
work: `docker.io`, `index.docker.io`, `https://index.docker.io/v1/` and
`registry-1.docker.io` are one registry, because a bare `nginx:alpine` resolves
to the last of those while nobody types it that way. Prefer an access token over
your account password — read scope is enough.
Nothing to do if you only use public images.
## Upgrading to 0.55.0 — nothing to do
**Images and Networks are grouped by stack too**, the same way Volumes were in
@@ -318,6 +353,12 @@ it is what your saved destination credentials are encrypted with.
compose** converter.
- **Dashboard** — system resource bar, stack grid with quick actions, and a
recent-activity audit feed.
- **Private registries** — a login per registry (Settings → Private registries)
used both by StackPilot's own update checks and by `docker compose pull`,
which it reaches through a generated `DOCKER_CONFIG`. Passwords are encrypted
at rest and never leave the server. Without one, an image whose registry
demands auth now reports *needs credentials* instead of quietly looking up to
date.
- **Auto-discovery** — stacks created outside the UI (any folder under the stacks
dir containing a compose file) are picked up automatically.
- **Dark / light theme.**
@@ -863,6 +904,16 @@ GET /api/dashboard/funnel[?refresh=true] (stack-health funnel, 30s TTL cache)
GET /api/dashboard/summary (containers, uptime series, ops activity)
```
### Private registry endpoints
```
GET /api/registries (admin; passwords are never returned)
POST /api/registries ({"host","username","password","name"?})
PUT /api/registries/{id} (omit "password" to keep the stored one)
DELETE /api/registries/{id} (also drops the Docker CLI login)
POST /api/registries/test (verify credentials; omit "password" to test a saved one)
```
### Stack icon endpoints
```
@@ -878,6 +929,9 @@ GET /api/stacks/icons/logo/{slug} (one catalog logo, served from our cache)
- The Docker socket is only ever touched by the backend process; it is never
proxied to the browser.
- Registry passwords and backup-destination credentials are encrypted at rest
(Fernet, key derived from `SECRET_KEY`). The generated Docker CLI config that
carries them for `compose pull` is written 0600 inside the data volume.
- Login is rate-limited (10/min/IP).
- Compose files are backed up to `*.bak` before every overwrite.
- Generated YAML never includes the obsolete `version:` field and uses Compose v2