require('dotenv').config({ path: '../.env' }); const express = require('express'); const session = require('express-session'); const cookieParser = require('cookie-parser'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const { doubleCsrf } = require('csrf-csrf'); const path = require('path'); const crypto = require('crypto'); // Mirror of safeEqual() in ../utils.js — duplicated here because the settings-site Docker build context excludes the parent dir. function safeEqual(a, b) { const ab = Buffer.from(String(a || ''), 'utf8'); const bb = Buffer.from(String(b || ''), 'utf8'); return ab.length === bb.length && crypto.timingSafeEqual(ab, bb); } const app = express(); const PORT = parseInt(process.env.SETTINGS_PORT) || 12752; const INTERNAL_URL = process.env.INTERNAL_API_URL || `http://127.0.0.1:${process.env.INTERNAL_API_PORT || 12753}/internal`; const SECRET = process.env.INTERNAL_API_SECRET; const ADMIN_PASSWORD = process.env.SETTINGS_ADMIN_PASSWORD; const ADMIN_PASSWORD_2 = process.env.SETTINGS_ADMIN_PASSWORD_2; const SESSION_SECRET = process.env.SESSION_SECRET; const IS_PROD = process.env.NODE_ENV === 'production'; if (!SESSION_SECRET) { console.error('[settings] FATAL: SESSION_SECRET env var is required (min 32 random bytes)'); process.exit(1); } if (!SECRET) { console.error('[settings] FATAL: INTERNAL_API_SECRET env var is required'); process.exit(1); } if (!ADMIN_PASSWORD) { console.error('[settings] FATAL: SETTINGS_ADMIN_PASSWORD env var is required'); process.exit(1); } // Single-hop reverse proxy (Caddy at /opt/caddy/Caddyfile on the rustdesk // droplet — not accessible from this box; assumed to set X-Forwarded-Proto // and X-Forwarded-For). Required so express-session marks the connection // as secure and rate limits key off the real client IP. app.set('trust proxy', 1); app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", 'https://fonts.googleapis.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'https:'], scriptSrc: ["'self'"], connectSrc: ["'self'"], frameAncestors: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], objectSrc: ["'none'"] } } })); app.use(express.json({ limit: '64kb' })); app.use(express.urlencoded({ extended: true, limit: '64kb' })); app.use(session({ secret: SESSION_SECRET, resave: false, // Required true: csrf-csrf binds its token signature to req.sessionID. With // `false`, the session cookie isn't sent until the session is modified, so // each pre-login request gets a fresh sessionID and CSRF validation always // fails. See the "audit" commit (33b1f27) which inadvertently flipped this. saveUninitialized: true, cookie: { httpOnly: true, secure: IS_PROD, sameSite: 'strict', maxAge: 8 * 60 * 60 * 1000 } })); app.use(cookieParser(SESSION_SECRET)); const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({ getSecret: () => SESSION_SECRET, getSessionIdentifier: (req) => req.sessionID || '', cookieName: IS_PROD ? '__Host-x-csrf-token' : 'x-csrf-token', cookieOptions: { sameSite: 'strict', secure: IS_PROD, httpOnly: true, path: '/' }, getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'] }); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many login attempts, please try again later.' } }); const apiLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, please try again later.' } }); function requireAuth(req, res, next) { if (req.session?.authed) return next(); res.redirect('/login'); } async function callBot(method, apiPath, body) { const res = await fetch(`${INTERNAL_URL}${apiPath}`, { method, headers: { 'Content-Type': 'application/json', 'x-internal-secret': SECRET }, body: body ? JSON.stringify(body) : undefined }); const text = await res.text(); try { return JSON.parse(text); } catch { return { error: 'bad_upstream', status: res.status, body: text.slice(0, 500) }; } } function proxy(method, botPath) { return async (req, res) => { try { const data = await callBot(method, botPath, method === 'POST' ? req.body : undefined); res.json(data); } catch (e) { res.status(502).json({ error: 'Bot unreachable' }); } }; } async function pingBot() { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); try { const r = await fetch(`${INTERNAL_URL}/config`, { method: 'GET', headers: { 'x-internal-secret': SECRET }, signal: controller.signal }); return r.ok; } catch (_) { return false; } finally { clearTimeout(timer); } } app.use(express.static(path.join(__dirname, 'public'), { index: false })); app.get('/healthz', async (req, res) => { const bot = await pingBot(); res.json({ ok: true, bot }); }); app.get('/api/csrf-token', (req, res) => { const csrfToken = generateCsrfToken(req, res); res.json({ csrfToken }); }); app.use(doubleCsrfProtection); app.get('/login', (req, res) => { if (req.session?.authed) return res.redirect('/'); res.sendFile(path.join(__dirname, 'public', 'login.html')); }); app.post('/login', loginLimiter, (req, res) => { const matchesPrimary = safeEqual(req.body.password, ADMIN_PASSWORD); const matchesSecondary = ADMIN_PASSWORD_2 && safeEqual(req.body.password, ADMIN_PASSWORD_2); if (matchesPrimary || matchesSecondary) { req.session.authed = true; return res.json({ ok: true }); } res.status(401).json({ error: 'Invalid password' }); }); app.post('/logout', (req, res) => { req.session.destroy(() => res.json({ ok: true })); }); app.get('/', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/api/config', apiLimiter, requireAuth, proxy('GET', '/config')); app.post('/api/config', apiLimiter, requireAuth, proxy('POST', '/config')); app.get('/api/discord/guild', apiLimiter, requireAuth, proxy('GET', '/discord/guild')); app.post('/api/restart', apiLimiter, requireAuth, proxy('POST', '/restart')); app.get('/api/restart/status', apiLimiter, requireAuth, proxy('GET', '/restart/status')); app.get('/*splat', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.use((err, req, res, next) => { if (err && (err.code === 'EBADCSRFTOKEN' || err.code === 'ERR_BAD_CSRF_TOKEN')) { return res.status(403).json({ error: 'Invalid CSRF token' }); } next(err); }); // Default bind is loopback. Production runs in Docker with // docker-compose.yml publishing `100.114.205.53:12752:12752` — that host-side // publish is what restricts ingress to the Tailscale IP, and the container // sets SETTINGS_BIND_HOST=0.0.0.0 so Docker's DNAT can reach Node. Caddy lives // on a separate host (rustdesk droplet) and reaches this box over Tailscale, // so 127.0.0.1 is not reachable from Caddy in prod; do not change that // default without updating docker-compose.yml in lockstep. const BIND_HOST = process.env.SETTINGS_BIND_HOST || '127.0.0.1'; app.listen(PORT, BIND_HOST, () => { console.log(`[settings] running on ${BIND_HOST}:${PORT}`); });