From 04a0a535ffd8b62c907705e4aafded0884513d99 Mon Sep 17 00:00:00 2001 From: strandedturtle Date: Tue, 30 Jun 2026 14:14:44 +0000 Subject: [PATCH 1/4] Feature: check private images via mounted Docker credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read static auths from $DOCKER_CONFIG/config.json (or ~/.docker/config.json) and authenticate registry requests: send Basic on the token request for the standard Bearer flow (GHCR, Docker Hub) and fall back to direct HTTP Basic for registries that want it. Previously private images were skipped and anonymous Docker Hub pulls could hit the rate limit. No secrets are stored by the app — credentials live only in the mounted Docker config. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013Lj6nYJQDtLaZFvvEQJGM4 --- .env.example | 8 +++ README.md | 15 ++++-- server/src/registry-auth.js | 85 +++++++++++++++++++++++++++++++ server/src/registry.js | 66 +++++++++++++++--------- server/test/registry-auth.test.js | 40 +++++++++++++++ 5 files changed, 187 insertions(+), 27 deletions(-) create mode 100644 server/src/registry-auth.js create mode 100644 server/test/registry-auth.test.js diff --git a/.env.example b/.env.example index 71dd3b3..d915cf0 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,14 @@ STACKS_DIR=/opt/stacks # Path to the host Docker socket, bind-mounted into the container. DOCKER_SOCKET=/var/run/docker.sock +# Optional: directory holding a Docker config.json with registry credentials +# (what `docker login` writes). Lets DockPull check PRIVATE images and avoid +# Docker Hub's anonymous rate limit. Mount your config and point this at its +# directory, e.g. mount ~/.docker/config.json -> /root/.docker/config.json:ro +# and leave this unset (the default /root/.docker is used), or set a custom dir. +# Only static base64 `auths` are supported (not credential-helper stores). +# DOCKER_CONFIG=/root/.docker + # Directory where the SQLite database is stored (should be a persistent # volume). DATA_DIR=/data diff --git a/README.md b/README.md index 475dacb..5edc68d 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,18 @@ If the paths don't match you'll get `compose file not found` and broken bind mou - **Install as an app (PWA)** — use your browser's "Add to Home Screen" / "Install" to get a standalone, full-screen icon. -The update check queries registries directly (Docker Hub, GHCR, lscr.io, quay.io, …) -for **public** images reachable anonymously. Private images that need credentials are -skipped. +The update check queries registries directly (Docker Hub, GHCR, lscr.io, quay.io, …). +Public images work anonymously. For **private** images (and to dodge Docker Hub's +anonymous rate limit), mount your Docker credentials read-only so DockPull can +authenticate: + +```yaml + volumes: + - ~/.docker/config.json:/root/.docker/config.json:ro +``` + +This is the file `docker login` writes; only static `auths` entries are used (not +credential-helper stores). --- diff --git a/server/src/registry-auth.js b/server/src/registry-auth.js new file mode 100644 index 0000000..290d20a --- /dev/null +++ b/server/src/registry-auth.js @@ -0,0 +1,85 @@ +/** + * Registry credentials from the host's Docker config. + * + * Reads `auths` out of `$DOCKER_CONFIG/config.json` (or `~/.docker/config.json`) + * — the file `docker login` writes — so the checker can query private images + * and avoid Docker Hub's anonymous rate limit. Only the static base64 + * `auths[host].auth` form is supported (the common headless-server case); + * credential *helpers* / stores (Docker Desktop keychains) are not, since they + * require running external `docker-credential-*` binaries. Operators using a + * cred store should provide a plain config.json instead. + * + * No secrets are stored by the app — credentials live only in the mounted + * Docker config, exactly like Watchtower/Diun. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// All the spellings Docker Hub appears under in a config.json `auths` map. +const DOCKER_HUB_ALIASES = new Set([ + 'index.docker.io', + 'docker.io', + 'registry-1.docker.io', + 'registry.hub.docker.com', +]); + +let cache; // memoized Map ; undefined until first load + +function configPath() { + if (process.env.DOCKER_CONFIG) { + return path.join(process.env.DOCKER_CONFIG, 'config.json'); + } + return path.join(os.homedir() || '/root', '.docker', 'config.json'); +} + +// Strip scheme + any path so "https://index.docker.io/v1/" -> "index.docker.io". +function normalizeAuthHost(key) { + return String(key) + .replace(/^https?:\/\//, '') + .replace(/\/.*$/, ''); +} + +function load() { + if (cache) return cache; + const map = new Map(); + try { + const json = JSON.parse(fs.readFileSync(configPath(), 'utf8')); + const auths = json && typeof json.auths === 'object' ? json.auths : {}; + for (const [key, val] of Object.entries(auths)) { + let b64 = typeof val?.auth === 'string' ? val.auth : null; + if (!b64 && val?.username && val?.password) { + b64 = Buffer.from(`${val.username}:${val.password}`).toString('base64'); + } + if (b64) map.set(normalizeAuthHost(key), b64); + } + } catch { + // No config, unreadable, or malformed — just means no creds available. + } + cache = map; + return cache; +} + +/** + * Base64 `user:pass` for a registry host, or null if none is configured. + * @param {string} registry e.g. "docker.io", "ghcr.io" + * @returns {string|null} + */ +export function basicAuthForRegistry(registry) { + const auths = load(); + if (DOCKER_HUB_ALIASES.has(registry)) { + for (const alias of DOCKER_HUB_ALIASES) { + if (auths.has(alias)) return auths.get(alias); + } + return null; + } + return auths.get(registry) || null; +} + +/** Test hook: drop the memoized config so a fresh DOCKER_CONFIG is re-read. */ +export function _resetAuthCache() { + cache = undefined; +} + +export default { basicAuthForRegistry, _resetAuthCache }; diff --git a/server/src/registry.js b/server/src/registry.js index 4026e55..6dbbf18 100644 --- a/server/src/registry.js +++ b/server/src/registry.js @@ -10,6 +10,7 @@ */ import { parseRef } from './reconcile.js'; +import { basicAuthForRegistry } from './registry-auth.js'; const DOCKER_HUB_API_HOST = 'registry-1.docker.io'; @@ -45,13 +46,18 @@ export function parseWwwAuthenticate(header) { return params; } -async function fetchToken(wwwAuth, repository, timeoutMs) { +async function fetchToken(wwwAuth, repository, timeoutMs, basicAuth) { if (!wwwAuth.realm) return null; const url = new URL(wwwAuth.realm); if (wwwAuth.service) url.searchParams.set('service', wwwAuth.service); url.searchParams.set('scope', wwwAuth.scope || `repository:${repository}:pull`); const res = await fetch(url, { - headers: { Accept: 'application/json' }, + headers: { + Accept: 'application/json', + // Authenticate the token request when we have creds, so the registry + // grants pull scope for private repos (and a higher Docker Hub limit). + ...(basicAuth ? { Authorization: `Basic ${basicAuth}` } : {}), + }, signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) return null; @@ -59,6 +65,23 @@ async function fetchToken(wwwAuth, repository, timeoutMs) { return body?.token || body?.access_token || null; } +/** + * Resolve the `Authorization` header to use for registry requests after an + * initial 401: a Bearer token (standard token flow, authenticated with our + * Basic creds when available) or, for registries that want HTTP Basic directly, + * the Basic header itself. Returns null if no auth could be established. + */ +async function resolveAuthHeader(res401, registry, repository, timeoutMs) { + const basicAuth = basicAuthForRegistry(registry); + const wwwAuth = parseWwwAuthenticate(res401.headers.get('www-authenticate')); + if (wwwAuth?.realm) { + const token = await fetchToken(wwwAuth, repository, timeoutMs, basicAuth); + if (token) return `Bearer ${token}`; + } + if (basicAuth) return `Basic ${basicAuth}`; + return null; +} + /** * Resolve the current registry digest for an image ref's tag. * @@ -75,25 +98,23 @@ export async function getRemoteDigest(imageRef, { timeoutMs = 10000 } = {}) { const host = apiHost(registry); const manifestUrl = `https://${host}/v2/${repository}/manifests/${encodeURIComponent(tag)}`; - const headManifest = (token) => + const headManifest = (authHeader) => fetch(manifestUrl, { method: 'HEAD', headers: { Accept: MANIFEST_ACCEPT, - ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(authHeader ? { Authorization: authHeader } : {}), }, signal: AbortSignal.timeout(timeoutMs), }); let res = await headManifest(null); - // Standard token handshake: on 401, read the realm/scope and retry. + // On 401, establish auth (token flow, or HTTP Basic for private registries) + // using any configured Docker credentials, then retry. if (res.status === 401) { - const wwwAuth = parseWwwAuthenticate(res.headers.get('www-authenticate')); - if (wwwAuth) { - const token = await fetchToken(wwwAuth, repository, timeoutMs); - if (token) res = await headManifest(token); - } + const authHeader = await resolveAuthHeader(res, registry, repository, timeoutMs); + if (authHeader) res = await headManifest(authHeader); } if (!res.ok) { @@ -121,11 +142,11 @@ export function pickPlatformManifest(manifests) { ); } -async function authedJson(url, token, timeoutMs) { +async function authedJson(url, authHeader, timeoutMs) { const res = await fetch(url, { headers: { Accept: MANIFEST_ACCEPT, - ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(authHeader ? { Authorization: authHeader } : {}), }, signal: AbortSignal.timeout(timeoutMs), }); @@ -155,21 +176,18 @@ export async function getRemoteVersion(imageRef, { timeoutMs = 10000 } = {}) { const host = apiHost(registry); const manifestUrl = `https://${host}/v2/${repository}/manifests/${encodeURIComponent(tag)}`; - let token = null; + let authHeader = null; let res = await fetch(manifestUrl, { headers: { Accept: MANIFEST_ACCEPT }, signal: AbortSignal.timeout(timeoutMs), }); if (res.status === 401) { - const wwwAuth = parseWwwAuthenticate(res.headers.get('www-authenticate')); - if (wwwAuth) { - token = await fetchToken(wwwAuth, repository, timeoutMs); - if (token) { - res = await fetch(manifestUrl, { - headers: { Accept: MANIFEST_ACCEPT, Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(timeoutMs), - }); - } + authHeader = await resolveAuthHeader(res, registry, repository, timeoutMs); + if (authHeader) { + res = await fetch(manifestUrl, { + headers: { Accept: MANIFEST_ACCEPT, Authorization: authHeader }, + signal: AbortSignal.timeout(timeoutMs), + }); } } if (!res.ok) return null; @@ -181,7 +199,7 @@ export async function getRemoteVersion(imageRef, { timeoutMs = 10000 } = {}) { const picked = pickPlatformManifest(manifest.manifests); if (!picked?.digest) return null; const subUrl = `https://${host}/v2/${repository}/manifests/${picked.digest}`; - imageManifest = await authedJson(subUrl, token, timeoutMs); + imageManifest = await authedJson(subUrl, authHeader, timeoutMs); if (!imageManifest) return null; } @@ -190,7 +208,7 @@ export async function getRemoteVersion(imageRef, { timeoutMs = 10000 } = {}) { const blobUrl = `https://${host}/v2/${repository}/blobs/${configDigest}`; const blobRes = await fetch(blobUrl, { - headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + headers: { ...(authHeader ? { Authorization: authHeader } : {}) }, signal: AbortSignal.timeout(timeoutMs), }); if (!blobRes.ok) return null; diff --git a/server/test/registry-auth.test.js b/server/test/registry-auth.test.js new file mode 100644 index 0000000..428f68a --- /dev/null +++ b/server/test/registry-auth.test.js @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { basicAuthForRegistry, _resetAuthCache } from '../src/registry-auth.js'; + +function writeConfig(auths) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dockpull-docker-')); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ auths })); + process.env.DOCKER_CONFIG = dir; + _resetAuthCache(); + return dir; +} + +const ghcrB64 = Buffer.from('user:pat').toString('base64'); +const hubB64 = Buffer.from('hubuser:hubpass').toString('base64'); + +test('basicAuthForRegistry: ghcr by host', () => { + writeConfig({ 'ghcr.io': { auth: ghcrB64 } }); + assert.equal(basicAuthForRegistry('ghcr.io'), ghcrB64); + assert.equal(basicAuthForRegistry('quay.io'), null); +}); + +test('basicAuthForRegistry: docker hub matched via index.docker.io key', () => { + writeConfig({ 'https://index.docker.io/v1/': { auth: hubB64 } }); + assert.equal(basicAuthForRegistry('docker.io'), hubB64); + assert.equal(basicAuthForRegistry('registry-1.docker.io'), hubB64); +}); + +test('basicAuthForRegistry: derives base64 from username/password', () => { + writeConfig({ 'ghcr.io': { username: 'user', password: 'pat' } }); + assert.equal(basicAuthForRegistry('ghcr.io'), ghcrB64); +}); + +test('basicAuthForRegistry: no config -> null', () => { + process.env.DOCKER_CONFIG = fs.mkdtempSync(path.join(os.tmpdir(), 'dockpull-empty-')); + _resetAuthCache(); + assert.equal(basicAuthForRegistry('ghcr.io'), null); +}); From e39f4342210a847a5b4d3f3a50ef4b87db6447e9 Mon Sep 17 00:00:00 2001 From: strandedturtle Date: Tue, 30 Jun 2026 14:23:05 +0000 Subject: [PATCH 2/4] Feature: post-update health check + one-click revert After an update DockPull now verifies the container actually comes up: it polls state/health for ~30s and, if it crash-loops or stays unhealthy, reports an actionable failure instead of a false green. Every update that changes the image records a rollback point (the previous local image ID). A new POST /api/update/:name/revert recreates the container from that image and starts it, streaming over the same SSE channel; the dashboard shows a Revert button (with a confirm) whenever a rollback point exists. Works for compose- and standalone-managed containers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013Lj6nYJQDtLaZFvvEQJGM4 --- API_CONTRACT.md | 18 +++ README.md | 4 +- client/src/api.js | 4 + client/src/components/UpdateCard.jsx | 35 +++++- client/src/hooks/useUpdateRunner.js | 58 +++++---- client/src/styles/app.css | 9 ++ server/src/containers-service.js | 12 +- server/src/db.js | 42 +++++++ server/src/docker.js | 158 +++++++++++++++++++++++-- server/src/routes/api.js | 1 + server/src/routes/update.js | 79 ++++++++++++- server/test/containers-service.test.js | 2 + 12 files changed, 380 insertions(+), 42 deletions(-) diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 33fc9a3..08101ff 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -76,6 +76,20 @@ All request/response bodies are JSON unless noted otherwise. progress via the SSE endpoint below. - Errors: `404` if no such container; `409` if an update is already in progress for that container. +- Note: after `up -d`, the container is health-checked; if it doesn't come up + healthy the result is reported as `success:false` with an actionable message + (and a rollback point is recorded). + +### `POST /api/update/:name/revert` + +- Auth: cookie. +- Path param: `name` — container name. +- Recreates the container from the image it ran before its last update (the + rollback point), then starts it. Same SSE streaming + result shape as an + update; subscribe via `GET /api/update/:name/stream`. +- Response: `200 { "streamId": "string" }`. +- Errors: `404 no_rollback` if there's nothing to revert to; `404 not_found` + if no such container; `409` if an update/revert is already in progress. ### `GET /api/update/:name/stream` @@ -243,6 +257,10 @@ Field notes: - `pinned` — `true` if the image ref is in the `pinned` table ("Pin Version": update indicator is suppressed and the container is grouped separately, but a manual update is still allowed). +- `canRevert` — `true` if a rollback point exists (the container was updated and + its previous image is remembered), so the UI can offer a one-click revert. +- `rollbackVersion` — the previous version label for that rollback point, or + `null`. - `state` — Docker container state (`running`, `exited`, etc.). - `composeFile` / `workingDir` — derived from `com.docker.compose.project.config_files` / diff --git a/README.md b/README.md index 5edc68d..e0685e9 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ If the paths don't match you'll get `compose file not found` and broken bind mou - **Updates tab** — containers grouped by stack, update-available ones on top. Defaults to showing only what needs updating; flip to **All** to see everything. Tap **Update** to pull + recreate that service (watch live logs), or **Update all** - to run them one at a time. **Pin Version** holds a container at its current version. + to run them one at a time. After an update DockPull verifies the container actually + comes up healthy (catching crash-loops), and offers a one-click **Revert** to the + previous image if it doesn't. **Pin Version** holds a container at its current version. - **History tab** — a log of past updates. **Clear history** wipes it (with a confirm). - **Settings tab** — theme, default view, auto-check on open, the **daily background scan** + **Discord webhook** (with a "send test" button), and pinned-version diff --git a/client/src/api.js b/client/src/api.js index ba51bd0..81ba08c 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -101,6 +101,10 @@ export function startUpdate(name) { return post(`/update/${encodeURIComponent(name)}`); } +export function revertUpdate(name) { + return post(`/update/${encodeURIComponent(name)}/revert`); +} + // --- History --- export function getHistory(params = {}) { diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx index 8a5df48..410e5f0 100644 --- a/client/src/components/UpdateCard.jsx +++ b/client/src/components/UpdateCard.jsx @@ -3,6 +3,7 @@ import { pin, unpin, getChangelog } from '../api.js'; import { useUpdateRunner } from '../hooks/useUpdateRunner.js'; import StatusMessage from './StatusMessage.jsx'; import StreamLog from './StreamLog.jsx'; +import ConfirmDialog from './ConfirmDialog.jsx'; function shortDigest(digest) { if (!digest) return '—'; @@ -137,18 +138,19 @@ function ChangelogContent({ data }) { * - registerRunner(name, runFn) — handle for "Update all" */ export default function UpdateCard({ container, onSettled, onPinChange, registerRunner }) { - const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, pinned, sourceUrl } = + const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, pinned, sourceUrl, canRevert, rollbackVersion } = container; const [pinBusy, setPinBusy] = useState(false); const [actionError, setActionError] = useState(''); + const [confirmRevert, setConfirmRevert] = useState(false); const [clOpen, setClOpen] = useState(false); const [clLoading, setClLoading] = useState(false); const [clData, setClData] = useState(null); const [clError, setClError] = useState(''); - const { run, busy, startError, status, lines } = useUpdateRunner(name, onSettled); + const { run, revert, busy, startError, status, lines } = useUpdateRunner(name, onSettled); useEffect(() => { if (registerRunner) registerRunner(name, run); @@ -162,6 +164,12 @@ export default function UpdateCard({ container, onSettled, onPinChange, register run(); }, [busy, run]); + const handleRevert = useCallback(() => { + setConfirmRevert(false); + if (busy) return; + revert(); + }, [busy, revert]); + const togglePin = useCallback(async () => { setPinBusy(true); setActionError(''); @@ -265,6 +273,17 @@ export default function UpdateCard({ container, onSettled, onPinChange, register {clOpen ? 'Hide changes' : showUpdateAvailable ? "What's changed" : 'Release notes'} )} + {canRevert && ( + + )}