Adds a layered set of protections around the admin login and the app edge.
Trust proxy (server/src/utils/trustProxy.js)
- Configurable via TRUST_PROXY; pin to the newt agent ("ptero") LAN IP so
X-Forwarded-For is trusted ONLY from that peer. A blanket "true" is
rejected (coerced to 1) to prevent XFF spoofing that would dodge every
IP-based control. DEBUG_TRUST_PROXY logs peer/XFF/req.ip to re-verify the
proxy IP without a redeploy. Documents the Omada static-reservation
assumption.
Login throttling (server/src/middleware/loginProtection.js, rateLimit.js)
- express-slow-down progressive delay + the existing hard rate cap + a
separate per-IP exponential backoff that persists across the rate window.
All failures return one generic message (no user/pass disclosure).
Honeypot (login form + auth.controller)
- Hidden, plausibly-named field ("company"); a filled value fails
generically and is scored as an unambiguous bot.
Optional per-user TOTP 2FA (speakeasy/qrcode)
- totp_secret/totp_enabled columns (+ idempotent migration). Self-service
Account page: enroll via QR, confirm a code to enable, code-gated disable.
- Login is two-step for enrolled users: after the password, a short-lived
signed challenge (stage:'totp', not a session) is required before the
real session is issued.
Bot / scanner scoring + IP ban (server/src/middleware/botScore.js)
- Weighted CMS-scanner paths (this app uses none). Junk paths 404 FIRST,
unconditionally — independent of score/ban state, so a scanner rotating
through fresh Cloudflare IPs gets no free pass. /wp-admin/install.php is
the top-weighted near-1-hit ban (worst offender in prod logs). Per-IP
score with quiet-period decay temp-bans an IP from ALL routes once past a
(deliberately low) threshold, to protect /admin from credential stuffing.
Failed logins and honeypot hits feed the same score.
- Periodic sweep evicts stale, unbanned, quiet entries so the in-memory
store can't grow unbounded; the interval is unref'd and cleared on
graceful shutdown.
Tests: node --test suite (40) covering trust-proxy parsing + live req.ip
(incl. pinned-IP), rate limiter + exponential backoff, honeypot rejection,
TOTP verify (enabled/disabled) + challenge-isn't-a-session, bot-score
threshold/decay/ban + junk-404-independence + install.php + store sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
2.5 KiB
JavaScript
76 lines
2.5 KiB
JavaScript
require('dotenv').config()
|
|
const http = require('http')
|
|
|
|
const app = require('./app')
|
|
const botScore = require('./middleware/botScore')
|
|
const { ensureSchema, close } = require('./utils/db')
|
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
|
const settings = require('./model/settings/settings.model')
|
|
const mailer = require('./utils/mailer')
|
|
const createLogger = require('./utils/logger')
|
|
const pkg = require('../package.json')
|
|
|
|
const log = createLogger('server')
|
|
const PORT = Number(process.env.PORT) || 3000
|
|
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
|
|
|
|
async function start() {
|
|
log.info(`starting UOMysticmoon server v${pkg.version}`, {
|
|
node: process.version,
|
|
env: process.env.NODE_ENV || 'development',
|
|
logLevel: process.env.LOG_LEVEL || 'info',
|
|
logFile: createLogger.logFilePath || 'disabled (console only)',
|
|
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'uomysticmoon'}`,
|
|
cookieSecure: process.env.COOKIE_SECURE || 'auto',
|
|
smtp: mailer.isConfigured() ? 'configured' : 'not configured (mailto fallback)',
|
|
})
|
|
|
|
log.info('ensuring database schema...')
|
|
await ensureSchema()
|
|
log.info('seeding defaults...')
|
|
await seedDefaults()
|
|
await createInitialAdminFromEnv()
|
|
|
|
const mode = await settings.get('site_mode')
|
|
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
|
|
|
const server = http.createServer(app)
|
|
server.listen(PORT, HOST, () => {
|
|
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
|
|
})
|
|
|
|
setupShutdown(server)
|
|
}
|
|
|
|
function setupShutdown(server) {
|
|
let closing = false
|
|
const shutdown = async (signal) => {
|
|
if (closing) return
|
|
closing = true
|
|
log.warn(`${signal} received — shutting down gracefully`)
|
|
botScore.stopSweeper() // stop the bot-store cleanup interval
|
|
server.close(() => log.info('http server closed'))
|
|
try {
|
|
await close()
|
|
log.info('database pool closed')
|
|
} catch (err) {
|
|
log.error('error closing database pool', err)
|
|
}
|
|
await createLogger.close() // flush the log file
|
|
process.exit(0)
|
|
}
|
|
|
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
|
|
process.on('uncaughtException', (err) => {
|
|
log.error('uncaughtException', err)
|
|
process.exit(1)
|
|
})
|
|
}
|
|
|
|
start().catch((err) => {
|
|
log.error('failed to start server', err)
|
|
process.exit(1)
|
|
})
|