diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e934f15 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Publish image + +on: + push: + branches: [main] + tags: ["v*"] + +jobs: + publish: + name: Build and push to GHCR + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + # GHCR image names must be lowercase; the owner may not be. + - name: Compute lowercase image name + id: img + run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + - name: Docker metadata (tags/labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.img.outputs.name }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=edge,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: server/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 0c51c86..07fef7a 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -56,6 +56,27 @@ All request/response bodies are JSON unless noted otherwise. - Auth: cookie. - Response: `200` — array of container items (shape below). +### `POST /api/check` + +- Auth: cookie. +- Body: none. +- Actively queries the registry for each running image's current digest + (independent of Diun webhooks) and records/clears update events + accordingly. +- Response: + - `200 { "total": n, "checked": n, "updatesFound": n, "errors": n }` + - `503 { "error": "docker_unavailable" }` if the Docker daemon is + unreachable. + +### `GET /api/events` + +- Auth: cookie. +- Response: `text/event-stream` (SSE). Emits + `data: {"type":"containers-changed"}` whenever server state changes (a Diun + webhook arrived, a manual check ran, or an update finished) so dashboards + can refresh without a manual reload. Comment lines (`: ...`) are sent as + keepalives. + ### `POST /api/update/:name` - Auth: cookie. diff --git a/README.md b/README.md index a437b5e..9690450 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,19 @@ The SQLite database is created automatically in the `diun-updater-data` volume on first start. The first time you load the UI you'll get the login screen — enter `ADMIN_PASSWORD`. +> **Prefer a prebuilt image?** Tagged releases publish a multi-arch image +> (`linux/amd64` + `linux/arm64`) to GHCR. Instead of `build:`, point the +> compose service at it and skip the build: +> +> ```yaml +> services: +> diun-updater: +> image: ghcr.io/strandedturtle/diupdater:latest +> # ...keep the same environment + volumes as above... +> ``` +> +> Then `docker compose up -d` (no `--build`). + ### 5. Point Diun at the app (the webhook) Add a `webhook` notifier to your Diun config (this is **in addition to** your @@ -273,7 +286,12 @@ Open `http://:5000` (or your tunnel URL) and log in with `ADMIN_PASSWORD`. badge clears. - **Update all** runs every eligible container one at a time (a failure on one doesn't stop the rest). +- **Check** actively queries the registries for newer digests right now, instead + of waiting for Diun (see [Active update checks](#active-update-checks)). - **Refresh** re-reads live state from Docker. +- The dashboard also **updates itself live** — when a Diun webhook arrives, a + check runs, or an update finishes, the list refreshes automatically (no need to + hit Refresh). - The **pin** icon hides a container's update badge (useful to "ignore this one for now"). Pinned items can still be updated manually; manage/unpin them from Settings. @@ -292,6 +310,20 @@ pages through older entries. Screen". It installs as a standalone, full-screen app with an icon — this is the mobile experience that replaces fiddling with Dockge. +### Active update checks + +The **Check** button (and `POST /api/check`) makes the app query the registries +directly for each running image's current digest and flag anything out of date — +independent of Diun. This is useful for a first run (Diun only sends a webhook +*when a digest changes*, so a fresh install is otherwise quiet), to recover from +a webhook that was missed while the app was down, or if you'd rather not depend +on Diun at all. + +It currently supports registries reachable **anonymously** over the standard +token flow — Docker Hub, GHCR, lscr.io, quay.io, etc. for public images. Private +images that require credentials are skipped (counted under `errors`) and still +rely on Diun's webhook for their signal. + --- ## Configuration reference diff --git a/client/src/Dashboard.jsx b/client/src/Dashboard.jsx index b9ecc34..3bc9a6c 100644 --- a/client/src/Dashboard.jsx +++ b/client/src/Dashboard.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getContainers } from './api.js'; +import { getContainers, checkNow } from './api.js'; import UpdateCard from './components/UpdateCard.jsx'; import UpdateAllButton from './components/UpdateAllButton.jsx'; @@ -8,6 +8,8 @@ export default function Dashboard({ onPendingCountChange }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [refreshing, setRefreshing] = useState(false); + const [checking, setChecking] = useState(false); + const [checkMsg, setCheckMsg] = useState(''); // name -> run() function, populated by each UpdateCard so "Update all" // can drive the same start+SSE flow the per-card button uses. @@ -34,6 +36,56 @@ export default function Dashboard({ onPendingCountChange }) { setRefreshing(false); }, [load]); + // Actively ask the server to re-check registries, then refresh the list. + const handleCheck = useCallback(async () => { + setChecking(true); + setCheckMsg(''); + try { + const r = await checkNow(); + await load(); + const checked = r?.checked ?? 0; + const found = r?.updatesFound ?? 0; + const errs = r?.errors ?? 0; + setCheckMsg( + `Checked ${checked} image${checked === 1 ? '' : 's'} — ${found} update${found === 1 ? '' : 's'} found` + + (errs ? `, ${errs} couldn't be checked` : '') + + '.' + ); + } catch (err) { + setCheckMsg(err.message || 'Check failed'); + } finally { + setChecking(false); + } + }, [load]); + + // Live updates: refresh automatically when the server signals a change + // (a Diun webhook arrived, a check ran, or an update finished). + useEffect(() => { + let es; + let debounce; + try { + es = new EventSource('/api/events'); + es.onmessage = (e) => { + let payload; + try { + payload = JSON.parse(e.data); + } catch { + return; + } + if (payload && payload.type === 'containers-changed') { + clearTimeout(debounce); + debounce = setTimeout(() => load(), 400); + } + }; + } catch { + // EventSource unavailable — manual Refresh/Check still work. + } + return () => { + clearTimeout(debounce); + if (es) es.close(); + }; + }, [load]); + // Called by UpdateCard once its update settles (success/error/stream // error). Re-fetch so digests/updateAvailable/pinned reflect server state. const handleSettled = useCallback(() => { @@ -75,6 +127,10 @@ export default function Dashboard({ onPendingCountChange }) { )}
+
+ {checkMsg &&

{checkMsg}

} {loading && (
diff --git a/client/src/api.js b/client/src/api.js index ccdda46..9772d13 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -76,6 +76,12 @@ export function getContainers() { return get('/containers'); } +// Actively re-check registries for newer digests. Returns +// { total, checked, updatesFound, errors }. +export function checkNow() { + return post('/check'); +} + export function startUpdate(name) { return post(`/update/${encodeURIComponent(name)}`); } diff --git a/client/src/styles/app.css b/client/src/styles/app.css index e3c2e14..c708bd9 100644 --- a/client/src/styles/app.css +++ b/client/src/styles/app.css @@ -928,3 +928,11 @@ a { text-overflow: ellipsis; white-space: nowrap; } + +/* ---------- Check result message ---------- */ + +.check-msg { + margin: 0 0 12px; + font-size: 0.85rem; + color: var(--text-secondary); +} diff --git a/server/Dockerfile b/server/Dockerfile index 28a1a18..4c8271e 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -3,14 +3,28 @@ # (docker-compose.yml at the repo root is already configured this way.) # ---- Stage 1: build the client SPA ---- -FROM node:22-alpine AS client-builder +# Pin to the build platform: the client output is static, arch-independent JS, +# so there's no need to run the Vite build under emulation for each target +# platform during a multi-arch build. +FROM --platform=$BUILDPLATFORM node:22-alpine AS client-builder WORKDIR /app/client COPY client/package*.json ./ RUN npm ci COPY client/ ./ RUN npm run build -# ---- Stage 2: server runtime ---- +# ---- Stage 2: server dependencies ---- +# better-sqlite3 is a native module with no musl (Alpine) prebuilt binary, so +# it must be compiled from source here — which needs a toolchain. Doing it in a +# dedicated stage keeps python3/make/g++ out of the final image. This stage +# runs on the TARGET platform, so the compiled binary matches the runtime arch. +FROM node:22-alpine AS server-deps +RUN apk add --no-cache python3 make g++ +WORKDIR /app +COPY server/package*.json ./ +RUN npm ci --omit=dev + +# ---- Stage 3: server runtime ---- FROM node:22-alpine # docker-cli + the v2 compose plugin so `docker compose ...` works. @@ -20,9 +34,8 @@ RUN apk add --no-cache docker-cli docker-cli-compose WORKDIR /app +COPY --from=server-deps /app/node_modules ./node_modules COPY server/package*.json ./ -RUN npm ci --omit=dev - COPY server/src ./src COPY --from=client-builder /app/client/dist ./client/dist diff --git a/server/src/checker.js b/server/src/checker.js new file mode 100644 index 0000000..34830ba --- /dev/null +++ b/server/src/checker.js @@ -0,0 +1,81 @@ +/** + * Active update check: for each running container, ask the registry for the + * current digest of its tag and reconcile against what's running — recording + * an update event when they differ, or resolving stale events when they match. + * + * This makes the dashboard work even if a Diun webhook was missed or never + * fired (Diun only notifies on change). It complements, and does not replace, + * the webhook path. + */ + +import { listContainers } from './docker.js'; +import { getRemoteDigest } from './registry.js'; +import { digestsEqual } from './reconcile.js'; +import * as db from './db.js'; + +const CONCURRENCY = 4; + +/** + * @returns {Promise<{ total: number, checked: number, updatesFound: number, errors: number }>} + * @throws if the Docker daemon can't be reached (caller maps to 503). + */ +export async function runCheck() { + const containers = await listContainers(); + + // De-dupe by normalized ref so we hit each image once even if several + // containers run it. + const byRef = new Map(); + for (const c of containers) { + if (!byRef.has(c.normalizedRef)) byRef.set(c.normalizedRef, c); + } + const items = [...byRef.values()]; + + let checked = 0; + let updatesFound = 0; + let errors = 0; + + let idx = 0; + async function worker() { + while (idx < items.length) { + const c = items[idx]; + idx += 1; + try { + const remote = await getRemoteDigest(c.image); + checked += 1; + if (!remote) continue; // digest-pinned or registry gave no digest + + if (c.currentDigest && digestsEqual(remote, c.currentDigest)) { + // Up to date — clear any stale unresolved event. + db.resolveEventsForRef(c.normalizedRef); + continue; + } + + // Differs from what's running: flag it, unless we already have an + // unresolved event for this exact digest (avoid duplicate rows on + // repeated checks). + const existing = db.latestUnresolvedEventForRef(c.normalizedRef); + if (existing && digestsEqual(existing.digest, remote)) continue; + + db.recordEvent({ + image: c.image, + normalized_ref: c.normalizedRef, + status: 'update', + digest: remote, + raw_json: JSON.stringify({ source: 'check' }), + }); + updatesFound += 1; + } catch (err) { + errors += 1; + console.warn(`checker: failed to check ${c.image}: ${err.message}`); + } + } + } + + await Promise.all( + Array.from({ length: Math.min(CONCURRENCY, items.length) }, () => worker()) + ); + + return { total: items.length, checked, updatesFound, errors }; +} + +export default { runCheck }; diff --git a/server/src/reconcile.js b/server/src/reconcile.js index aa3a361..837238f 100644 --- a/server/src/reconcile.js +++ b/server/src/reconcile.js @@ -1,5 +1,5 @@ /** - * Pure reconciliation helpers: normalizing Docker image references and + * Pure reconciliation helpers: parsing/normalizing Docker image references and * comparing digests. No DB access, no dockerode — kept dependency-free so * it is trivially unit-testable (see server/test/reconcile.test.js). */ @@ -18,49 +18,44 @@ function looksLikeRegistryHost(segment) { } /** - * Normalize a Docker image reference to a canonical `registry/repo:tag` - * string (or `registry/repo` if the ref was digest-pinned with no tag). + * Parse a Docker image reference into its components. * * Rules: * - Default registry is `docker.io`; Docker Hub official images (no slash, * or no registry-looking first segment) get the `library/` namespace. * - Default tag is `latest` when no tag is present. * - A colon that is part of a registry's port (e.g. `registry:5000/foo`) is - * never mistaken for a tag separator — only a colon appearing after the - * last `/` (i.e. in the final path segment) can introduce a tag. - * - Any `@sha256:...` digest suffix is stripped. If the ref had a digest - * and no tag, the result has no tag at all (`registry/repo`, no trailing - * `:tag`). This means digest-pinned refs won't match tag-keyed Diun - * events — that's an accepted limitation, not a bug. - * - The registry + repo path is lowercased (Docker image names are - * lowercase by spec); the tag is left as-is since tags are - * case-sensitive. + * never mistaken for a tag separator — only a colon in the final path + * segment can introduce a tag. + * - An `@sha256:...` digest suffix is captured separately. If the ref had a + * digest, `tag` is null (digest-pinned refs have no meaningful tag to + * check against tag-keyed events). + * - The registry + repository are lowercased; the tag is left as-is. * * @param {string} imageRef - * @returns {string} + * @returns {{ registry: string, repository: string, tag: string|null, digest: string|null }} */ -export function normalizeRef(imageRef) { +export function parseRef(imageRef) { if (typeof imageRef !== 'string') { - throw new TypeError('normalizeRef: imageRef must be a string'); + throw new TypeError('parseRef: imageRef must be a string'); } let ref = imageRef.trim(); if (ref === '') { - throw new TypeError('normalizeRef: imageRef must not be empty'); + throw new TypeError('parseRef: imageRef must not be empty'); } // 1. Strip a trailing @sha256:... digest, if present. - let hasDigest = false; + let digest = null; const digestMatch = ref.match(/@sha256:[0-9a-fA-F]+$/); if (digestMatch) { - hasDigest = true; + digest = digestMatch[0].slice(1); // drop the leading '@' -> "sha256:..." ref = ref.slice(0, ref.length - digestMatch[0].length); } - // 2. Split into registry+repo (the "name") and an optional tag. - // A tag is only present if there's a colon in the LAST path segment - // (the part after the final '/'), so registry:port is not confused - // with name:tag. + // 2. Split into registry+repo ("name") and an optional tag. A tag is only + // present if there's a colon in the LAST path segment, so registry:port + // is not confused with name:tag. const lastSlash = ref.lastIndexOf('/'); const lastSegment = lastSlash === -1 ? ref : ref.slice(lastSlash + 1); const colonInLastSegment = lastSegment.lastIndexOf(':'); @@ -75,9 +70,8 @@ export function normalizeRef(imageRef) { name = ref; } - if (hasDigest) { - // Digest-pinned: no tag, regardless of whether one was parsed above - // (a ref can't legally have both, but be defensive). + if (digest) { + // Digest-pinned: no tag, regardless of whether one was parsed above. tag = null; } else if (tag === null || tag === '') { tag = DEFAULT_TAG; @@ -97,17 +91,31 @@ export function normalizeRef(imageRef) { registry = parts[0]; repoParts = parts.slice(1); } else { - // e.g. "library/nginx" or "lscr.io"-less two-segment Hub images like + // e.g. "library/nginx" or two-segment Hub images like // "linuxserver/sonarr" -> docker.io/linuxserver/sonarr registry = DEFAULT_REGISTRY; repoParts = parts; } - const repo = repoParts.join('/'); - const registryLower = registry.toLowerCase(); - const repoLower = repo.toLowerCase(); + return { + registry: registry.toLowerCase(), + repository: repoParts.join('/').toLowerCase(), + tag, + digest, + }; +} - const base = `${registryLower}/${repoLower}`; +/** + * Normalize a Docker image reference to a canonical `registry/repo:tag` + * string (or `registry/repo` if the ref was digest-pinned with no tag). + * Used as the matching key between Diun events and running containers. + * + * @param {string} imageRef + * @returns {string} + */ +export function normalizeRef(imageRef) { + const { registry, repository, tag } = parseRef(imageRef); + const base = `${registry}/${repository}`; return tag === null ? base : `${base}:${tag}`; } diff --git a/server/src/registry.js b/server/src/registry.js new file mode 100644 index 0000000..42190a4 --- /dev/null +++ b/server/src/registry.js @@ -0,0 +1,106 @@ +/** + * Minimal Docker Registry v2 client: resolve the current manifest digest for + * an image tag WITHOUT pulling the image, so the app can actively check for + * updates (independently of Diun webhooks). + * + * Supports anonymous access to registries that use the standard + * `WWW-Authenticate: Bearer ...` token flow — Docker Hub, GHCR, lscr.io, + * quay.io, etc. for public images. Private registries that need credentials + * are not handled yet (those images are simply skipped by the checker). + */ + +import { parseRef } from './reconcile.js'; + +const DOCKER_HUB_API_HOST = 'registry-1.docker.io'; + +// Accept manifest lists / OCI indexes first so we get the multi-arch index +// digest where applicable, matching what Diun typically reports. +const MANIFEST_ACCEPT = [ + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.oci.image.manifest.v1+json', +].join(', '); + +function apiHost(registry) { + // `docker.io` is the canonical name but the v2 API lives on registry-1. + return registry === 'docker.io' ? DOCKER_HUB_API_HOST : registry; +} + +/** + * Parse a `WWW-Authenticate: Bearer realm="...",service="...",scope="..."` + * header into its parameters. + * + * @param {string|null} header + * @returns {{ realm?: string, service?: string, scope?: string }|null} + */ +export function parseWwwAuthenticate(header) { + if (!header || !/^Bearer\s/i.test(header)) return null; + const params = {}; + const re = /(\w+)="([^"]*)"/g; + let m; + while ((m = re.exec(header)) !== null) { + params[m[1]] = m[2]; + } + return params; +} + +async function fetchToken(wwwAuth, repository, timeoutMs) { + 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' }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) return null; + const body = await res.json().catch(() => null); + return body?.token || body?.access_token || null; +} + +/** + * Resolve the current registry digest for an image ref's tag. + * + * @param {string} imageRef e.g. "nginx:latest", "ghcr.io/foo/bar:v1" + * @param {{ timeoutMs?: number }} [opts] + * @returns {Promise} a `sha256:...` digest, or null if the ref is + * digest-pinned or the registry didn't return a digest. + * @throws if the registry is unreachable or returns a non-OK status. + */ +export async function getRemoteDigest(imageRef, { timeoutMs = 10000 } = {}) { + const { registry, repository, tag } = parseRef(imageRef); + if (!tag) return null; // digest-pinned; nothing to check against a tag + + const host = apiHost(registry); + const manifestUrl = `https://${host}/v2/${repository}/manifests/${encodeURIComponent(tag)}`; + + const headManifest = (token) => + fetch(manifestUrl, { + method: 'HEAD', + headers: { + Accept: MANIFEST_ACCEPT, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: AbortSignal.timeout(timeoutMs), + }); + + let res = await headManifest(null); + + // Standard token handshake: on 401, read the realm/scope and 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); + } + } + + if (!res.ok) { + throw new Error(`registry returned ${res.status} for ${imageRef}`); + } + + return res.headers.get('docker-content-digest') || null; +} + +export default { getRemoteDigest, parseWwwAuthenticate }; diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 9e6f955..273a7c1 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -11,6 +11,8 @@ import express from 'express'; import { listContainers } from '../docker.js'; import { buildContainerItems } from '../containers-service.js'; import { normalizeRef } from '../reconcile.js'; +import { runCheck } from '../checker.js'; +import { subscribeGlobal, broadcastGlobal } from '../sse.js'; import * as db from '../db.js'; export const apiRouter = express.Router(); @@ -52,6 +54,26 @@ apiRouter.get('/api/containers', async (req, res) => { return res.status(200).json(items); }); +// Actively check registries for newer digests (independent of Diun webhooks). +apiRouter.post('/api/check', async (req, res) => { + let result; + try { + result = await runCheck(); + } catch (err) { + console.error(`api.js: POST /api/check failed: ${err.message}`); + return res.status(503).json({ error: 'docker_unavailable', message: err.message }); + } + broadcastGlobal({ type: 'containers-changed' }); + return res.status(200).json(result); +}); + +// Global SSE channel: emits {"type":"containers-changed"} when server state +// changes (webhook event, manual check, finished update) so dashboards can +// refresh without a manual reload. +apiRouter.get('/api/events', (req, res) => { + subscribeGlobal(res, req); +}); + apiRouter.get('/api/history', (req, res) => { const limit = toSafeInt(req.query.limit, 50, 500); const offset = toSafeInt(req.query.offset, 0); diff --git a/server/src/routes/update.js b/server/src/routes/update.js index 94c0812..236b7ec 100644 --- a/server/src/routes/update.js +++ b/server/src/routes/update.js @@ -64,6 +64,9 @@ async function runUpdate(name, image) { message: err.message, }); sse.finish(name, { success: false, message: err.message }); + } finally { + // Let other connected dashboards refresh their list/badges. + sse.broadcastGlobal({ type: 'containers-changed' }); } } diff --git a/server/src/sse.js b/server/src/sse.js index b439186..0d6ffb8 100644 --- a/server/src/sse.js +++ b/server/src/sse.js @@ -188,4 +188,60 @@ function writeToSubscribers(session, evt) { } } -export default { startSession, isActive, pushLog, finish, subscribe }; +// --- Global event channel ------------------------------------------------- +// A lightweight broadcast channel, separate from per-update sessions, used to +// nudge connected dashboards to refresh when something changes server-side: a +// new Diun webhook event, a manual "check now", or a finished update. +const globalClients = new Set(); + +export function subscribeGlobal(res, req) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + res.write(': connected\n\n'); + + globalClients.add(res); + const keepAlive = setInterval(() => { + try { + res.write(': keepalive\n\n'); + } catch { + // gone; close handler clears the interval + } + }, 15_000); + + const cleanup = () => { + clearInterval(keepAlive); + globalClients.delete(res); + }; + res.on('close', cleanup); + if (req) { + req.on('close', cleanup); + } +} + +export function broadcastGlobal(evt) { + const payload = `data: ${JSON.stringify(evt)}\n\n`; + for (const res of globalClients) { + try { + res.write(payload); + } catch { + // subscriber gone; its close handler will clean it up + } + } +} + +export default { + startSession, + isActive, + pushLog, + finish, + subscribe, + subscribeGlobal, + broadcastGlobal, +}; diff --git a/server/src/webhook.js b/server/src/webhook.js index 5cce492..7542206 100644 --- a/server/src/webhook.js +++ b/server/src/webhook.js @@ -11,6 +11,7 @@ import express from 'express'; import { config } from './config.js'; import { normalizeRef } from './reconcile.js'; import { recordEvent } from './db.js'; +import { broadcastGlobal } from './sse.js'; export const webhookRouter = express.Router(); @@ -71,6 +72,10 @@ webhookRouter.post('/api/diun/webhook', (req, res) => { raw_json: JSON.stringify(req.body), }); + // Nudge any connected dashboards to refresh so the badge appears without a + // manual reload. + broadcastGlobal({ type: 'containers-changed' }); + return res.status(204).end(); }); diff --git a/server/test/parseref.test.js b/server/test/parseref.test.js new file mode 100644 index 0000000..d683a4a --- /dev/null +++ b/server/test/parseref.test.js @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseRef } from '../src/reconcile.js'; + +test('parseRef: official short name gets library namespace and latest tag', () => { + assert.deepEqual(parseRef('nginx'), { + registry: 'docker.io', + repository: 'library/nginx', + tag: 'latest', + digest: null, + }); +}); + +test('parseRef: explicit tag preserved', () => { + assert.deepEqual(parseRef('nginx:1.25'), { + registry: 'docker.io', + repository: 'library/nginx', + tag: '1.25', + digest: null, + }); +}); + +test('parseRef: third-party registry, repo lowercased, tag case preserved', () => { + assert.deepEqual(parseRef('ghcr.io/Foo/Bar:Tag'), { + registry: 'ghcr.io', + repository: 'foo/bar', + tag: 'Tag', + digest: null, + }); +}); + +test('parseRef: registry with port is not mistaken for a tag', () => { + assert.deepEqual(parseRef('registry:5000/team/app:v1'), { + registry: 'registry:5000', + repository: 'team/app', + tag: 'v1', + digest: null, + }); +}); + +test('parseRef: two-segment Docker Hub image keeps its namespace', () => { + assert.deepEqual(parseRef('lscr.io/linuxserver/sonarr'), { + registry: 'lscr.io', + repository: 'linuxserver/sonarr', + tag: 'latest', + digest: null, + }); +}); + +test('parseRef: digest-pinned ref has no tag and captures the digest', () => { + assert.deepEqual(parseRef('nginx@sha256:abc123'), { + registry: 'docker.io', + repository: 'library/nginx', + tag: null, + digest: 'sha256:abc123', + }); +}); diff --git a/server/test/registry.test.js b/server/test/registry.test.js new file mode 100644 index 0000000..bbb48d6 --- /dev/null +++ b/server/test/registry.test.js @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseWwwAuthenticate } from '../src/registry.js'; + +test('parseWwwAuthenticate: parses realm/service/scope from a Bearer challenge', () => { + const header = + 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"'; + assert.deepEqual(parseWwwAuthenticate(header), { + realm: 'https://auth.docker.io/token', + service: 'registry.docker.io', + scope: 'repository:library/nginx:pull', + }); +}); + +test('parseWwwAuthenticate: returns null for non-Bearer or empty headers', () => { + assert.equal(parseWwwAuthenticate(null), null); + assert.equal(parseWwwAuthenticate(''), null); + assert.equal(parseWwwAuthenticate('Basic realm="x"'), null); +});