diff --git a/.env.example b/.env.example index 71dd3b3..8a6bee7 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,14 @@ STACKS_DIR=/opt/stacks # Path to the host Docker socket, bind-mounted into the container. DOCKER_SOCKET=/var/run/docker.sock +# Optional: directory holding a Docker config.json with registry credentials +# (what `docker login` writes). Lets DockPull check PRIVATE images and avoid +# Docker Hub's anonymous rate limit. Mount your config and point this at its +# directory, e.g. mount ~/.docker/config.json -> /root/.docker/config.json:ro +# and leave this unset (the default /root/.docker is used), or set a custom dir. +# Only static base64 `auths` are supported (not credential-helper stores). +# DOCKER_CONFIG=/root/.docker + # Directory where the SQLite database is stored (should be a persistent # volume). DATA_DIR=/data @@ -35,13 +43,20 @@ BASE_URL=http://localhost:5000 # true. Leave unset when exposed directly, so X-Forwarded-For can't be spoofed. # TRUST_PROXY=1 +# Serve under a subpath (e.g. behind a reverse proxy at https://host/dockpull/) +# when the proxy does NOT strip the prefix. Build the client with the same +# value: BASE_PATH=/dockpull docker compose build. Leave unset for root. +# BASE_PATH=/dockpull + # --- Background checks & notifications (all optional; also editable in the UI) --- # Whether the server checks for updates on a schedule. Default: true. # BACKGROUND_CHECK_ENABLED=true # Daily local time (HH:MM, 24h) for the scheduled scan. Default: 09:00. # SCHEDULED_CHECK_TIME=09:00 -# Discord (or compatible) webhook URL to notify when updates are found. -# Leave unset to disable notifications. +# Notifications (all also editable in the UI). NOTIFY_TYPE is one of: +# discord | ntfy | gotify | webhook. DISCORD_WEBHOOK_URL is the target URL for +# whichever type you pick (a self-hosted ntfy/Gotify on your LAN is fine). +# NOTIFY_TYPE=discord # DISCORD_WEBHOOK_URL= # Optional GitHub token (read-only, no scopes needed) used for changelog and diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 33fc9a3..08101ff 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -76,6 +76,20 @@ All request/response bodies are JSON unless noted otherwise. progress via the SSE endpoint below. - Errors: `404` if no such container; `409` if an update is already in progress for that container. +- Note: after `up -d`, the container is health-checked; if it doesn't come up + healthy the result is reported as `success:false` with an actionable message + (and a rollback point is recorded). + +### `POST /api/update/:name/revert` + +- Auth: cookie. +- Path param: `name` — container name. +- Recreates the container from the image it ran before its last update (the + rollback point), then starts it. Same SSE streaming + result shape as an + update; subscribe via `GET /api/update/:name/stream`. +- Response: `200 { "streamId": "string" }`. +- Errors: `404 no_rollback` if there's nothing to revert to; `404 not_found` + if no such container; `409` if an update/revert is already in progress. ### `GET /api/update/:name/stream` @@ -243,6 +257,10 @@ Field notes: - `pinned` — `true` if the image ref is in the `pinned` table ("Pin Version": update indicator is suppressed and the container is grouped separately, but a manual update is still allowed). +- `canRevert` — `true` if a rollback point exists (the container was updated and + its previous image is remembered), so the UI can offer a one-click revert. +- `rollbackVersion` — the previous version label for that rollback point, or + `null`. - `state` — Docker container state (`running`, `exited`, etc.). - `composeFile` / `workingDir` — derived from `com.docker.compose.project.config_files` / diff --git a/README.md b/README.md index 475dacb..371dfd0 100644 --- a/README.md +++ b/README.md @@ -71,17 +71,28 @@ If the paths don't match you'll get `compose file not found` and broken bind mou - **Updates tab** — containers grouped by stack, update-available ones on top. Defaults to showing only what needs updating; flip to **All** to see everything. Tap **Update** to pull + recreate that service (watch live logs), or **Update all** - to run them one at a time. **Pin Version** holds a container at its current version. + to run them one at a time. After an update DockPull verifies the container actually + comes up healthy (catching crash-loops), and offers a one-click **Revert** to the + previous image if it doesn't. **Pin Version** holds a container at its current version. - **History tab** — a log of past updates. **Clear history** wipes it (with a confirm). - **Settings tab** — theme, default view, auto-check on open, the **daily background - scan** + **Discord webhook** (with a "send test" button), and pinned-version - management. + scan** + **notifications** (Discord, ntfy, Gotify, or a generic webhook — with a + "send test" button), and pinned-version management. - **Install as an app (PWA)** — use your browser's "Add to Home Screen" / "Install" to get a standalone, full-screen icon. -The update check queries registries directly (Docker Hub, GHCR, lscr.io, quay.io, …) -for **public** images reachable anonymously. Private images that need credentials are -skipped. +The update check queries registries directly (Docker Hub, GHCR, lscr.io, quay.io, …). +Public images work anonymously. For **private** images (and to dodge Docker Hub's +anonymous rate limit), mount your Docker credentials read-only so DockPull can +authenticate: + +```yaml + volumes: + - ~/.docker/config.json:/root/.docker/config.json:ro +``` + +This is the file `docker login` writes; only static `auths` entries are used (not +credential-helper stores). --- @@ -119,6 +130,11 @@ model and hardening tips (HTTPS/`BASE_URL`, `TRUST_PROXY`, `SESSION_TTL`). To update DockPull itself: `docker compose pull dockpull && docker compose up -d dockpull`. +**Behind a reverse proxy?** Set `TRUST_PROXY=1`. To serve under a **subpath** +(`https://host/dockpull/`), either have the proxy strip the prefix, or set +`BASE_PATH=/dockpull` and build the client with the same value +(`BASE_PATH=/dockpull docker compose build`). + --- ## Troubleshooting diff --git a/SECURITY.md b/SECURITY.md index f3aae66..c2e4efc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,9 +22,10 @@ DockPull is built for a **trusted LAN / homelab** behind authentication. It is with argument arrays — never a shell string — so container/label values can't inject commands. - **Parameterized SQL** (better-sqlite3 prepared statements). -- **SSRF-guarded webhooks.** The Discord webhook URL must be `https` to a public - host; loopback/private/link-local/metadata addresses are rejected, including - hostnames that *resolve* to a private address. +- **Notification URL is admin-only.** The notification target (Discord / ntfy / + Gotify / generic webhook) is set behind the login and may deliberately point at + an internal service (e.g. a self-hosted ntfy/Gotify on your LAN), so internal + hosts are allowed by design; it is validated only as a well-formed `http(s)` URL. - **Security headers** on every response: `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Cross-Origin-Opener-Policy`, and `Strict-Transport-Security` when served over diff --git a/client/src/Dashboard.jsx b/client/src/Dashboard.jsx index e90c110..293c6f1 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, checkNow, getSettings, updateSettings } from './api.js'; +import { getContainers, checkNow, getSettings, updateSettings, getStatus } from './api.js'; import UpdateCard from './components/UpdateCard.jsx'; import UpdateAllButton from './components/UpdateAllButton.jsx'; import StackGroup from './components/StackGroup.jsx'; @@ -7,6 +7,18 @@ import StackGroup from './components/StackGroup.jsx'; const AUTOCHECK_SESSION = 'dockpull.autochecked'; const UNGROUPED = 'Ungrouped'; +// Compact "x ago" for the last-checked line. +function timeAgo(ts) { + if (!ts) return null; + const s = Math.max(0, Math.round((Date.now() - ts) / 1000)); + if (s < 60) return 'just now'; + const m = Math.round(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.round(h / 24)}d ago`; +} + function hasUpdate(c) { return c.updateAvailable && !c.pinned; } @@ -38,6 +50,7 @@ export default function Dashboard({ onPendingCountChange }) { const [checking, setChecking] = useState(false); const [checkMsg, setCheckMsg] = useState(''); const [filter, setFilter] = useState('updates'); + const [lastCheckedAt, setLastCheckedAt] = useState(null); // name -> run() function, populated by each UpdateCard so "Update all" // can drive the same start+SSE flow the per-card button uses. @@ -60,6 +73,7 @@ export default function Dashboard({ onPendingCountChange }) { try { const r = await checkNow(); await load(); + setLastCheckedAt(Date.now()); const checked = r?.checked ?? 0; const found = r?.updatesFound ?? 0; const errs = r?.errors ?? 0; @@ -75,6 +89,15 @@ export default function Dashboard({ onPendingCountChange }) { } }, [load]); + // Seed the "last checked" time from the server's persisted last check. + useEffect(() => { + getStatus() + .then((s) => { + if (s?.lastCheck?.at) setLastCheckedAt((prev) => prev || s.lastCheck.at); + }) + .catch(() => {}); + }, []); + // Initial load + settings + auto-check on first open this session. useEffect(() => { let cancelled = false; @@ -234,6 +257,7 @@ export default function Dashboard({ onPendingCountChange }) {

Queries each image's registry for a newer version — nothing is pulled until you tap Update. + {lastCheckedAt ? · Last checked {timeAgo(lastCheckedAt)} : null}

diff --git a/client/src/api.js b/client/src/api.js index ba51bd0..f65c384 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -2,7 +2,9 @@ // All requests are same-origin (dev proxy forwards /api to the server) and // always send the session cookie. -const BASE = '/api'; +// Honour a build-time subpath (Vite `base`), so `/api` is reached under the +// same prefix the app is served from (e.g. /dockpull/api). +const BASE = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/api`; class ApiError extends Error { constructor(message, status, body) { @@ -97,10 +99,19 @@ export function checkNow() { return post('/check'); } +// App status: { version, lastCheck: { at, total, checked, updatesFound, errors, errored } | null }. +export function getStatus() { + return get('/status'); +} + export function startUpdate(name) { return post(`/update/${encodeURIComponent(name)}`); } +export function revertUpdate(name) { + return post(`/update/${encodeURIComponent(name)}/revert`); +} + // --- History --- export function getHistory(params = {}) { @@ -140,8 +151,11 @@ export function updateSettings(patch) { return request('PUT', '/settings', patch); } -export function testNotify(url) { - return post('/notify/test', url ? { url } : {}); +export function testNotify(url, type) { + const body = {}; + if (url) body.url = url; + if (type) body.type = type; + return post('/notify/test', body); } export function getChangelog(name) { diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx index 8a5df48..65772f6 100644 --- a/client/src/components/UpdateCard.jsx +++ b/client/src/components/UpdateCard.jsx @@ -3,6 +3,7 @@ import { pin, unpin, getChangelog } from '../api.js'; import { useUpdateRunner } from '../hooks/useUpdateRunner.js'; import StatusMessage from './StatusMessage.jsx'; import StreamLog from './StreamLog.jsx'; +import ConfirmDialog from './ConfirmDialog.jsx'; function shortDigest(digest) { if (!digest) return '—'; @@ -137,18 +138,19 @@ function ChangelogContent({ data }) { * - registerRunner(name, runFn) — handle for "Update all" */ export default function UpdateCard({ container, onSettled, onPinChange, registerRunner }) { - const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, pinned, sourceUrl } = + const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, pinned, sourceUrl, canRevert, rollbackVersion, checkError } = container; const [pinBusy, setPinBusy] = useState(false); const [actionError, setActionError] = useState(''); + const [confirmRevert, setConfirmRevert] = useState(false); const [clOpen, setClOpen] = useState(false); const [clLoading, setClLoading] = useState(false); const [clData, setClData] = useState(null); const [clError, setClError] = useState(''); - const { run, busy, startError, status, lines } = useUpdateRunner(name, onSettled); + const { run, revert, busy, startError, status, lines } = useUpdateRunner(name, onSettled); useEffect(() => { if (registerRunner) registerRunner(name, run); @@ -162,6 +164,12 @@ export default function UpdateCard({ container, onSettled, onPinChange, register run(); }, [busy, run]); + const handleRevert = useCallback(() => { + setConfirmRevert(false); + if (busy) return; + revert(); + }, [busy, revert]); + const togglePin = useCallback(async () => { setPinBusy(true); setActionError(''); @@ -248,6 +256,11 @@ export default function UpdateCard({ container, onSettled, onPinChange, register )}
+ {checkError && ( +

+ ⚠ Couldn't check for updates (e.g. private registry or rate limit). +

+ )} {actionError && } {startError && } @@ -265,6 +278,17 @@ export default function UpdateCard({ container, onSettled, onPinChange, register {clOpen ? 'Hide changes' : showUpdateAvailable ? "What's changed" : 'Release notes'} )} + {canRevert && ( + + )}