Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 31 additions & 9 deletions API_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions client/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand All @@ -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);
Expand Down
32 changes: 26 additions & 6 deletions client/src/pages/SettingsPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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?.();
Expand Down Expand Up @@ -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'
Expand All @@ -527,7 +530,15 @@ export default function SettingsPage({ onPruneComplete } = {}) {
<p className="confirm-message">
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).
</p>
{pruneSelection.some((img) => img.fromContainer) && (
<p className="confirm-message prune-revert-warning">
⚠ Rows named after a container are its previous version — pruning one removes the
option to revert that container's last update.
</p>
)}
{pruneSelection.length === 0 ? (
<p className="prune-empty">All layers excluded — nothing will be pruned.</p>
) : (
Expand All @@ -550,7 +561,16 @@ export default function SettingsPage({ onPruneComplete } = {}) {
</span>
<span className="prune-source-id">{img.id}</span>
</td>
<td className="prune-size">{formatBytes(img.size || 0)}</td>
<td
className="prune-size"
title={
img.fullSize && img.fullSize !== img.size
? `Whole image is ${formatBytes(img.fullSize)}; the rest is shared with other images and stays.`
: undefined
}
>
{formatBytes(img.size || 0)}
</td>
<td className="prune-created">{formatAge(img.created)}</td>
<td className="prune-remove-cell">
<button
Expand Down
35 changes: 29 additions & 6 deletions server/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ function isValidPassword(provided) {
return crypto.timingSafeEqual(providedBuf, expectedBuf);
}

/**
* Short fingerprint of the current admin password, keyed by SESSION_SECRET.
* Embedded in the session cookie so changing ADMIN_PASSWORD signs out every
* existing session (including a stolen cookie) instead of leaving them valid
* until they expire. Reveals nothing about the password itself.
*/
function passwordFingerprint() {
return crypto
.createHmac('sha256', config.SESSION_SECRET || '')
.update(`dockpull-session:${config.ADMIN_PASSWORD || ''}`)
.digest('hex')
.slice(0, 16);
}

/** Session cookie value: `<expiry ms>.<password fingerprint>`. */
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
Expand All @@ -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);
}

/**
Expand All @@ -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',
Expand Down
32 changes: 29 additions & 3 deletions server/src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
});
Expand Down
Loading
Loading