admin site version 2

This commit is contained in:
Harlekin committed 2026-10-25 20:46:01 +01:00
1 parent b84349c037
commit bef622b61f
8 files changed
+70 -50

No files matched your search

+2 -4
View File
@@ -8,8 +8,6 @@ PROXY_NETWORK=nginx_default
# weil kein Port auf dem Host freigegeben wird. Nur ändern, wenn es wirklich nötig ist.
# PORT=3000
# Passwort für die Admin-Seiten. Ohne Passwort bleiben sie aus.
# Passwort für die Admin-Seiten. Ohne Passwort bleiben sie aus, unter 12 Zeichen auch.
# Der Login ist aus dem Internet erreichbar, also lieber lang und zufällig (z.B. openssl rand -base64 24).
ADMIN_PASSWORD=
# Port auf dem Server, unter dem die Admin-Seiten im Heimnetz erreichbar sind
# ADMIN_HOST_PORT=3051
+10 -5
View File
@@ -1,6 +1,6 @@
# lux.harlelux.de
Meine persönliche Website mit Portfolio. Node.js und Express, die Seiten werden mit Nunjucks auf dem Server gerendert. Inhalte liegen als YAML und Markdown auf der Platte, gepflegt werden sie über ein paar Admin-Seiten, die nur im Heimnetz erreichbar sind.
Meine persönliche Website mit Portfolio. Node.js und Express, die Seiten werden mit Nunjucks auf dem Server gerendert. Inhalte liegen als YAML und Markdown auf der Platte, gepflegt werden sie über ein paar Admin-Seiten mit Passwort.
## Entwicklung
@@ -36,13 +36,13 @@ Die Dateien lassen sich auch von Hand bearbeiten, die Seite lädt sie bei Änder
## Admin-Seiten
Laufen im selben Container auf einem eigenen Port (im Container 3001, auf dem Server `ADMIN_HOST_PORT`, Standard 3051). Über `lux.harlelux.de` gibt es kein `/admin`. Der Port wird von der FRITZ!Box nicht freigegeben, ist also nur im Heimnetz erreichbar:
Laufen im selben Container auf einem eigenen Port (3001) und bekommen im Nginx Proxy Manager eine eigene Subdomain, z.B.:
```
http://<server-ip>:3051/admin
https://admin.harlelux.de/admin
```
Zusätzlich lässt die App dort nur private Adressen durch und verlangt das Passwort aus `ADMIN_PASSWORD`. Nach zehn Fehlversuchen ist der Login eine Viertelstunde gesperrt.
Über `lux.harlelux.de` gibt es kein `/admin`. Der Login verlangt das Passwort aus `ADMIN_PASSWORD`, in Produktion mindestens 12 Zeichen, sonst bleiben die Admin-Seiten aus. Nach zehn Fehlversuchen ist der Login für diese Adresse eine Viertelstunde gesperrt (IPv6 zählt pro /64). Die App sieht dafür die echte Adresse aus `X-Forwarded-For`, das setzt der Proxy Manager von selbst.
Unter derselben Adresse zeigt `/de/` die ganze Seite mit Entwürfen, als Vorschau.
@@ -61,7 +61,12 @@ cp .env.example .env # ADMIN_PASSWORD eintragen
docker compose up -d --build
```
Im Nginx Proxy Manager einen Proxy Host für `lux.harlelux.de` auf `personal-website:3000` anlegen, mit Let's-Encrypt-Zertifikat und Force SSL. Der Container hängt im Netzwerk `nginx_default`, ein anderer Name lässt sich über `PROXY_NETWORK` in der `.env` setzen.
Im Nginx Proxy Manager zwei Proxy Hosts anlegen, beide mit Let's-Encrypt-Zertifikat, Force SSL und HSTS:
- `lux.harlelux.de` auf `personal-website:3000`
- die Admin-Subdomain (z.B. `admin.harlelux.de`, braucht einen DNS-Eintrag) auf `personal-website:3001`
Der Container hängt im Netzwerk `nginx_default`, ein anderer Name lässt sich über `PROXY_NETWORK` in der `.env` setzen. Freigegebene Ports auf dem Server braucht er keine.
Code-Updates: `git pull` und `docker compose up -d --build`. Wer statt Git mit `rsync` hochlädt, muss `data` und `.env` ausschließen, sonst sind die Inhalte weg.
+1 -5
View File
@@ -9,10 +9,6 @@ services:
required: false
environment:
SITE_URL: ${SITE_URL:-https://lux.harlelux.de}
ports:
# Admin-Seiten. Die FRITZ!Box gibt diesen Port nicht nach außen frei,
# er ist also nur im Heimnetz erreichbar: http://<server-ip>:3051/admin
- "${ADMIN_HOST_PORT:-3051}:3001"
volumes:
# Startinhalte aus dem Repo
- ./content:/app/content:ro
@@ -31,7 +27,7 @@ services:
networks:
# Netzwerk des Reverse Proxy, muss schon existieren (Nginx Proxy Manager: nginx_default).
# Im Proxy Host dann personal-website:3000 als Ziel eintragen.
# Ziele für die Proxy Hosts: personal-website:3000 (Seite) und personal-website:3001 (Admin).
proxy:
external: true
name: ${PROXY_NETWORK:-nginx_default}
+22 -18
View File
@@ -11,20 +11,16 @@ export function passwordMatches(input, expected) {
return Boolean(expected) && timingSafeEqual(digest(input), digest(expected));
}
export function isPrivateAddress(address = '') {
const ip = address.replace(/^::ffff:/, '');
if (ip === '::1' || ip.startsWith('127.')) return true;
if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true;
const docker = /^172\.(\d+)\./.exec(ip);
if (docker && Number(docker[1]) >= 16 && Number(docker[1]) <= 31) return true;
// fc00::/7 (unique local) and fe80::/10 (link local)
return /^f[cd][0-9a-f]{0,2}:/i.test(ip) || /^fe[89ab][0-9a-f]?:/i.test(ip);
}
// Second line of defence next to the port not being forwarded by the router
export function homeNetworkOnly(req, res, next) {
if (isPrivateAddress(req.socket.remoteAddress)) return next();
res.status(403).type('text').send('Nur im Heimnetz erreichbar.');
// Failed logins are counted per client. IPv6 clients usually get a whole /64,
// so they are counted per prefix, otherwise a new address would mean new attempts.
export function clientKey(ip = '') {
const address = ip.replace(/^::ffff:/, '');
if (!address.includes(':')) return address;
const [head, tail = ''] = address.split('::');
const left = head ? head.split(':') : [];
const right = tail ? tail.split(':') : [];
const groups = [...left, ...Array(Math.max(0, 8 - left.length - right.length)).fill('0'), ...right];
return `${groups.slice(0, 4).map((group) => parseInt(group, 16).toString(16)).join(':')}::/64`;
}
function readCookie(req, name) {
@@ -40,6 +36,13 @@ export function createAuth({ password }) {
const sessions = new Map();
const failures = new Map();
// Forget expired entries, so neither map keeps growing
setInterval(() => {
const now = Date.now();
for (const [key, entry] of failures) if (entry.until < now) failures.delete(key);
for (const [token, session] of sessions) if (session.expires < now) sessions.delete(token);
}, failureWindow).unref();
function current(req) {
const token = readCookie(req, cookieName);
const session = token ? sessions.get(token) : null;
@@ -52,18 +55,19 @@ export function createAuth({ password }) {
}
function login(req, res, input) {
const ip = req.socket.remoteAddress;
// req.ip is the address of the visitor, not the reverse proxy (see "trust proxy" in app.js)
const key = clientKey(req.ip);
const now = Date.now();
const entry = failures.get(ip);
const entry = failures.get(key);
if (entry && entry.until > now && entry.count >= maxFailures) return 'blocked';
if (!passwordMatches(input, password)) {
if (!entry || entry.until < now) failures.set(ip, { count: 1, until: now + failureWindow });
if (!entry || entry.until < now) failures.set(key, { count: 1, until: now + failureWindow });
else entry.count++;
return 'wrong';
}
failures.delete(ip);
failures.delete(key);
const token = randomBytes(32).toString('base64url');
sessions.set(token, { token, expires: now + sessionTtl, csrf: randomBytes(24).toString('base64url') });
res.cookie(cookieName, token, {
+5 -5
View File
@@ -4,7 +4,6 @@ import helmet from 'helmet';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import nunjucks from 'nunjucks';
import { homeNetworkOnly } from './admin/auth.js';
import { adminRouter } from './admin/routes.js';
import { loadConfig, rootDir } from './config.js';
import { createContentStore } from './content.js';
@@ -123,15 +122,16 @@ export async function createApp(options = {}) {
return app;
}
// Admin pages plus the whole site with drafts, on a separate port for the home network.
// Admin pages plus the whole site with drafts, on a separate port that gets its own host in the reverse proxy.
// `content` must be a store that includes drafts, `stores` are reloaded after every change.
export async function createAdminApp({ config, content, stores = [content] }) {
if (!content.get()) await content.reload();
const app = express();
app.use(homeNetworkOnly);
// Plain HTTP inside the LAN, so no upgrade to https here
setupCommon(app, config, { upgradeInsecureRequests: false });
// Requests come through the reverse proxy in the Docker network. Trusting it gives the
// visitor's address in req.ip (for the login lockout) and https in req.secure (secure cookie).
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
setupCommon(app, config, { upgradeInsecureRequests: config.production });
app.get('/', (req, res) => res.redirect('/admin'));
app.use(adminRouter({ config, content, stores }));
mountSite(app, { config: { ...config, showDrafts: true }, content });
+1 -1
View File
@@ -16,7 +16,7 @@ export function loadConfig(overrides = {}) {
dataDir: path.resolve(rootDir, env.DATA_DIR || 'data'),
cacheDir: path.resolve(rootDir, env.CACHE_DIR || 'cache'),
showDrafts: !production,
// The admin pages run on their own port, which is only reachable from the home network
// The admin pages run on their own port, the reverse proxy gives them their own host
adminPort: Number(env.ADMIN_PORT) || 3001,
adminPassword: env.ADMIN_PASSWORD || '',
...overrides,
+4
View File
@@ -6,6 +6,7 @@ import { createContentStore } from './content.js';
import { seedData } from './data.js';
const config = loadConfig();
const minPasswordLength = 12;
let dataWritable = true;
try {
@@ -35,6 +36,9 @@ const servers = [
const drafts = createContentStore({ ...config, showDrafts: true });
if (!config.adminPassword) {
console.log('[admin] ADMIN_PASSWORD is not set, admin pages are off');
} else if (config.production && config.adminPassword.length < minPasswordLength) {
// The login is reachable from the internet, a short password could be guessed
console.error(`[admin] ADMIN_PASSWORD is shorter than ${minPasswordLength} characters, admin pages are off`);
} else if (dataWritable) {
await drafts.reload();
drafts.watch();
+25 -12
View File
@@ -6,7 +6,7 @@ import path from 'node:path';
import { after, before, test } from 'node:test';
import sharp from 'sharp';
import YAML from 'yaml';
import { isPrivateAddress } from '../src/admin/auth.js';
import { clientKey } from '../src/admin/auth.js';
import { createAdminApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import { createContentStore } from '../src/content.js';
@@ -229,19 +229,32 @@ test('journey, profile and homelab', async () => {
assert.equal(publicStore.get().pages.homelab.de.lead, 'Was läuft');
});
test('home network check', () => {
for (const ip of ['127.0.0.1', '::1', '192.168.178.20', '10.0.0.5', '172.18.0.1', '::ffff:192.168.178.20', 'fd00::1', 'fe80::1']) {
assert.ok(isPrivateAddress(ip), ip);
}
for (const ip of ['79.221.147.164', '172.32.0.1', '2003:f6:8f30:d900::1', '8.8.8.8']) {
assert.ok(!isPrivateAddress(ip), ip);
}
test('clients are counted by address, IPv6 by /64', () => {
assert.equal(clientKey('79.221.147.164'), '79.221.147.164');
assert.equal(clientKey('::ffff:192.168.178.20'), '192.168.178.20');
assert.equal(clientKey('2003:f6:8f30:d900::1'), '2003:f6:8f30:d900::/64');
assert.equal(clientKey('2003:00f6:8f30:d900:1234:5678:9abc:def0'), '2003:f6:8f30:d900::/64');
assert.equal(clientKey('2001:db8::1'), '2001:db8:0:0::/64');
assert.equal(clientKey('::1'), '0:0:0:0::/64');
});
test('too many wrong passwords lock the login', async () => {
test('too many wrong passwords lock the login, per client behind the proxy', async () => {
const login = (ip, password) =>
request('/admin/login', {
method: 'POST',
headers: { 'x-forwarded-for': ip, 'x-forwarded-proto': 'https' },
body: new URLSearchParams({ password }),
});
let res;
for (let i = 0; i < 11; i++) {
res = await request('/admin/login', { method: 'POST', body: new URLSearchParams({ password: `wrong ${i}` }) });
}
for (let i = 0; i < 11; i++) res = await login('203.0.113.7', `wrong ${i}`);
assert.equal(res.status, 429);
assert.equal((await login('203.0.113.7', 'correct horse')).status, 429, 'even with the right password');
res = await login('198.51.100.1', 'correct horse');
assert.equal(res.status, 303, 'other visitors can still log in');
assert.match(res.headers.get('set-cookie'), /Secure/i, 'over https the cookie is secure');
for (let i = 0; i < 10; i++) await login(`2001:db8:1:2::${i + 1}`, 'nope');
assert.equal((await login('2001:db8:1:2::ff', 'nope')).status, 429, 'a new address in the same /64 does not help');
assert.equal((await login('2001:db8:1:3::1', 'nope')).status, 401);
});