- delete public/js/notifications.js (521 LOC) - remove notifications/patterns/surge/chat/priority/bosscord/accountinfo/threads UI sections - remove 3 /api/notifications/* proxy routes from server.js - untrack settings-site backup files from git - ~926 LOC removed from settings-site
228 lines
7.3 KiB
JavaScript
228 lines
7.3 KiB
JavaScript
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}`);
|
|
});
|