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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,11 @@ DIUN_WEBHOOK_TOKEN=
SESSION_TTL=604800

# Public base URL of this app (used in logs / any absolute links).
# If this starts with https, the login cookie is marked Secure.
BASE_URL=http://localhost:5000

# Name of this app's OWN container. It is excluded from the dashboard so it
# can't be told to update (and thereby restart) itself mid-update. Defaults to
# "diun-updater" (the container_name in the shipped docker-compose.yml); change
# it only if you rename the service.
SELF_CONTAINER_NAME=diun-updater
2 changes: 2 additions & 0 deletions API_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ All request/response bodies are JSON unless noted otherwise.
- Response:
- `200 { "ok": true }` + `Set-Cookie: diun_session=...` on success.
- `401 { "error": "invalid_password" }` on bad password.
- `429 { "error": "too_many_attempts" }` after too many failed attempts
from one client IP (temporary lockout).

### `POST /api/auth/logout`

Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 strandedturtle

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ All configuration is via environment variables (see `.env.example`).
| `DATA_DIR` | `/data` | | SQLite (`app.db`) location; persist via a volume. |
| `SESSION_TTL` | `604800` | | Login cookie lifetime in seconds (7 days). |
| `BASE_URL` | `http://localhost:5000` | | Public URL; if `https`, the cookie is set `Secure`. |
| `SELF_CONTAINER_NAME` | `diun-updater` | | This app's own container name, excluded from the dashboard so it can't update itself. |

The three required vars are enforced at startup — the server refuses to boot
without them (a `SKIP_CONFIG_CHECK=1` escape hatch exists for skeleton
Expand All @@ -329,9 +330,13 @@ smoke-tests only; never use it in production).
by `DIUN_WEBHOOK_TOKEN` (constant-time compared). Treat that token like a
password and don't expose the app publicly without a proxy if you can avoid it.
- **Auth** is a single password compared in constant time, issuing a signed,
`httpOnly`, `SameSite=Lax` cookie (`Secure` when `BASE_URL` is https). There's
intentionally no rate-limiting on login yet — keep the app off the open
internet or front it with Access/basic-auth if that matters to you.
`httpOnly`, `SameSite=Lax` cookie (`Secure` when `BASE_URL` is https). Failed
logins are rate-limited per client IP (lockout after repeated failures) to
blunt brute-force — but this is not a substitute for keeping the app off the
open internet or fronting it with Cloudflare Access if exposure matters.
- **The app excludes its own container** from the dashboard (it can't safely
update itself). Update the updater the normal way:
`docker compose pull diun-updater && docker compose up -d diun-updater`.

---

Expand Down
4 changes: 4 additions & 0 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f1117" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Diun Updater" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Diun Updater</title>
Expand Down
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "diun-updater-client",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion client/src/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export default function Dashboard({ onPendingCountChange }) {

{!loading && !error && containers.length === 0 && (
<div className="empty-state">
<p>All up to date.</p>
<p>No containers found.</p>
</div>
)}

Expand Down
43 changes: 25 additions & 18 deletions client/src/hooks/useUpdateRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,25 @@ export function useUpdateRunner(name, onSettled) {
const { lines, result, error: sseError, reset } = useSSE(name, streamActive);

const resolveRef = useRef(null);
// Ensures we settle (resolve the run promise + notify the dashboard) exactly
// once per run, so a connection error arriving after the result can't
// overwrite a success or trigger a second re-fetch.
const settledRef = useRef(false);

const settle = useCallback(() => {
if (settledRef.current) return;
settledRef.current = true;
onSettled(name);
if (resolveRef.current) {
resolveRef.current();
resolveRef.current = null;
}
}, [name, onSettled]);

const run = useCallback(() => {
return new Promise((resolve) => {
resolveRef.current = resolve;
settledRef.current = false;
setStartError('');
setStatus({ type: '', message: '' });
reset();
Expand All @@ -37,15 +52,11 @@ export function useUpdateRunner(name, onSettled) {
})
.catch((err) => {
setStartError(err.message || 'Failed to start update');
if (resolveRef.current) {
resolveRef.current();
resolveRef.current = null;
}
onSettled(name);
settle();
})
.finally(() => setStarting(false));
});
}, [name, reset, onSettled]);
}, [name, reset, settle]);

useEffect(() => {
if (!result) return;
Expand All @@ -54,22 +65,18 @@ export function useUpdateRunner(name, onSettled) {
type: result.success ? 'success' : 'error',
message: result.message || (result.success ? 'Updated successfully' : 'Update failed'),
});
onSettled(name);
if (resolveRef.current) {
resolveRef.current();
resolveRef.current = null;
}
}, [result, name, onSettled]);
settle();
}, [result, settle]);

useEffect(() => {
if (!sseError) return;
// If the result already arrived, a subsequent stream error (e.g. the
// server closing the connection) is expected — don't clobber the result.
if (result || settledRef.current) return;
setStreamActive(false);
setStatus({ type: 'error', message: sseError });
onSettled(name);
if (resolveRef.current) {
resolveRef.current();
resolveRef.current = null;
}
}, [sseError, name, onSettled]);
settle();
}, [sseError, result, settle]);

const busy = starting || streamActive;

Expand Down
5 changes: 5 additions & 0 deletions server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ COPY --from=client-builder /app/client/dist ./client/dist

EXPOSE 5000

# Container health: hit the public /api/health endpoint. Node 22 ships a
# global fetch, so no extra tooling is needed.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||5000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["node", "src/index.js"]
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "diun-updater-server",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"engines": {
"node": ">=22"
Expand Down
41 changes: 41 additions & 0 deletions server/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,39 @@ export const authRouter = express.Router();

const SESSION_COOKIE = 'diun_session';

// --- Simple in-memory login rate limiting ---------------------------------
// Per-client-IP failed-attempt tracking with a lockout, to blunt brute-force
// of the single shared password. Not a substitute for keeping the app off the
// open internet, but a sane default for a tool that may be exposed. State is
// in-memory (resets on restart), which is fine for a single-instance app.
const MAX_FAILURES = 10; // failures allowed within the window before lockout
const FAILURE_WINDOW_MS = 15 * 60 * 1000; // rolling window for counting failures
const LOCKOUT_MS = 15 * 60 * 1000; // how long a lockout lasts once tripped

const loginAttempts = new Map(); // ip -> { count, firstAt, lockedUntil }

export function isLockedOut(ip, now = Date.now()) {
const a = loginAttempts.get(ip);
return Boolean(a && a.lockedUntil && a.lockedUntil > now);
}

export function recordLoginFailure(ip, now = Date.now()) {
let a = loginAttempts.get(ip);
if (!a || now - a.firstAt > FAILURE_WINDOW_MS) {
a = { count: 0, firstAt: now, lockedUntil: 0 };
}
a.count += 1;
if (a.count >= MAX_FAILURES) {
a.lockedUntil = now + LOCKOUT_MS;
}
loginAttempts.set(ip, a);
return a;
}

export function clearLoginFailures(ip) {
loginAttempts.delete(ip);
}

/**
* Constant-time comparison of the supplied password against
* `config.ADMIN_PASSWORD`. Guards against length mismatches
Expand Down Expand Up @@ -62,12 +95,20 @@ export function isValidSession(req) {
* success.
*/
export function loginHandler(req, res) {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';

if (isLockedOut(ip)) {
return res.status(429).json({ error: 'too_many_attempts' });
}

const password = req.body?.password;

if (!isValidPassword(password)) {
recordLoginFailure(ip);
return res.status(401).json({ error: 'invalid_password' });
}

clearLoginFailures(ip);
const expiry = String(Date.now() + config.SESSION_TTL * 1000);
res.cookie(SESSION_COOKIE, expiry, {
signed: true,
Expand Down
4 changes: 4 additions & 0 deletions server/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export const config = {
DIUN_WEBHOOK_TOKEN: process.env.DIUN_WEBHOOK_TOKEN || '',
SESSION_TTL: envInt('SESSION_TTL', 604800),
BASE_URL: process.env.BASE_URL || 'http://localhost:5000',
// Name of this app's own container, excluded from the dashboard so it
// can't try to update (and kill) itself. Defaults to the container_name
// used in the shipped docker-compose.yml; override if you rename it.
SELF_CONTAINER_NAME: process.env.SELF_CONTAINER_NAME || 'diun-updater',
};

/**
Expand Down
19 changes: 19 additions & 0 deletions server/src/docker.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,24 @@
*/

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import Docker from 'dockerode';
import { config } from './config.js';
import { normalizeRef } from './reconcile.js';

// Best-effort identity of this app's own container, so listContainers can
// exclude it (you can't safely update the updater from within itself). By
// default Docker sets a container's hostname to its short id.
const SELF_HOSTNAME = os.hostname();

function isSelfContainer(name, id) {
if (config.SELF_CONTAINER_NAME && name === config.SELF_CONTAINER_NAME) return true;
if (SELF_HOSTNAME && id && id.startsWith(SELF_HOSTNAME)) return true;
return false;
}

// Constructing the client does not connect to the daemon — it just sets
// up the socket path to dial on first request. Safe to do at import time.
const docker = new Docker({ socketPath: config.DOCKER_SOCKET });
Expand Down Expand Up @@ -237,6 +249,13 @@ export async function listContainers() {
const inspectData = await container.inspect();

const name = stripLeadingSlash(inspectData.Name);

// Never list our own container — offering to update it would recreate
// the container running this process mid-update.
if (isSelfContainer(name, summary.Id)) {
continue;
}

const image = inspectData.Config?.Image;
if (!image) {
console.warn(`docker.js: container ${name} has no Config.Image, skipping`);
Expand Down
26 changes: 24 additions & 2 deletions server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import express from 'express';
import cookieParser from 'cookie-parser';
import { config, assertRequiredConfig } from './config.js';
// Importing db creates the data dir + tables as a side effect on load.
import './db.js';
import db from './db.js';
import { webhookRouter } from './webhook.js';
import { authRouter, requireAuth } from './auth.js';
import { apiRouter } from './routes/api.js';
Expand All @@ -25,6 +25,7 @@ if (process.env.SKIP_CONFIG_CHECK !== '1') {
}

const app = express();
app.disable('x-powered-by');

app.use(express.json());
app.use(cookieParser(config.SESSION_SECRET));
Expand Down Expand Up @@ -66,6 +67,27 @@ if (clientDistExists) {
console.warn(`No client build found at ${clientDistDir} — skipping static file serving.`);
}

app.listen(config.PORT, () => {
const server = app.listen(config.PORT, () => {
console.log(`Diun Updater server listening at ${config.BASE_URL} (port ${config.PORT})`);
});

// Graceful shutdown: stop accepting connections and checkpoint/close SQLite
// so a `docker stop` doesn't leave the WAL or an in-flight write half-done.
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Received ${signal}, shutting down…`);
server.close(() => {
try {
db.close();
} catch {
// already closed / nothing to do
}
process.exit(0);
});
// Don't hang forever if a connection (e.g. an open SSE stream) won't close.
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
17 changes: 17 additions & 0 deletions server/src/sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export function finish(name, result) {
writeToSubscribers(session, evt);
for (const res of session.subscribers) {
try {
clearInterval(res.__sseKeepAlive);
res.end();
} catch {
// ignore — subscriber may already be gone
Expand Down Expand Up @@ -117,6 +118,9 @@ export function subscribe(name, res, req) {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
// Tell nginx (and similar) not to buffer the stream, or log lines would
// be held back until the response closed.
'X-Accel-Buffering': 'no',
});
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
Expand Down Expand Up @@ -147,7 +151,20 @@ export function subscribe(name, res, req) {

session.subscribers.add(res);

// Heartbeat so reverse proxies don't drop an idle connection during a long
// pull with sparse output. SSE comment lines (": ...") are ignored by the
// EventSource client, so they don't show up as log lines.
const keepAlive = setInterval(() => {
try {
res.write(': keepalive\n\n');
} catch {
// subscriber is gone; the close handler will clear this interval
}
}, 15_000);
res.__sseKeepAlive = keepAlive;

const cleanup = () => {
clearInterval(keepAlive);
session.subscribers.delete(res);
};
res.on('close', cleanup);
Expand Down
Loading
Loading