Files
proxy/nodejs/routes/host.js
T
wmantly 6092468901 Add per-host reverse-proxy controls (rate limit, cache, headers, IP ACL)
Every proxied request flows through one shared OpenResty location whose
behavior is chosen at request time from the host's Redis hash. Add per-host
controls as new Host fields enforced in Lua rather than static nginx config
(which can't key off a per-request variable):

- Rate limiting: per-client-IP token bucket via resty.limit.req
  (ratelimit_enabled/rate/burst), backed by a new `ratelimit` shared dict.
- Response caching: opt-in per host via a global proxy_cache zone gated by
  $skip_cache (respcache_enabled). Off by default; upstream Cache-Control
  still honored.
- Custom/security headers: req_headers (upstream) + resp_headers (client) and
  hsts_enabled, applied in access/header_filter phases.
- IP allow/deny CIDR lists via resty.ipmatcher (deny wins; non-empty allow is
  default-deny).

New ops/nginx_conf/hostfeatures.lua holds the enforcement; proxy.conf's
access_by_lua string becomes a block that calls it, plus a header_filter block.
nodejs/utils/host_features.js is the pure, unit-tested normalize/validate layer
(header/CIDR parsing, range clamping, injection-safe values) applied in
routes/host.js and mirrored by the hosts.ejs edit form. install.sh gains the
ipmatcher rock, the cache dir, and the hostfeatures.lua symlink.

Per-host cache TTL is intentionally deferred (global default only) — see the
plan's limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:10:55 -04:00

137 lines
3.2 KiB
JavaScript
Executable File

'use strict';
const router = require('express').Router();
const {Host, Domain} = require('../models').models;
const authz = require('../middleware/authz');
const {normalizeHostFeatures} = require('../utils/host_features');
const Model = Host;
router.get('/', async function(req, res, next){
try{
let results = await Model[req.query.detail ? "listDetail" : "list"]();
// Restrict to hosts whose domain the caller may view. list() yields host
// strings; listDetail() yields instances with a .host.
results = await authz.filterViewable(req, results,
item => (typeof item === 'string' ? item : item.host));
return res.json({results});
}catch(error){
return next(error);
}
});
router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), async function(req, res, next){
try{
req.body.created_by = authz.reqUsername(req);
normalizeHostFeatures(req.body);
let item = await Model.create(req.body);
return res.json({
message: `"${item[Model._key]}" added.`,
...item,
});
} catch (error){
next(error);
}
});
router.get('/lookup/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){
try{
return res.json({
string: req.params.item,
results: await Model.lookUp(req.params.item),
});
}catch(error){
return next(error);
}
});
// The full lookup tree exposes every host, so restrict it to admins.
router.get('/lookupobj', authz.requireAdmin, async function(req, res, next){
try{
return res.json({
results: Model.lookUpObj,
});
}catch(error){
return next(error);
}
});
router.delete('/cache', authz.requireAdmin, async function(req, res, next){
try{
let count = await Model.clearCache();
return res.json({
message: `Cleared ${count} cached host${count === 1 ? '' : 's'}.`,
count,
});
}catch(error){
return next(error);
}
});
router.get('/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){
try{
return res.json({
item: req.params.item,
results: await Model.get(req.params.item)
});
}catch(error){
return next(error);
}
});
router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
try{
req.body.updated_by = authz.reqUsername(req);
normalizeHostFeatures(req.body);
let item = await Model.get(req.params.item);
item = await item.update(req.body);
return res.json({
message: `"${req.params.item}" updated.`,
__requestedHost: req.params.item,
...item,
});
}catch(error){
return next(error);
}
});
router.delete('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
try{
let item = await Model.get(req.params.item);
let count = await item.remove();
return res.json({
message: `${req.params.item} deleted`,
...item,
});
}catch(error){
return next(error);
}
});
router.put('/:item/renew', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
try{
let item = await Model.get(req.params.item);
item.createWildcardCert();
return res.json({
message: `Requesting wildcard cert for ${req.params.item}`,
})
}catch(error){
next(error);
}
});
module.exports = router;