Files
broccolini-bot/settings-site/server.js

203 lines
6.0 KiB
JavaScript

require('dotenv').config({ path: process.env.ENV_FILE || '../.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 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 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,
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
});
return res.json();
}
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) => {
if (req.body.password === ADMIN_PASSWORD) {
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('/api/notifications/alerts', apiLimiter, requireAuth, proxy('GET', '/notifications/alerts'));
app.get('/api/notifications/state', apiLimiter, requireAuth, proxy('GET', '/notifications/state'));
app.post('/api/notifications/toggle', apiLimiter, requireAuth, proxy('POST', '/notifications/toggle'));
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);
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`[settings] running on port ${PORT}`);
});