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
19 changes: 17 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions API_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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` /
Expand Down
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion client/src/Dashboard.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
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';

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;
}
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -234,6 +257,7 @@ export default function Dashboard({ onPendingCountChange }) {

<p className="dashboard-subtitle">
Queries each image's registry for a newer version — nothing is pulled until you tap Update.
{lastCheckedAt ? <span className="dashboard-lastcheck"> · Last checked {timeAgo(lastCheckedAt)}</span> : null}
</p>

<div className="filter-row" role="group" aria-label="Filter containers">
Expand Down
20 changes: 17 additions & 3 deletions client/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 38 additions & 2 deletions client/src/components/UpdateCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 '—';
Expand Down Expand Up @@ -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);
Expand All @@ -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('');
Expand Down Expand Up @@ -248,6 +256,11 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
)}
</div>

{checkError && (
<p className="card-check-error" title={checkError}>
⚠ Couldn't check for updates (e.g. private registry or rate limit).
</p>
)}
{actionError && <StatusMessage type="error" message={actionError} />}
{startError && <StatusMessage type="error" message={startError} />}
<StatusMessage type={status.type} message={status.message} />
Expand All @@ -265,6 +278,17 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
{clOpen ? 'Hide changes' : showUpdateAvailable ? "What's changed" : 'Release notes'}
</button>
)}
{canRevert && (
<button
type="button"
className="btn-ghost btn-ghost-danger"
onClick={() => setConfirmRevert(true)}
disabled={busy}
title={rollbackVersion ? `Revert to ${rollbackVersion}` : 'Revert to the previous image'}
>
Revert{rollbackVersion ? ` to ${rollbackVersion}` : ''}
</button>
)}
</div>
<button
type="button"
Expand All @@ -277,6 +301,18 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
</button>
</div>

{confirmRevert && (
<ConfirmDialog
title="Revert to the previous image?"
message={`This recreates "${name}" from the image it ran before the last update${
rollbackVersion ? ` (${rollbackVersion})` : ''
}. Tip: pin the version afterwards, or your next compose update will pull the newer image again.`}
confirmLabel="Revert"
onConfirm={handleRevert}
onCancel={() => setConfirmRevert(false)}
/>
)}

{clOpen && (
<div className="changelog-panel">
{clLoading && (
Expand Down
Loading
Loading