diff --git a/API_CONTRACT.md b/API_CONTRACT.md
index 16613ae..6dc859e 100644
--- a/API_CONTRACT.md
+++ b/API_CONTRACT.md
@@ -16,6 +16,14 @@ All request/response bodies are JSON unless noted otherwise.
require a valid `dockpull_session` cookie. If it is missing, invalid, or
expired, the server responds `401 Unauthorized` with
`{ "error": "unauthorized" }`.
+- The cookie is bound to the current `ADMIN_PASSWORD`: changing the password
+ signs out every existing session.
+- **CSRF:** every state-changing `/api/*` request (anything but
+ GET/HEAD/OPTIONS — login included) must send the header `X-DockPull: 1`, or
+ it's rejected with `403 { "error": "csrf_header_missing" }`.
+- Routes with a `:name` param reject anything that isn't a valid container
+ name with `400 { "error": "invalid_container_name" }`.
+- Error bodies may include a human-readable `message` alongside `error`.
## Endpoints
@@ -103,7 +111,13 @@ All request/response bodies are JSON unless noted otherwise.
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.
+ if no such container; `409` if an update/revert is already in progress;
+ `410 rollback_image_gone` if the saved image no longer exists (e.g. it was
+ pruned) — the rollback point is dropped and the container is not touched.
+- The recreated container runs a bare image ID, so DockPull labels it with
+ `io.dockpull.image-ref` / `io.dockpull.image-digest` to keep tracking its
+ image (checks, updates and pins keep working, and the newer version is
+ offered again).
### `GET /api/update/:name/stream`
@@ -159,12 +173,16 @@ All request/response bodies are JSON unless noted otherwise.
- Auth: cookie.
- Dry-run preview of what `POST /api/images/prune` would remove — lists
- dangling images (untagged layers no container references) without
- deleting anything, for a confirmation dialog to summarize before the user
- commits to pruning.
+ dangling images (untagged images no container — running or stopped — uses)
+ without deleting anything, for a confirmation dialog to summarize before
+ the user commits to pruning.
- Response: `200` —
- `{ "count": number, "totalSize": number, "images": [{ "id": string, "size": number, "created": number|null, "fromContainer": string|null }] }`
- where `totalSize` is in bytes, `id` is a short (12-char) image ID, and
+ `{ "count": number, "totalSize": number, "exact": boolean, "images": [{ "id": string, "size": number, "fullSize": number, "created": number|null, "fromContainer": string|null }] }`
+ where `size` is what removing that image should free — its whole size
+ (`fullSize`) minus the layers it shares with other images, which stay —
+ and `totalSize` is their sum, in bytes (`exact: false` when the daemon
+ didn't report shared sizes, so `size` falls back to the whole image). `id`
+ is a short (12-char) image ID, and
`fromContainer` is the name of the container this image was replaced on
(via its remembered rollback point), or `null` when that's unknown —
images left over from before the container's most recent update, or
@@ -184,9 +202,13 @@ All request/response bodies are JSON unless noted otherwise.
layers); each ID is re-checked against the current dangling set before
removal, so a stale or non-dangling ID is silently skipped. With no body
(or no `ids`), every dangling layer is pruned.
-- Response: `200` — `{ "ok": true, "deleted": number, "spaceReclaimed": number }`
- where `deleted` is the number of image layers removed and `spaceReclaimed`
- is in bytes.
+- Response: `200` —
+ `{ "ok": true, "deleted": number, "spaceReclaimed": number, "revertsRemoved": [string] }`
+ where `deleted` is the number of images removed, `spaceReclaimed` is the
+ bytes actually freed — measured as image-layer disk usage before minus
+ after (falls back to the per-image estimate if the daemon can't report it)
+ — and `revertsRemoved` names containers whose revert point was among the
+ removed images (their rollback points are dropped).
- `503 { "error": "docker_unavailable" }` when the Docker daemon is
unreachable.
diff --git a/SECURITY.md b/SECURITY.md
index 4072b9a..ec864ac 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -17,7 +17,14 @@ DockPull is built for a **trusted LAN / homelab** behind authentication. It is
- **Login rate-limiting / lockout** per client IP (10 failures → 15-minute
lockout) to blunt brute-force.
- **All `/api/*` routes require the session cookie** (only `GET /api/health`,
- login, and `me` are public).
+ login, and `me` are public). The cookie is tied to the current
+ `ADMIN_PASSWORD`, so **changing the password signs out every session**.
+- **CSRF protection.** State-changing API requests must carry an `X-DockPull`
+ header, which another site (or another app on a different port of the same
+ host — still "same-site" to the browser) can't add to a forged request.
+- **Container names are validated** before they reach the Docker API.
+- **Registry credentials** from your Docker config are only ever sent to
+ `https` token servers.
- **No shell interpolation.** Docker actions run via `spawn(..., {shell:false})`
with argument arrays — never a shell string — so container/label values can't
inject commands.
diff --git a/client/src/api.js b/client/src/api.js
index 72c754b..f3b6a55 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -42,7 +42,13 @@ async function request(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
credentials: 'include',
- headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
+ headers: {
+ // Required by the server on every state-changing request: a cross-site
+ // page can't add custom headers without a CORS preflight (which the
+ // server never grants), so this blocks CSRF from other sites/ports.
+ 'X-DockPull': '1',
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
+ },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
@@ -54,7 +60,7 @@ async function request(method, path, body) {
if (onUnauthorized) onUnauthorized();
}
const errMessage =
- (data && typeof data === 'object' && data.error) ||
+ (data && typeof data === 'object' && (data.message || data.error)) ||
(typeof data === 'string' && data) ||
`${method} ${path} failed with ${res.status}`;
throw new ApiError(errMessage, res.status, data);
diff --git a/client/src/pages/SettingsPage.jsx b/client/src/pages/SettingsPage.jsx
index 1851fba..73bd237 100644
--- a/client/src/pages/SettingsPage.jsx
+++ b/client/src/pages/SettingsPage.jsx
@@ -16,7 +16,7 @@ import { useTheme } from '../hooks/useTheme.js';
// Human-readable byte count: whole bytes below 1 KB, one decimal above.
function formatBytes(n) {
if (!Number.isFinite(n) || n < 1024) return `${n} B`;
- const units = ['KB', 'MB', 'GB'];
+ const units = ['KB', 'MB', 'GB', 'TB'];
let value = n;
let i = -1;
do {
@@ -195,11 +195,14 @@ export default function SettingsPage({ onPruneComplete } = {}) {
setPruning(true);
setPruneStatus('');
try {
- const { deleted = 0, spaceReclaimed = 0 } = (await pruneImages(ids)) || {};
+ const { deleted = 0, spaceReclaimed = 0, revertsRemoved = [] } = (await pruneImages(ids)) || {};
+ const revertNote = revertsRemoved.length
+ ? ` Revert is no longer available for ${revertsRemoved.join(', ')}.`
+ : '';
setPruneStatus(
- deleted > 0
+ (deleted > 0
? `Freed ${formatBytes(spaceReclaimed)} (${deleted} layer${deleted === 1 ? '' : 's'} removed).`
- : 'Nothing to prune — no dangling layers found.'
+ : 'Nothing to prune — no dangling layers found.') + revertNote
);
if (deleted > 0) {
onPruneComplete?.();
@@ -511,7 +514,7 @@ export default function SettingsPage({ onPruneComplete } = {}) {
dialogClassName="confirm-dialog--wide"
confirmLabel={
pruneSelection.length
- ? `Prune ${pruneSelection.length} (${formatBytes(
+ ? `Prune ${pruneSelection.length} (~${formatBytes(
pruneSelection.reduce((sum, img) => sum + (img.size || 0), 0)
)})`
: 'Prune'
@@ -527,7 +530,15 @@ export default function SettingsPage({ onPruneComplete } = {}) {
Leftover layers from image updates. Remove any row with ✕ to keep that layer —
it'll reappear here next time. Tagged images and anything in use are never touched.
+ Sizes are what removing each one should free (layers shared with images you still
+ use aren't counted).
+ {pruneSelection.some((img) => img.fromContainer) && (
+
+ ⚠ Rows named after a container are its previous version — pruning one removes the
+ option to revert that container's last update.
+
+ )}
{pruneSelection.length === 0 ? (
All layers excluded — nothing will be pruned.
) : (
@@ -550,7 +561,16 @@ export default function SettingsPage({ onPruneComplete } = {}) {
{img.id}
- {formatBytes(img.size || 0)}
+
+ {formatBytes(img.size || 0)}
+
{formatAge(img.created)}
.`. */
+export function sessionCookieValue(expiry) {
+ return `${expiry}.${passwordFingerprint()}`;
+}
+
/**
* Reads `req.signedCookies.dockpull_session` and checks whether it represents a
* non-expired session. cookie-parser has already verified the HMAC
@@ -97,10 +116,14 @@ function isValidPassword(provided) {
*/
export function isValidSession(req) {
const value = req.signedCookies?.[SESSION_COOKIE];
- if (!value) return false;
- const expiry = Number(value);
- if (!Number.isFinite(expiry)) return false;
- return expiry > Date.now();
+ if (typeof value !== 'string' || !value) return false;
+ const dot = value.indexOf('.');
+ if (dot === -1) return false; // pre-fingerprint cookie: log in again
+ const expiry = Number(value.slice(0, dot));
+ if (!Number.isFinite(expiry) || expiry <= Date.now()) return false;
+ const given = Buffer.from(value.slice(dot + 1));
+ const expected = Buffer.from(passwordFingerprint());
+ return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
/**
@@ -122,8 +145,8 @@ export function loginHandler(req, res) {
}
clearLoginFailures(ip);
- const expiry = String(Date.now() + config.SESSION_TTL * 1000);
- res.cookie(SESSION_COOKIE, expiry, {
+ const expiry = Date.now() + config.SESSION_TTL * 1000;
+ res.cookie(SESSION_COOKIE, sessionCookieValue(expiry), {
signed: true,
httpOnly: true,
sameSite: 'lax',
diff --git a/server/src/db.js b/server/src/db.js
index fd07676..155623f 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -83,6 +83,14 @@ CREATE INDEX IF NOT EXISTS idx_history_created ON update_history(created_at DESC
}
}
+// update_history: remember versions on the row itself, for when an image's
+// digest is unknown (so the digest→version lookup can't recover them later).
+{
+ const cols = db.prepare('PRAGMA table_info(update_history)').all().map((col) => col.name);
+ if (!cols.includes('old_version')) db.exec('ALTER TABLE update_history ADD COLUMN old_version TEXT');
+ if (!cols.includes('new_version')) db.exec('ALTER TABLE update_history ADD COLUMN new_version TEXT');
+}
+
const stmts = {
recordEvent: db.prepare(`
INSERT INTO update_events (image, normalized_ref, status, digest, available_version, breaking, raw_json)
@@ -144,8 +152,8 @@ const stmts = {
DELETE FROM rollback_points WHERE container_name = ?
`),
recordUpdate: db.prepare(`
- INSERT INTO update_history (container_name, image, old_digest, new_digest, status, message)
- VALUES (@container_name, @image, @old_digest, @new_digest, @status, @message)
+ INSERT INTO update_history (container_name, image, old_digest, new_digest, old_version, new_version, status, message)
+ VALUES (@container_name, @image, @old_digest, @new_digest, @old_version, @new_version, @status, @message)
`),
getHistoryAll: db.prepare(`
SELECT * FROM update_history
@@ -273,6 +281,22 @@ export function deleteRollbackPoint(container_name) {
return stmts.deleteRollbackPoint.run(container_name);
}
+/**
+ * Forget rollback points whose saved image is gone (e.g. it was pruned), so the
+ * dashboard stops offering a Revert that can't work. `shortIds` are 12-char
+ * image IDs. Returns the affected container names.
+ */
+export function deleteRollbackPointsForImages(shortIds) {
+ const gone = new Set(shortIds || []);
+ if (gone.size === 0) return [];
+ const affected = stmts.getAllRollbackPoints
+ .all()
+ .filter((r) => gone.has(String(r.image_id || '').replace(/^sha256:/, '').slice(0, 12)))
+ .map((r) => r.container_name);
+ for (const name of affected) stmts.deleteRollbackPoint.run(name);
+ return affected;
+}
+
/**
* Every container's remembered previous image ID (container_name, image_id
* pairs only) — used to attribute a dangling image back to the container it
@@ -283,12 +307,14 @@ export function getAllRollbackPoints() {
return stmts.getAllRollbackPoints.all();
}
-export function recordUpdate({ container_name, image, old_digest, new_digest, status, message }) {
+export function recordUpdate({ container_name, image, old_digest, new_digest, old_version, new_version, status, message }) {
return stmts.recordUpdate.run({
container_name,
image,
old_digest: old_digest ?? null,
new_digest: new_digest ?? null,
+ old_version: old_version ?? null,
+ new_version: new_version ?? null,
status,
message: message ?? null,
});
diff --git a/server/src/docker.js b/server/src/docker.js
index 56b415c..081ed11 100644
--- a/server/src/docker.js
+++ b/server/src/docker.js
@@ -37,6 +37,9 @@ const COMPOSE_PROJECT_LABEL = 'com.docker.compose.project';
const COMPOSE_SERVICE_LABEL = 'com.docker.compose.service';
const COMPOSE_CONFIG_FILES_LABEL = 'com.docker.compose.project.config_files';
const COMPOSE_WORKING_DIR_LABEL = 'com.docker.compose.project.working_dir';
+// Compose records the image ID a container was created from here, and skips
+// recreating a container whose label already matches the service's image.
+const COMPOSE_IMAGE_LABEL = 'com.docker.compose.image';
const COMPOSE_FILE_CANDIDATES = [
'compose.yaml',
@@ -45,6 +48,30 @@ const COMPOSE_FILE_CANDIDATES = [
'docker-compose.yml',
];
+// Set on a container DockPull recreated from a previous image (revert). Docker
+// then records the bare image ID as its image, so these remember which ref it
+// tracks and which registry digest it runs — otherwise the container drops out
+// of update checks ("up to date" forever) and can't be updated or pinned.
+export const REF_LABEL = 'io.dockpull.image-ref';
+export const DIGEST_LABEL = 'io.dockpull.image-digest';
+
+const IMAGE_ID_RE = /^(sha256:)?[0-9a-f]{12,64}$/i;
+
+/**
+ * Pure: the image ref a container tracks — its configured `Config.Image`,
+ * unless that's a bare image ID left by a revert, in which case the ref
+ * remembered in REF_LABEL.
+ *
+ * @param {object} inspectData - container inspect output.
+ * @returns {string|null}
+ */
+export function trackedImageRef(inspectData) {
+ const configured = inspectData?.Config?.Image || null;
+ const remembered = inspectData?.Config?.Labels?.[REF_LABEL];
+ if (remembered && (!configured || IMAGE_ID_RE.test(configured))) return remembered;
+ return configured;
+}
+
/**
* Strips the leading slash Docker prefixes onto container names.
* @param {string} rawName
@@ -275,6 +302,25 @@ export async function getComposeInfo(nameOrContainer) {
return fromLabels;
}
+/**
+ * True if `ref` exists locally and points at an image other than `imageId`.
+ * Cached per listing (many containers can share a tag).
+ */
+async function tagPointsElsewhere(ref, imageId, cache) {
+ if (!cache.has(ref)) {
+ cache.set(
+ ref,
+ docker
+ .getImage(ref)
+ .inspect()
+ .then((i) => i.Id)
+ .catch(() => null)
+ );
+ }
+ const tagId = await cache.get(ref);
+ return Boolean(tagId) && tagId !== imageId;
+}
+
/**
* Lists all containers (including stopped ones) with the fields needed by
* the `/api/containers` endpoint's docker-derived data. Skips (with a
@@ -290,6 +336,7 @@ export async function getComposeInfo(nameOrContainer) {
export async function listContainers() {
const summaries = await docker.listContainers({ all: true });
const results = [];
+ const tagIdCache = new Map(); // image ref -> local image ID it points at
for (const summary of summaries) {
try {
@@ -304,18 +351,33 @@ export async function listContainers() {
continue;
}
- const image = inspectData.Config?.Image;
+ const image = trackedImageRef(inspectData);
if (!image) {
console.warn(`docker.js: container ${name} has no Config.Image, skipping`);
continue;
}
- const {
- digest: currentDigest,
- digests: currentDigests,
- version: currentVersion,
- source: sourceUrl,
- } = await inspectImageMeta(inspectData.Image, image);
+ const meta = await inspectImageMeta(inspectData.Image, image);
+ const { version: currentVersion, source: sourceUrl } = meta;
+ let currentDigests = meta.digests;
+ // A reverted container runs an untagged image with no RepoDigests; use
+ // the digest recorded at revert time, so it's still checked (and the
+ // newer image is offered again).
+ const revertDigest = inspectData.Config?.Labels?.[DIGEST_LABEL];
+ if (currentDigests.length === 0 && revertDigest) currentDigests = [revertDigest];
+ // Still no digest: the image lost its tag (and, with the containerd image
+ // store, its RepoDigests) — e.g. a sibling container on the same tag was
+ // updated, or this one was reverted. If the tag now points at a DIFFERENT
+ // local image, this container is behind; let its image ID stand in as
+ // the digest so the check flags the newer image instead of silently
+ // treating it as up to date.
+ if (currentDigests.length === 0 && /^sha256:/.test(inspectData.Image || '')) {
+ const reverted = Boolean(inspectData.Config?.Labels?.[REF_LABEL]);
+ if (reverted || (await tagPointsElsewhere(image, inspectData.Image, tagIdCache))) {
+ currentDigests = [inspectData.Image];
+ }
+ }
+ const currentDigest = currentDigests[0] ?? null;
const labels = inspectData.Config?.Labels;
const labelInfo = composeInfoFromLabels(labels);
@@ -448,9 +510,9 @@ async function currentDigestForContainerName(name) {
console.warn(`docker.js: failed to inspect ${name} for digest resolution: ${err.message}`);
return null;
}
- const image = inspectData.Config?.Image;
+ const image = trackedImageRef(inspectData);
if (!image) return null;
- return resolveCurrentDigest(inspectData.Image, image);
+ return (await resolveCurrentDigest(inspectData.Image, image)) ?? inspectData.Config?.Labels?.[DIGEST_LABEL] ?? null;
}
/**
@@ -491,6 +553,7 @@ export function buildRecreateOptions(inspectData, baseImageConfig, targetImage)
const baseLabels = base.Labels || {};
const labels = {};
for (const [k, v] of Object.entries(cfg.Labels || {})) {
+ if (k === REF_LABEL || k === DIGEST_LABEL) continue; // describe the OLD image only
if (baseLabels[k] !== v) labels[k] = v;
}
@@ -628,6 +691,15 @@ async function replaceContainer(name, inspectData, createOpts, log, opts = {}) {
}
}
+/** The local image ID a container currently runs, or null. */
+async function imageIdForContainerName(name) {
+ try {
+ return (await docker.getContainer(name).inspect()).Image || null;
+ } catch {
+ return null;
+ }
+}
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
@@ -718,8 +790,14 @@ export async function updateContainer(name, onLine) {
};
}
- const image = inspectData.Config?.Image;
- const oldDigest = image ? await resolveCurrentDigest(inspectData.Image, image) : null;
+ const image = trackedImageRef(inspectData);
+ // The old image's registry digest can be unknown — e.g. another container
+ // sharing the image updated first, leaving this one's image untagged with no
+ // RepoDigests. Its version label is still readable, so keep that for the
+ // revert button / history.
+ const oldMeta = image ? await inspectImageMeta(inspectData.Image, image) : null;
+ const oldDigest = oldMeta?.digest ?? inspectData.Config?.Labels?.[DIGEST_LABEL] ?? null;
+ const oldVersion = oldMeta?.version ?? null;
// Local image ID of what's running now, so a later revert can recreate the
// container from this exact (working) image without pulling.
const oldImageId = inspectData.Image || null;
@@ -796,10 +874,45 @@ export async function updateContainer(name, onLine) {
};
}
+ // Safety net: Compose decides whether to recreate from its own labels, and
+ // can skip a container that isn't actually on the service's current image
+ // (e.g. one recreated outside Compose). If the container still isn't on
+ // the image the tag now points to, force the recreate rather than report
+ // a success that changed nothing.
+ try {
+ const [runningId, latestId] = await Promise.all([
+ imageIdForContainerName(name),
+ docker.getImage(image).inspect().then((i) => i.Id),
+ ]);
+ if (runningId && latestId && runningId !== latestId) {
+ onLine?.('Container is not on the pulled image yet — forcing a recreate…', 'stdout');
+ const forced = await spawnAndStream('docker', [...baseArgs, 'up', '-d', '--force-recreate', service], onLine);
+ if (forced.code !== 0) {
+ return {
+ success: false,
+ message: `docker compose up -d --force-recreate failed (exit ${forced.code}):\n${forced.tail}`,
+ oldDigest,
+ newDigest: null,
+ };
+ }
+ }
+ } catch (err) {
+ console.warn(`docker.js: couldn't verify ${name} is on the pulled image: ${err.message}`);
+ }
+
const newDigest = await currentDigestForContainerName(name);
+ const newImageId = await imageIdForContainerName(name);
return withHealthCheck(
name,
- { success: true, message: 'Updated successfully via docker compose.', oldDigest, newDigest, oldImageId },
+ {
+ success: true,
+ message: 'Updated successfully via docker compose.',
+ oldDigest,
+ newDigest,
+ oldImageId,
+ newImageId,
+ oldVersion,
+ },
true
);
}
@@ -854,6 +967,7 @@ export async function updateContainer(name, onLine) {
}
const newDigest = await currentDigestForContainerName(name);
+ const newImageId = await imageIdForContainerName(name);
return withHealthCheck(
name,
{
@@ -862,6 +976,8 @@ export async function updateContainer(name, onLine) {
oldDigest,
newDigest,
oldImageId,
+ newImageId,
+ oldVersion,
},
wasRunning
);
@@ -880,7 +996,7 @@ export async function updateContainer(name, onLine) {
* @param {(line: string, stream: 'stdout'|'stderr') => void} [onLine]
* @returns {Promise<{ success: boolean, message: string, oldDigest: string|null, newDigest: string|null }>}
*/
-export async function revertContainer(name, imageId, onLine) {
+export async function revertContainer(name, imageId, onLine, { imageRef = null, digest = null } = {}) {
const log = (line) => onLine && onLine(line, 'stdout');
let inspectData;
try {
@@ -895,6 +1011,17 @@ export async function revertContainer(name, imageId, onLine) {
log(`Reverting "${name}" to the previous image…`);
const baseImageConfig = await imageConfigOf(inspectData.Image);
const createOpts = buildRecreateOptions(inspectData, baseImageConfig, imageId);
+ // Docker will record the bare image ID as this container's image; remember
+ // what it tracks so checks/updates/pins keep working (see REF_LABEL).
+ const ref = imageRef || trackedImageRef(inspectData);
+ if (ref) createOpts.Labels = { ...createOpts.Labels, [REF_LABEL]: ref };
+ // Keep Compose's record truthful: the copied label names the NEWER image,
+ // which would make a later `compose up` think this container is already
+ // current and never move it forward again.
+ if (createOpts.Labels?.[COMPOSE_IMAGE_LABEL]) {
+ createOpts.Labels = { ...createOpts.Labels, [COMPOSE_IMAGE_LABEL]: imageId };
+ }
+ if (digest) createOpts.Labels = { ...createOpts.Labels, [DIGEST_LABEL]: digest };
// Revert always starts the container, even if it was stopped.
await replaceContainer(name, inspectData, createOpts, log, { start: true });
log('Container recreated from the previous image.');
@@ -919,7 +1046,7 @@ export async function revertContainer(name, imageId, onLine) {
*/
export async function getContainerImageMeta(name) {
const inspectData = await docker.getContainer(name).inspect();
- const image = inspectData.Config?.Image || null;
+ const image = trackedImageRef(inspectData);
if (!image) return { image: null, currentVersion: null, sourceUrl: null };
const { version, source } = await inspectImageMeta(inspectData.Image, image);
return { image, currentVersion: version, sourceUrl: source };
@@ -937,41 +1064,118 @@ export function shortImageId(id) {
return (id || '').replace(/^sha256:/, '').slice(0, 12);
}
+/**
+ * Pure: how much disk deleting an image would actually free, best-effort.
+ *
+ * Docker's `Size` for an image is its WHOLE size — every layer, including base
+ * layers it shares with other images (the current version of the same app,
+ * other apps on the same base). Deleting an old image only frees the layers
+ * nothing else uses, so summing `Size` wildly over-reports (several GB "freed"
+ * when the real figure is a few hundred MB). `SharedSize` (requested with
+ * `shared-size=1`) is the part shared with other images; Size − SharedSize is
+ * what's unique to this one. When the daemon doesn't report SharedSize (-1 /
+ * missing) we can't tell, so fall back to Size and flag it as an upper bound.
+ *
+ * @param {{ Size?: number, SharedSize?: number }} img - /images/json entry.
+ * @returns {{ size: number, exact: boolean }}
+ */
+export function reclaimableSize(img) {
+ const size = Number.isFinite(img?.Size) ? img.Size : 0;
+ const shared = img?.SharedSize;
+ if (Number.isFinite(shared) && shared >= 0) {
+ return { size: Math.max(0, size - shared), exact: true };
+ }
+ return { size, exact: false };
+}
+
+/**
+ * Dangling images no container uses, with a correct `SharedSize`. Docker computes SharedSize only
+ * among the images in the SAME response, so asking for `dangling=true` with
+ * `shared-size` measures sharing between leftovers only — a lone leftover
+ * then looks 100% unique, even though most of it is the base layer the
+ * current image still uses. So take the dangling set from the filtered list,
+ * and their SharedSize from the unfiltered one.
+ */
+async function listDanglingRaw() {
+ const [dangling, all, containers] = await Promise.all([
+ docker.listImages({ filters: { dangling: ['true'] } }),
+ docker.listImages({ all: true, 'shared-size': true }),
+ docker.listContainers({ all: true }),
+ ]);
+ // `dangling=true` means "untagged", not "unused": an untagged image a
+ // container still runs (e.g. after a revert, or a sibling container updated
+ // first) is listed too. Docker refuses to delete those, so leave them out of
+ // the preview instead of promising space that won't be freed.
+ const inUse = new Set(containers.map((c) => c.ImageID).filter(Boolean));
+ const sharedById = new Map(all.map((img) => [img.Id, img.SharedSize]));
+ return dangling
+ .filter((img) => !inUse.has(img.Id))
+ .map((img) => ({ ...img, SharedSize: sharedById.get(img.Id) ?? img.SharedSize }));
+}
+
+/**
+ * Total bytes of image layers on disk (`docker system df`'s image layers
+ * figure), or null if unavailable. Asks for image data only: a full df also
+ * walks every volume to size it, which can take minutes on a big host.
+ * (dockerode's df() drops its options, hence the direct dial.)
+ */
+async function imageLayersSize() {
+ try {
+ const df = await new Promise((resolve, reject) => {
+ docker.modem.dial(
+ { path: '/system/df?', method: 'GET', options: { type: ['image'] }, statusCodes: { 200: true } },
+ (err, data) => (err ? reject(err) : resolve(data))
+ );
+ });
+ return Number.isFinite(df?.LayersSize) ? df.LayersSize : null;
+ } catch {
+ return null;
+ }
+}
+
/**
* List dangling images (untagged layers no container references) without
* deleting anything — a dry-run preview for the prune confirmation dialog,
* so the user knows what they're about to remove before they remove it.
+ * `size` per image and `totalSize` are what removing them should actually
+ * free (see reclaimableSize); `fullSize` is Docker's whole-image figure.
*
- * @returns {Promise<{ count: number, totalSize: number, images: Array<{ id: string, size: number, created: number }> }>}
+ * @returns {Promise<{ count: number, totalSize: number, exact: boolean, images: Array<{ id: string, size: number, fullSize: number, created: number }> }>}
*/
export async function listDanglingImages() {
- const images = await docker.listImages({ filters: { dangling: ['true'] } });
- const list = images.map((img) => ({
- id: shortImageId(img.Id),
- size: img.Size ?? 0,
- created: img.Created ?? null,
- }));
+ const images = await listDanglingRaw();
+ let exact = true;
+ const list = images.map((img) => {
+ const r = reclaimableSize(img);
+ if (!r.exact) exact = false;
+ return {
+ id: shortImageId(img.Id),
+ size: r.size,
+ fullSize: img.Size ?? 0,
+ created: img.Created ?? null,
+ };
+ });
return {
count: list.length,
totalSize: list.reduce((sum, img) => sum + img.size, 0),
+ exact,
images: list,
};
}
/**
- * Remove dangling images (untagged layers no container references) —
- * the leftovers that accumulate after image updates. Safe: never touches
- * tagged images or anything in use.
+ * Remove every dangling image (untagged layers no container references) — the
+ * leftovers that accumulate after image updates. Safe: never touches tagged
+ * images or anything in use. Goes through removeDanglingImages so the count is
+ * images (Docker's prune response also lists untag/layer entries) and the
+ * space figure is measured (Docker's own SpaceReclaimed under-reports with the
+ * containerd image store).
*
- * @returns {Promise<{ deleted: number, spaceReclaimed: number }>}
+ * @returns {Promise<{ deleted: number, spaceReclaimed: number, removedIds: string[] }>}
*/
export async function pruneDanglingImages() {
- // dockerode serializes the filters object into the API's JSON filter param.
- const result = await docker.pruneImages({ filters: { dangling: ['true'] } });
- return {
- deleted: result.ImagesDeleted?.length ?? 0,
- spaceReclaimed: result.SpaceReclaimed ?? 0,
- };
+ const { images } = await listDanglingImages();
+ return removeDanglingImages(images.map((img) => img.id));
}
/**
@@ -983,27 +1187,49 @@ export async function pruneDanglingImages() {
* Removals are best-effort per image: one failure is logged and doesn't abort
* the rest.
*
+ * `spaceReclaimed` is MEASURED: image-layer disk usage before minus after. If
+ * the daemon can't report that, it falls back to the per-image reclaimable
+ * estimate (never the inflated whole-image sizes).
+ *
* @param {string[]} ids - short (12-char) image IDs to remove.
- * @returns {Promise<{ deleted: number, spaceReclaimed: number }>}
+ * @returns {Promise<{ deleted: number, spaceReclaimed: number, removedIds: string[] }>}
*/
export async function removeDanglingImages(ids) {
const wanted = new Set((ids || []).map(shortImageId));
- if (wanted.size === 0) return { deleted: 0, spaceReclaimed: 0 };
+ if (wanted.size === 0) return { deleted: 0, spaceReclaimed: 0, removedIds: [] };
- const images = await docker.listImages({ filters: { dangling: ['true'] } });
- let deleted = 0;
- let spaceReclaimed = 0;
+ const images = await listDanglingRaw();
+ const before = await imageLayersSize();
+ const removedIds = [];
+ let estimate = 0;
for (const img of images) {
- if (!wanted.has(shortImageId(img.Id))) continue;
+ const id = shortImageId(img.Id);
+ if (!wanted.has(id)) continue;
try {
await docker.getImage(img.Id).remove();
- deleted += 1;
- spaceReclaimed += img.Size ?? 0;
+ removedIds.push(id);
+ estimate += reclaimableSize(img).size;
} catch (err) {
- console.warn(`docker.js: failed to remove image ${shortImageId(img.Id)}: ${err.message}`);
+ console.warn(`docker.js: failed to remove image ${id}: ${err.message}`);
}
}
- return { deleted, spaceReclaimed };
+ let spaceReclaimed = estimate;
+ if (removedIds.length && before !== null) {
+ const after = await imageLayersSize();
+ if (after !== null) spaceReclaimed = Math.max(0, before - after);
+ }
+ return { deleted: removedIds.length, spaceReclaimed, removedIds };
+}
+
+/** True if an image (by ID or ref) still exists locally. */
+export async function imageExists(idOrRef) {
+ try {
+ await docker.getImage(idOrRef).inspect();
+ return true;
+ } catch (err) {
+ if (err.statusCode === 404) return false;
+ throw err;
+ }
}
export { docker };
diff --git a/server/src/index.js b/server/src/index.js
index 3e1cdd1..8fcb001 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -9,7 +9,7 @@ import db from './db.js';
import { authRouter, requireAuth } from './auth.js';
import { apiRouter } from './routes/api.js';
import { updateRouter } from './routes/update.js';
-import { securityHeaders } from './security.js';
+import { securityHeaders, requireCsrfHeader } from './security.js';
import scheduler from './scheduler.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -49,6 +49,8 @@ app.use(securityHeaders({ https: config.BASE_URL.startsWith('https') }));
app.use(express.json());
app.use(cookieParser(config.SESSION_SECRET));
+// Before every router (login included): unsafe /api methods need X-DockPull.
+app.use(requireCsrfHeader);
app.get('/api/health', (req, res) => {
res.json({ ok: true });
diff --git a/server/src/registry.js b/server/src/registry.js
index 6dbbf18..43f5c99 100644
--- a/server/src/registry.js
+++ b/server/src/registry.js
@@ -28,6 +28,19 @@ function apiHost(registry) {
return registry === 'docker.io' ? DOCKER_HUB_API_HOST : registry;
}
+/**
+ * Base URL for a registry's v2 API. Like Docker itself, registries on the
+ * loopback address (localhost / 127.0.0.0/8 / ::1, any port) are spoken to over
+ * plain http — that's where a local `registry:2` lives. Everything else: https.
+ */
+export function registryBaseUrl(registry) {
+ const host = apiHost(registry);
+ const bracketed = host.match(/^\[([^\]]+)\](?::\d+)?$/); // [ipv6]:port
+ const hostname = bracketed ? bracketed[1] : host.replace(/:\d+$/, '');
+ const loopback = hostname === 'localhost' || /^127\.\d+\.\d+\.\d+$/.test(hostname) || hostname === '::1';
+ return `${loopback ? 'http' : 'https'}://${host}`;
+}
+
/**
* Parse a `WWW-Authenticate: Bearer realm="...",service="...",scope="..."`
* header into its parameters.
@@ -49,6 +62,9 @@ export function parseWwwAuthenticate(header) {
async function fetchToken(wwwAuth, repository, timeoutMs, basicAuth) {
if (!wwwAuth.realm) return null;
const url = new URL(wwwAuth.realm);
+ // Only hand stored credentials to an https token server — never send them
+ // in clear text (an anonymous token request over http is still allowed).
+ if (url.protocol !== 'https:') basicAuth = null;
if (wwwAuth.service) url.searchParams.set('service', wwwAuth.service);
url.searchParams.set('scope', wwwAuth.scope || `repository:${repository}:pull`);
const res = await fetch(url, {
@@ -95,8 +111,8 @@ 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 base = registryBaseUrl(registry);
+ const manifestUrl = `${base}/v2/${repository}/manifests/${encodeURIComponent(tag)}`;
const headManifest = (authHeader) =>
fetch(manifestUrl, {
@@ -173,8 +189,8 @@ export async function getRemoteVersion(imageRef, { timeoutMs = 10000 } = {}) {
const { registry, repository, tag } = parseRef(imageRef);
if (!tag) return null;
- const host = apiHost(registry);
- const manifestUrl = `https://${host}/v2/${repository}/manifests/${encodeURIComponent(tag)}`;
+ const base = registryBaseUrl(registry);
+ const manifestUrl = `${base}/v2/${repository}/manifests/${encodeURIComponent(tag)}`;
let authHeader = null;
let res = await fetch(manifestUrl, {
@@ -198,7 +214,7 @@ export async function getRemoteVersion(imageRef, { timeoutMs = 10000 } = {}) {
if (Array.isArray(manifest.manifests) && manifest.manifests.length > 0) {
const picked = pickPlatformManifest(manifest.manifests);
if (!picked?.digest) return null;
- const subUrl = `https://${host}/v2/${repository}/manifests/${picked.digest}`;
+ const subUrl = `${base}/v2/${repository}/manifests/${picked.digest}`;
imageManifest = await authedJson(subUrl, authHeader, timeoutMs);
if (!imageManifest) return null;
}
@@ -206,7 +222,7 @@ export async function getRemoteVersion(imageRef, { timeoutMs = 10000 } = {}) {
const configDigest = imageManifest.config?.digest;
if (!configDigest) return null;
- const blobUrl = `https://${host}/v2/${repository}/blobs/${configDigest}`;
+ const blobUrl = `${base}/v2/${repository}/blobs/${configDigest}`;
const blobRes = await fetch(blobUrl, {
headers: { ...(authHeader ? { Authorization: authHeader } : {}) },
signal: AbortSignal.timeout(timeoutMs),
@@ -219,4 +235,4 @@ export async function getRemoteVersion(imageRef, { timeoutMs = 10000 } = {}) {
}
}
-export default { getRemoteDigest, getRemoteVersion, parseWwwAuthenticate, pickPlatformManifest };
+export default { registryBaseUrl, getRemoteDigest, getRemoteVersion, parseWwwAuthenticate, pickPlatformManifest };
diff --git a/server/src/routes/api.js b/server/src/routes/api.js
index 7294018..40f92ed 100644
--- a/server/src/routes/api.js
+++ b/server/src/routes/api.js
@@ -29,9 +29,13 @@ import { sendTest } from '../notify.js';
import { getChangelog } from '../changelog.js';
import { isValidNotifyUrl } from '../urlguard.js';
import * as db from '../db.js';
+import { validateContainerNameParam } from '../security.js';
export const apiRouter = express.Router();
+// Every :name route talks to Docker; reject anything that isn't a container name.
+apiRouter.param('name', validateContainerNameParam);
+
// App version, read once from package.json for the About panel / status.
const APP_VERSION = (() => {
try {
@@ -132,8 +136,8 @@ apiRouter.get('/api/events', (req, res) => {
function withVersions(rows) {
return rows.map((r) => ({
...r,
- old_version: db.getImageVersion(r.old_digest),
- new_version: db.getImageVersion(r.new_digest),
+ old_version: r.old_version ?? db.getImageVersion(r.old_digest),
+ new_version: r.new_version ?? db.getImageVersion(r.new_digest),
}));
}
@@ -203,7 +207,28 @@ apiRouter.post('/api/images/prune', async (req, res) => {
console.error(`api.js: POST /api/images/prune failed: ${err.message}`);
return res.status(500).json({ error: 'prune_failed' });
}
- return res.status(200).json({ ok: true, deleted: result.deleted, spaceReclaimed: result.spaceReclaimed });
+
+ // A pruned image may have been some container's revert point; drop those so
+ // the dashboard stops offering a Revert that would fail.
+ const revertsRemoved = db.deleteRollbackPointsForImages(result.removedIds);
+
+ // Refresh the "something to prune" status now, rather than leaving the
+ // pre-prune count in place until the next daily scan (which made the
+ // Settings badge reappear on reload).
+ try {
+ const left = await listDanglingImages();
+ db.setMeta('danglingImages', { count: left.count, totalSize: left.totalSize, checkedAt: Date.now() });
+ } catch {
+ // best-effort
+ }
+
+ if (revertsRemoved.length) broadcastGlobal({ type: 'containers-changed' });
+ return res.status(200).json({
+ ok: true,
+ deleted: result.deleted,
+ spaceReclaimed: result.spaceReclaimed,
+ revertsRemoved,
+ });
});
apiRouter.get('/api/pinned', (req, res) => {
diff --git a/server/src/routes/update.js b/server/src/routes/update.js
index 516a8e0..38bac14 100644
--- a/server/src/routes/update.js
+++ b/server/src/routes/update.js
@@ -14,13 +14,17 @@
*/
import express from 'express';
-import { docker, updateContainer, revertContainer } from '../docker.js';
+import { docker, updateContainer, revertContainer, imageExists, trackedImageRef } from '../docker.js';
import { normalizeRef } from '../reconcile.js';
import * as sse from '../sse.js';
import * as db from '../db.js';
+import { validateContainerNameParam } from '../security.js';
export const updateRouter = express.Router();
+// Every :name route talks to Docker; reject anything that isn't a container name.
+updateRouter.param('name', validateContainerNameParam);
+
/**
* Runs the update + records history + finishes the SSE session, detached
* from the request lifecycle (the POST handler responds before this
@@ -32,11 +36,14 @@ export const updateRouter = express.Router();
async function runUpdate(name, image) {
try {
const result = await updateContainer(name, (line, stream) => sse.pushLog(name, line, stream));
+ const oldVersion = db.getImageVersion(result.oldDigest) ?? result.oldVersion ?? null;
db.recordUpdate({
container_name: name,
image,
old_digest: result.oldDigest,
new_digest: result.newDigest,
+ old_version: oldVersion,
+ new_version: db.getImageVersion(result.newDigest),
status: result.success ? 'success' : 'failure',
message: result.message,
});
@@ -56,13 +63,15 @@ async function runUpdate(name, image) {
// Remember how to undo this update (the previous local image) whenever the
// image actually changed — even on a health-downgraded "failure", so the
// user can revert a broken update.
- if (result.oldImageId && result.newDigest && result.oldDigest && result.newDigest !== result.oldDigest) {
+ // Compare local image IDs, not registry digests: the old image's digest
+ // may be unknown (untagged by an earlier update of a sibling container).
+ if (result.oldImageId && result.newImageId && result.oldImageId !== result.newImageId) {
db.setRollbackPoint({
container_name: name,
image_id: result.oldImageId,
image_ref: image,
old_digest: result.oldDigest,
- old_version: db.getImageVersion(result.oldDigest),
+ old_version: oldVersion,
});
}
sse.finish(name, { success: result.success, message: result.message });
@@ -104,7 +113,7 @@ updateRouter.post('/api/update/:name', async (req, res) => {
sse.startSession(name);
- const image = inspectData.Config?.Image ?? null;
+ const image = trackedImageRef(inspectData);
// Fire-and-forget: don't await, so the POST returns promptly. runUpdate
// catches its own errors, so this can never reject/crash the process.
void runUpdate(name, image);
@@ -116,14 +125,19 @@ updateRouter.post('/api/update/:name', async (req, res) => {
* Detached revert: recreate the container from its remembered previous image,
* record history, and finish the SSE session. Mirrors runUpdate.
*/
-async function runRevert(name, image, imageId) {
+async function runRevert(name, image, rollback) {
try {
- const result = await revertContainer(name, imageId, (line, stream) => sse.pushLog(name, line, stream));
+ const result = await revertContainer(name, rollback.image_id, (line, stream) => sse.pushLog(name, line, stream), {
+ imageRef: image,
+ digest: rollback.old_digest,
+ });
db.recordUpdate({
container_name: name,
image,
old_digest: result.oldDigest,
new_digest: result.newDigest,
+ old_version: db.getImageVersion(result.oldDigest),
+ new_version: rollback.old_version ?? null,
status: result.success ? 'success' : 'failure',
message: result.message,
});
@@ -170,9 +184,24 @@ updateRouter.post('/api/update/:name/revert', async (req, res) => {
return res.status(409).json({ error: 'update_in_progress' });
}
+ // The saved image can disappear (pruned, or removed by hand). Check before
+ // touching the container, and forget the dead rollback point.
+ try {
+ if (!(await imageExists(rollback.image_id))) {
+ db.deleteRollbackPoint(name);
+ sse.broadcastGlobal({ type: 'containers-changed' });
+ return res.status(410).json({
+ error: 'rollback_image_gone',
+ message: 'The previous image no longer exists (it may have been pruned), so this update can no longer be reverted.',
+ });
+ }
+ } catch {
+ return res.status(503).json({ error: 'docker_unavailable' });
+ }
+
sse.startSession(name);
- const image = inspectData.Config?.Image ?? rollback.image_ref ?? null;
- void runRevert(name, image, rollback.image_id);
+ const image = rollback.image_ref ?? trackedImageRef(inspectData);
+ void runRevert(name, image, rollback);
return res.status(200).json({ streamId: name });
});
diff --git a/server/src/security.js b/server/src/security.js
index a31efb9..29319f1 100644
--- a/server/src/security.js
+++ b/server/src/security.js
@@ -39,4 +39,36 @@ export function securityHeaders({ https = false } = {}) {
};
}
-export default { securityHeaders, CONTENT_SECURITY_POLICY };
+// Docker container names (and IDs) — anything else is rejected before it
+// reaches dockerode, which splices names into request paths unescaped (so a
+// "name" like "../../images/x" would address a different API endpoint).
+const CONTAINER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/;
+
+export function isValidContainerName(name) {
+ return typeof name === 'string' && CONTAINER_NAME_RE.test(name);
+}
+
+/** Express `router.param('name', …)` handler enforcing isValidContainerName. */
+export function validateContainerNameParam(req, res, next, name) {
+ if (isValidContainerName(name)) return next();
+ return res.status(400).json({ error: 'invalid_container_name' });
+}
+
+const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
+
+/**
+ * CSRF guard for the API. The session cookie is SameSite=Lax, but "same site"
+ * includes every port on the same host — and homelabs often run many apps on
+ * one IP, any of which (or an XSS in one) could otherwise make the browser
+ * POST to DockPull with the user's cookie. State-changing /api requests must
+ * carry `X-DockPull: 1`: a cross-origin page can't set a custom header without
+ * a CORS preflight, which this server never approves. The app's own client
+ * always sends it.
+ */
+export function requireCsrfHeader(req, res, next) {
+ if (SAFE_METHODS.has(req.method) || !req.path.startsWith('/api/')) return next();
+ if (req.get('x-dockpull') === '1') return next();
+ return res.status(403).json({ error: 'csrf_header_missing' });
+}
+
+export default { securityHeaders, requireCsrfHeader, isValidContainerName, validateContainerNameParam, CONTENT_SECURITY_POLICY };
diff --git a/server/test/auth.test.js b/server/test/auth.test.js
index 81eaf64..66ed751 100644
--- a/server/test/auth.test.js
+++ b/server/test/auth.test.js
@@ -1,6 +1,7 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
-import { isValidSession, requireAuth } from '../src/auth.js';
+import { isValidSession, requireAuth, sessionCookieValue } from '../src/auth.js';
+import { config } from '../src/config.js';
function makeReq({ signedCookies = {}, path = '/api/containers' } = {}) {
return { signedCookies, path };
@@ -16,7 +17,7 @@ describe('isValidSession', () => {
});
test('returns false when the expiry is in the past', () => {
- const expired = String(Date.now() - 1000);
+ const expired = sessionCookieValue(Date.now() - 1000);
assert.equal(isValidSession(makeReq({ signedCookies: { dockpull_session: expired } })), false);
});
@@ -24,8 +25,25 @@ describe('isValidSession', () => {
assert.equal(isValidSession(makeReq({ signedCookies: { dockpull_session: 'not-a-number' } })), false);
});
+ test('returns false for a legacy expiry-only cookie', () => {
+ const legacy = String(Date.now() + 1000 * 60);
+ assert.equal(isValidSession(makeReq({ signedCookies: { dockpull_session: legacy } })), false);
+ });
+
+ test('changing ADMIN_PASSWORD invalidates existing sessions', () => {
+ const before = config.ADMIN_PASSWORD;
+ const cookie = sessionCookieValue(Date.now() + 60_000);
+ try {
+ config.ADMIN_PASSWORD = `${before}-changed`;
+ assert.equal(isValidSession(makeReq({ signedCookies: { dockpull_session: cookie } })), false);
+ } finally {
+ config.ADMIN_PASSWORD = before;
+ }
+ assert.equal(isValidSession(makeReq({ signedCookies: { dockpull_session: cookie } })), true);
+ });
+
test('returns true when the expiry is in the future', () => {
- const future = String(Date.now() + 1000 * 60);
+ const future = sessionCookieValue(Date.now() + 1000 * 60);
assert.equal(isValidSession(makeReq({ signedCookies: { dockpull_session: future } })), true);
});
});
@@ -65,7 +83,7 @@ describe('requireAuth', () => {
});
test('calls next() for /api/ requests with a valid session', () => {
- const future = String(Date.now() + 1000 * 60);
+ const future = sessionCookieValue(Date.now() + 1000 * 60);
const req = makeReq({ signedCookies: { dockpull_session: future }, path: '/api/containers' });
const res = { status: () => { throw new Error('should not respond'); } };
let nextCalled = false;
diff --git a/server/test/docker.test.js b/server/test/docker.test.js
index 361c84a..16e14ad 100644
--- a/server/test/docker.test.js
+++ b/server/test/docker.test.js
@@ -132,3 +132,22 @@ test('buildRecreateOptions: without the base image config, everything is kept',
assert.deepEqual(o.Env, inspect.Config.Env);
assert.deepEqual(o.Cmd, ['serve']);
});
+
+import { trackedImageRef, REF_LABEL, DIGEST_LABEL } from '../src/docker.js';
+
+test('trackedImageRef: a reverted container (bare image ID) tracks its remembered ref', () => {
+ const id = `sha256:${'a'.repeat(64)}`;
+ assert.equal(trackedImageRef({ Config: { Image: id, Labels: { [REF_LABEL]: 'app:latest' } } }), 'app:latest');
+ assert.equal(trackedImageRef({ Config: { Image: 'app:1.2', Labels: { [REF_LABEL]: 'app:latest' } } }), 'app:1.2');
+ assert.equal(trackedImageRef({ Config: { Image: 'app:latest' } }), 'app:latest');
+ assert.equal(trackedImageRef({ Config: { Image: id } }), id);
+});
+
+test('buildRecreateOptions: revert labels never carry over to the next container', () => {
+ const o = buildRecreateOptions(
+ { ...inspect, Config: { ...inspect.Config, Labels: { ...inspect.Config.Labels, [REF_LABEL]: 'app:latest', [DIGEST_LABEL]: 'sha256:1' } } },
+ baseImage,
+ 'app:latest'
+ );
+ assert.deepEqual(o.Labels, { 'my.label': 'x' });
+});
diff --git a/server/test/prune.test.js b/server/test/prune.test.js
new file mode 100644
index 0000000..2600957
--- /dev/null
+++ b/server/test/prune.test.js
@@ -0,0 +1,113 @@
+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 http from 'node:http';
+
+// A fake Docker Engine API on a unix socket, so the prune code runs for real
+// (dockerode, query params, measured disk usage) without a daemon.
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dockpull-prune-'));
+const socketPath = path.join(tmp, 'docker.sock');
+process.env.DOCKER_SOCKET = socketPath;
+process.env.DATA_DIR = tmp;
+
+const GB = 1024 ** 3;
+const MB = 1024 ** 2;
+const ID = (c) => `sha256:${c.repeat(64)}`;
+// Two old images of an app: each is 1 GB in total, but 900 MB of that is the
+// base layer the CURRENT (tagged, in-use) image still uses. Only ~100 MB each
+// is actually freed by deleting them.
+let dangling = [
+ { Id: ID('a'), Size: 1000 * MB, SharedSize: 900 * MB, Created: 1 },
+ { Id: ID('b'), Size: 1000 * MB, SharedSize: 900 * MB, Created: 2 },
+];
+let layersSize = 5 * GB;
+let inUse = []; // image IDs containers run
+const requests = [];
+
+const server = http.createServer((req, res) => {
+ requests.push(`${req.method} ${req.url}`);
+ const url = new URL(req.url, 'http://docker');
+ const json = (code, body) => {
+ res.writeHead(code, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(body));
+ };
+ if (req.method === 'GET' && url.pathname.endsWith('/images/json')) {
+ // Like Docker: SharedSize is only meaningful when computed across ALL
+ // images. With the dangling filter, a lone leftover looks fully unique.
+ if (url.searchParams.get('filters')) {
+ return json(200, dangling.map((i) => ({ ...i, SharedSize: dangling.length > 1 ? i.SharedSize : 0 })));
+ }
+ return json(200, [...dangling, { Id: ID('c'), Size: 1000 * MB, SharedSize: 900 * MB, RepoTags: ['app:latest'] }]);
+ }
+ if (req.method === 'GET' && url.pathname.endsWith('/containers/json')) {
+ return json(200, inUse.map((ImageID) => ({ Id: 'c1', ImageID })));
+ }
+ if (req.method === 'GET' && url.pathname.endsWith('/system/df')) return json(200, { LayersSize: layersSize });
+ const del = url.pathname.match(/\/images\/(sha256:[0-9a-f]+)$/);
+ if (req.method === 'DELETE' && del) {
+ dangling = dangling.filter((i) => i.Id !== del[1]);
+ layersSize -= 100 * MB; // what the daemon really frees
+ return json(200, [{ Deleted: del[1] }]);
+ }
+ json(404, { message: 'not found' });
+});
+await new Promise((r) => server.listen(socketPath, r));
+
+const docker = await import('../src/docker.js');
+test.after(() => server.close());
+
+test('reclaimableSize: whole size minus the part shared with other images', () => {
+ assert.deepEqual(docker.reclaimableSize({ Size: 1000, SharedSize: 900 }), { size: 100, exact: true });
+ assert.deepEqual(docker.reclaimableSize({ Size: 1000, SharedSize: -1 }), { size: 1000, exact: false });
+ assert.deepEqual(docker.reclaimableSize({ Size: 1000 }), { size: 1000, exact: false });
+});
+
+test('listDanglingImages: preview reports reclaimable bytes, not whole-image sizes', async () => {
+ const r = await docker.listDanglingImages();
+ assert.equal(r.count, 2);
+ assert.equal(r.totalSize, 200 * MB); // was 2 GB before the fix
+ assert.equal(r.exact, true);
+ assert.equal(r.images[0].fullSize, 1000 * MB);
+ assert.ok(requests.some((q) => q.includes('/images/json') && q.includes('shared-size=true')));
+});
+
+test('listDanglingImages: a lone leftover still counts the base layer it shares with the current image', async () => {
+ const saved = dangling;
+ dangling = [saved[0]];
+ try {
+ const r = await docker.listDanglingImages();
+ assert.equal(r.totalSize, 100 * MB);
+ } finally {
+ dangling = saved;
+ }
+});
+
+test('listDanglingImages: untagged images a container still uses are not offered', async () => {
+ inUse = [ID('b')];
+ try {
+ const r = await docker.listDanglingImages();
+ assert.deepEqual(r.images.map((i) => i.id), ['aaaaaaaaaaaa']);
+ } finally {
+ inUse = [];
+ }
+});
+
+test('removeDanglingImages: reports MEASURED space freed and only removes requested dangling images', async () => {
+ requests.length = 0;
+ const r = await docker.removeDanglingImages(['aaaaaaaaaaaa', 'ffffffffffff' /* not dangling */]);
+ assert.equal(r.deleted, 1);
+ assert.deepEqual(r.removedIds, ['aaaaaaaaaaaa']);
+ assert.equal(r.spaceReclaimed, 100 * MB); // was 1 GB before the fix
+ assert.ok(requests.some((q) => q.startsWith('GET') && q.includes('/system/df') && q.includes('type=image')));
+ assert.equal(requests.filter((q) => q.startsWith('DELETE')).length, 1);
+ assert.equal(dangling.length, 1);
+});
+
+test('pruneDanglingImages: counts images (not untag/layer entries) and measures space', async () => {
+ const r = await docker.pruneDanglingImages();
+ assert.equal(r.deleted, 1);
+ assert.equal(r.spaceReclaimed, 100 * MB);
+ assert.equal(dangling.length, 0);
+});
diff --git a/server/test/registry.test.js b/server/test/registry.test.js
index 2f4a317..d7a5248 100644
--- a/server/test/registry.test.js
+++ b/server/test/registry.test.js
@@ -44,3 +44,15 @@ test('pickPlatformManifest: returns null for empty/non-array input', () => {
assert.equal(pickPlatformManifest(null), null);
assert.equal(pickPlatformManifest(undefined), null);
});
+
+import { registryBaseUrl } from '../src/registry.js';
+
+test('registryBaseUrl: loopback registries use http (like Docker), everything else https', () => {
+ assert.equal(registryBaseUrl('localhost:5000'), 'http://localhost:5000');
+ assert.equal(registryBaseUrl('127.0.0.1:5005'), 'http://127.0.0.1:5005');
+ assert.equal(registryBaseUrl('[::1]:5000'), 'http://[::1]:5000');
+ assert.equal(registryBaseUrl('docker.io'), 'https://registry-1.docker.io');
+ assert.equal(registryBaseUrl('ghcr.io'), 'https://ghcr.io');
+ assert.equal(registryBaseUrl('registry.local:5000'), 'https://registry.local:5000');
+ assert.equal(registryBaseUrl('localhost.evil.com'), 'https://localhost.evil.com');
+});
diff --git a/server/test/security.test.js b/server/test/security.test.js
index d5e193d..7951e6e 100644
--- a/server/test/security.test.js
+++ b/server/test/security.test.js
@@ -37,3 +37,35 @@ test('securityHeaders: HSTS only when https', () => {
assert.equal(run({ https: false }).headers['Strict-Transport-Security'], undefined);
assert.match(run({ https: true }).headers['Strict-Transport-Security'], /max-age=31536000/);
});
+
+import { requireCsrfHeader } from '../src/security.js';
+
+function runCsrf({ method, path, header }) {
+ let status = null;
+ let nextCalled = false;
+ const req = { method, path, get: (h) => (h.toLowerCase() === 'x-dockpull' ? header : undefined) };
+ const res = { status: (s) => ((status = s), { json: () => {} }) };
+ requireCsrfHeader(req, res, () => (nextCalled = true));
+ return { status, nextCalled };
+}
+
+test('requireCsrfHeader: blocks state-changing /api requests without the header', () => {
+ assert.equal(runCsrf({ method: 'POST', path: '/api/update/web' }).status, 403);
+ assert.equal(runCsrf({ method: 'DELETE', path: '/api/history' }).status, 403);
+ assert.equal(runCsrf({ method: 'POST', path: '/api/auth/login', header: '0' }).status, 403);
+});
+
+test('requireCsrfHeader: allows the app client, safe methods, and non-API paths', () => {
+ assert.equal(runCsrf({ method: 'POST', path: '/api/update/web', header: '1' }).nextCalled, true);
+ assert.equal(runCsrf({ method: 'GET', path: '/api/containers' }).nextCalled, true);
+ assert.equal(runCsrf({ method: 'POST', path: '/somewhere' }).nextCalled, true);
+});
+
+import { isValidContainerName } from '../src/security.js';
+
+test('isValidContainerName: accepts Docker names, rejects path tricks', () => {
+ for (const ok of ['web', 'my-app_1', 'stack.svc-2', 'a'.repeat(64)]) assert.equal(isValidContainerName(ok), true, ok);
+ for (const bad of ['', '../../images/json', 'a/b', '-x', '.hidden', 'a b', 'x?y', null]) {
+ assert.equal(isValidContainerName(bad), false, String(bad));
+ }
+});