Merge pull request #116 from theta42/feat/issues-48-57

Fix user creation & password (#48), per-host auth incl. SSO (#57), scheduler (#69) + host modal
This commit is contained in:
2026-07-11 20:41:41 -04:00
committed by GitHub
28 changed files with 1287 additions and 505 deletions
+6 -1
View File
@@ -60,10 +60,15 @@ app.use(express.json());
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to
// the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the
// per-host session cookie. Mounted before the page router.
app.use('/__proxy_auth', require('./routes/host_auth'));
// Routes for front end content.
app.use('/', require('./routes/render'));
// Routes for API
// Routes for API
app.use('/api', require('./routes/api'));
// Catch 404 and forward to error handler. If none of the above routes are
+9
View File
@@ -77,4 +77,13 @@ module.exports = {
'https://ifconfig.me/ip',
],
},
// Per-host SSO (#57). Reuses conf.oidc for the identity provider. Sessions
// are Redis-backed and read directly by OpenResty; the cookie only carries a
// random session id.
hostSso:{
enabled: true,
sessionTtl: 28800, // 8 hours, in seconds
cookieName: '__proxy_sso',
},
};
+22
View File
@@ -149,6 +149,28 @@ class DnsProvider extends Table{
return out;
}
// Re-sync every configured provider's domain list from its API. Mirrors the
// manual /dns/domain/refresh/:item route (get -> updateDomains) across all
// providers; used by the host scheduler. Never throws — one bad provider
// (e.g. a revoked key) must not abort the rest.
static async refreshAllDomains(){
let ids;
try{
ids = await this.list();
}catch(error){
console.error('refreshAllDomains: could not list providers', error.message);
return;
}
for(let id of ids){
try{
let provider = await this.get(id);
await provider.updateDomains();
}catch(error){
console.error('refreshAllDomains: provider', id, error.message);
}
}
}
get api(){
return new this.constructor.Provider(this);
}
+12
View File
@@ -41,6 +41,18 @@ class Host extends Table{
'ratelimit_burst': {default: 20, isRequired: false, type: 'number', min: 0, max: 1000000},
'respcache_enabled': {default: false, isRequired: false, type: 'boolean',},
'hsts_enabled': {default: false, isRequired: false, type: 'boolean',},
// Per-host HTTP basic auth. basicauth_users is {username: base64(sha1(pw))}
// (hashed at the route layer, see utils/basicauth.js); enforced in
// ops/nginx_conf/hostfeatures.lua.
'basicauth_enabled': {default: false, isRequired: false, type: 'boolean',},
'basicauth_realm': {default: 'Restricted', isRequired: false, type: 'string', min: 1, max: 128},
'basicauth_users': {default: function(){return {}}, isRequired: false, type: 'object',},
// Per-host SSO (OIDC via conf.oidc) — enforced by a signed session cookie
// checked in ops/nginx_conf/hostfeatures.lua. Empty allow-lists mean "any
// authenticated user". basic auth and SSO are OR'd (either satisfies).
'sso_enabled': {default: false, isRequired: false, type: 'boolean',},
'sso_allow_users': {default: function(){return []}, isRequired: false, type: 'object',},
'sso_allow_groups': {default: function(){return []}, isRequired: false, type: 'object',},
'req_headers': {default: function(){return {}}, isRequired: false, type: 'object',},
'resp_headers': {default: function(){return {}}, isRequired: false, type: 'object',},
'ip_allow': {default: function(){return []}, isRequired: false, type: 'object',},
+1
View File
@@ -14,3 +14,4 @@ require('./user');
require('./local_group');
require('./permission');
require('./oidc_state');
require('./sso_session');
+52
View File
@@ -0,0 +1,52 @@
'use strict';
const Table = require('.');
const conf = require('@simpleworkjs/conf');
/**
* Per-host SSO models (#57).
*
* HostSsoState — short-lived, in-flight OIDC authorization request for a
* protected host (like OidcState, but carries the target host + post-login
* redirect). Auto-expires via TTL.
*
* SsoSession — an established session after a successful, authorized login.
* Keyed by a random session id stored in the browser's `__proxy_sso` cookie.
* OpenResty (ops/nginx_conf/hostfeatures.lua) reads `proxy_SsoSession_<sid>`
* straight from Redis to gate requests; the allow-list was already enforced at
* callback time (utils/host_sso.js), so the Lua side only checks that a session
* exists and belongs to this host. Auto-expires via TTL.
*/
const SESSION_TTL = (conf.hostSso && conf.hostSso.sessionTtl) || 28800; // 8h
class HostSsoState extends Table{
static _key = 'state';
static _ttl = 300; // 5 minutes bounds the auth round-trip / replay
static _keyMap = {
'created_on': {default: function(){return (new Date).getTime()}},
'state': {isRequired: true, type: 'string', min: 8, max: 500},
'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500},
'host': {isRequired: true, type: 'string', min: 1, max: 500},
'rd': {default: '/', isRequired: false, type: 'string'},
}
}
HostSsoState.register();
class SsoSession extends Table{
static _key = 'sid';
static _ttl = SESSION_TTL;
static _keyMap = {
'created_on': {default: function(){return (new Date).getTime()}},
'sid': {isRequired: true, type: 'string', min: 16, max: 500},
'host': {isRequired: true, type: 'string', min: 1, max: 500},
'sub': {isRequired: true, type: 'string', min: 1, max: 500},
'email': {default: '', isRequired: false, type: 'string'},
'groups': {default: function(){return []}, isRequired: false, type: 'object'},
}
static ttl(){ return SESSION_TTL; }
}
SsoSession.register();
module.exports = {HostSsoState, SsoSession};
+1 -24
View File
@@ -61,27 +61,4 @@ class AuthToken extends Token{
}
AuthToken.register();
class InviteToken extends Token{
static _keyMap = {
...super._keyMap,
claimed_by: {default:"__NONE__", isRequired: false, type: 'string',},
}
async consume(data){
try{
if(this.is_valid){
data['is_valid'] = false;
await this.update(data);
return true;
}
return false;
}catch(error){
throw error;
}
}
}
InviteToken.register();
module.exports = {Token, InviteToken, AuthToken};
module.exports = {Token, AuthToken};
+1 -12
View File
@@ -1,7 +1,7 @@
'use strict';
const { Client, Attribute, Change } = require('ldapts');
const {Token, InviteToken} = require('./token');
const {Token} = require('./token');
const conf = require('@simpleworkjs/conf').ldap;
// tlsOptions is optional and forwarded to ldapts so the proxy can bind to
@@ -146,17 +146,6 @@ User.exists = async function(data){
}
};
User.invite = async function(){
try{
let token = await InviteToken.add({created_by: this.username});
return token;
}catch(error){
throw error;
}
};
User.login = async function(data){
try{
let user = await this.get(data.username);
+1 -37
View File
@@ -2,7 +2,7 @@
const linuxUser = require('linux-sys-user').promise();
const objValidate = require('../utils/object_validate');
const {Token, InviteToken} = require('./token');
const {Token} = require('./token');
const {promisify} = require('util');
const pam = require('authenticate-pam');
const authenticate = promisify(pam.authenticate);
@@ -90,31 +90,6 @@ User.create = async function(data) {
}
};
User.addByInvite = async function(data){
try{
let token = await InviteToken.get(data.token);
if(!token.is_valid){
let error = new Error('Token Invalid');
error.name = 'Token Invalid';
error.message = `Token is not valid or as allready been used. ${data.token}`;
error.status = 401;
throw error;
}
let user = await this.add(data);
if(user){
await token.consume({claimed_by: user.username});
return user;
}
}catch(error){
throw error;
}
};
User.remove = async function(data){
try{
return await linuxUser.removeUser(this.username);
@@ -133,17 +108,6 @@ User.setPassword = async function(data){
}
};
User.invite = async function(){
try{
let token = await InviteToken.add({created_by: this.username});
return token;
}catch(error){
throw error;
}
};
User.login = async function(data){
try{
let auth = await authenticate(data.username, data.password);
+3 -3
View File
@@ -11,10 +11,10 @@
"scripts": {
"start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www",
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js",
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js",
"test:integration": "node --test test/integration/dns_provider.test.js",
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
},
"engines": {
"node": ">=18.0.0"
+15 -3
View File
@@ -176,10 +176,22 @@
}
},
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
// or at least 3 of {lowercase, uppercase, number, symbol}.
password: function( value ) {
var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/;
if ( reg.test( value ) === false ) {
return "Weak password, Try again";
if ( typeof value !== 'string' || value.length < 8 ) {
return "Password must be at least 8 characters";
}
if ( value.length >= 12 ) return;
var classes = 0;
if ( /[a-z]/.test( value ) ) classes++;
if ( /[A-Z]/.test( value ) ) classes++;
if ( /[0-9]/.test( value ) ) classes++;
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
if ( classes < 3 ) {
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
}
}
}
+41 -1
View File
@@ -1,10 +1,14 @@
'use strict';
const router = require('express').Router();
const {Host, Domain} = require('../models').models;
const conf = require('@simpleworkjs/conf');
const {Host, Domain, User} = require('../models').models;
const {LocalGroup} = require('../models/local_group');
const {Permission} = require('../models/permission');
const authz = require('../middleware/authz');
const {normalizeHostFeatures} = require('../utils/host_features');
const {collectHostFieldErrors} = require('../utils/hostname_validate');
const {hashBasicAuthUsers} = require('../utils/basicauth');
const Model = Host;
@@ -15,6 +19,40 @@ function validateHostFields(body){
if(errors.length) throw Model.errors.ObjectValidateError(errors);
}
// After normalizeHostFeatures has parsed basic-auth creds to {user: plaintext},
// hash them so plaintext never reaches Redis. Runs at the route layer only, so
// internally-copied records (cache/wildcard children) keep their existing hashes.
function hashHostSecrets(body){
if(body.basicauth_users && typeof body.basicauth_users === 'object'){
body.basicauth_users = hashBasicAuthUsers(body.basicauth_users);
}
}
// Autocomplete source for the per-host auth allow-lists (SSO users/groups).
// Available to any authenticated host editor (not just global admins). Groups
// are derived from local groups, existing permission group-subjects, and the
// conf.auth admin/role-map groups.
router.get('/auth-suggestions', async function(req, res, next){
try{
let users = [];
try{ users = (await User.list()) || []; }catch(error){ /* none */ }
let groups = new Set();
try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ }
try{
for(let p of await Permission.listDetail()){
if(p.subjectType === 'group' && p.subject) groups.add(p.subject);
}
}catch(error){ /* none */ }
for(let g of (conf.auth && conf.auth.adminGroups) || []) groups.add(g);
for(let g of Object.keys((conf.auth && conf.auth.groupRoleMap) || {})) groups.add(g);
return res.json({users, groups: [...groups].sort()});
}catch(error){
return next(error);
}
});
router.get('/', async function(req, res, next){
try{
let results = await Model[req.query.detail ? "listDetail" : "list"]();
@@ -35,6 +73,7 @@ router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), asy
req.body.created_by = authz.reqUsername(req);
validateHostFields(req.body);
normalizeHostFeatures(req.body);
hashHostSecrets(req.body);
let item = await Model.create(req.body);
return res.json({
@@ -100,6 +139,7 @@ router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam)
req.body.updated_by = authz.reqUsername(req);
validateHostFields(req.body);
normalizeHostFeatures(req.body);
hashHostSecrets(req.body);
let item = await Model.get(req.params.item);
item = await item.update(req.body);
+145
View File
@@ -0,0 +1,145 @@
'use strict';
/**
* Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host
* (nginx routes that path here; see ops/nginx_conf/proxy.conf). These run the
* OIDC authorization-code flow (reusing utils/oidc.js and conf.oidc) and, on a
* successful + authorized login, mint a Redis-backed SsoSession and set the
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that
* session (ops/nginx_conf/hostfeatures.lua).
*
* Callback style: per-host (option A) — redirect_uri is
* https://<host>/__proxy_auth/callback, so each protected host's callback must
* be an allowed redirect URI at the IdP (a wildcard redirect URI covers all).
*/
const router = require('express').Router();
const conf = require('@simpleworkjs/conf');
const oidc = require('../utils/oidc');
const {Host} = require('../models').models;
const {HostSsoState, SsoSession} = require('../models/sso_session');
const {identityAllowed} = require('../utils/host_sso');
const COOKIE = (conf.hostSso && conf.hostSso.cookieName) || '__proxy_sso';
// Minimal HTML notice page (these endpoints are hit by browsers, not the API).
function page(message){
return `<!doctype html><html><head><meta charset="utf-8"><title>Sign in</title>`
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`
+ `<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}</style>`
+ `</head><body><p>${String(message).replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]))}</p></body></html>`;
}
// This host's own callback URL — must match between authorize and token steps.
function callbackUri(req){
return `${req.protocol}://${req.get('host')}/__proxy_auth/callback`;
}
// Constrain the post-login redirect to this same host (no open redirect). `rd`
// may be a bare path or a full same-host URL.
function safeRd(req, rd){
try{
if(!rd) return '/';
if(rd.charAt(0) === '/' && rd.charAt(1) !== '/') return rd;
let u = new URL(rd);
if(u.host === req.get('host')) return u.pathname + u.search;
}catch(error){ /* fall through */ }
return '/';
}
function readCookie(req, name){
for(let part of (req.headers.cookie || '').split(';')){
let idx = part.indexOf('=');
if(idx === -1) continue;
if(part.slice(0, idx).trim() === name) return decodeURIComponent(part.slice(idx + 1).trim());
}
return null;
}
// Resolve the effective Host record (exact, else via the wildcard lookup tree)
// so we can read its SSO allow-lists.
async function resolveHost(hostname){
try{ return await Host.get(hostname); }catch(error){ /* try wildcard */ }
try{ return Host.lookUp(hostname) || null; }catch(error){ return null; }
}
// Begin login: create PKCE/state, remember the target host + return path, and
// redirect the browser to the IdP.
router.get('/start', async function(req, res, next){
try{
if(!conf.oidc || !conf.oidc.enabled){
return res.status(503).send(page('SSO is not configured on this proxy.'));
}
let hostname = req.hostname;
let hostRec = await resolveHost(hostname);
if(!hostRec || !hostRec.sso_enabled){
return res.status(404).send(page('SSO is not enabled for this host.'));
}
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
await HostSsoState.create({state, codeVerifier, host: hostname, rd: safeRd(req, req.query.rd)});
return res.redirect(oidc.buildAuthUrl(state, codeChallenge, callbackUri(req)));
}catch(error){
return next(error);
}
});
// OIDC redirect target: validate state, exchange the code, enforce the host's
// allow-list, then establish the session and return the user to where they were.
router.get('/callback', async function(req, res, next){
try{
let {code, state} = req.query;
if(!code || !state) return res.status(400).send(page('Missing authorization code.'));
let st = await HostSsoState.get(state).catch(() => null);
if(!st) return res.status(400).send(page('Your login session expired. Please try again.'));
await st.remove().catch(() => {}); // one-time use
let hostname = req.hostname;
if(st.host !== hostname) return res.status(400).send(page('Login host mismatch.'));
let tokens = await oidc.exchangeCode(code, st.codeVerifier, callbackUri(req));
let claims = await oidc.fetchUserInfo(tokens.access_token);
let identity = oidc.claimsToIdentity(claims);
let email = claims.email || '';
let hostRec = await resolveHost(hostname);
let allowUsers = (hostRec && hostRec.sso_allow_users) || [];
let allowGroups = (hostRec && hostRec.sso_allow_groups) || [];
if(!identityAllowed({username: identity.username, email, groups: identity.groups}, allowUsers, allowGroups)){
return res.status(403).send(page(`You are not authorized to access ${hostname}.`));
}
let sid = oidc.randomToken(32);
await SsoSession.create({sid, host: hostname, sub: identity.username, email, groups: identity.groups || []});
res.cookie(COOKIE, sid, {
httpOnly: true,
secure: req.protocol === 'https',
sameSite: 'lax',
path: '/',
maxAge: SsoSession.ttl() * 1000,
});
return res.redirect(safeRd(req, st.rd));
}catch(error){
return next(error);
}
});
// End the session for this host.
router.get('/logout', async function(req, res, next){
try{
let sid = readCookie(req, COOKIE);
if(sid){
try{ let s = await SsoSession.get(sid); await s.remove(); }catch(error){ /* gone */ }
}
res.clearCookie(COOKIE, {path: '/'});
return res.redirect(safeRd(req, req.query.rd));
}catch(error){
return next(error);
}
});
module.exports = router;
+17 -31
View File
@@ -3,10 +3,18 @@
const router = require('express').Router();
const {User} = require('../models').models;
const authz = require('../middleware/authz');
const {passwordError} = require('../utils/password_policy');
// Reject a weak password before it reaches the model. Throws 422 with a
// per-field key the frontend surfaces inline.
function validatePassword(password){
let message = passwordError(password);
if(message) throw User.errors.ObjectValidateError([{key: 'password', message}]);
}
// User management is global-admin-only, except the self-service routes below
// (GET /me, PUT /password, POST /key) which any authenticated user may call for
// their own account.
// (GET /me, PUT /password) which any authenticated user may call for their own
// account.
router.get('/', authz.requireAdmin, async function(req, res, next){
try{
@@ -21,8 +29,12 @@ router.get('/', authz.requireAdmin, async function(req, res, next){
router.post('/', authz.requireAdmin, async function(req, res, next){
try{
req.body.created_by = authz.reqUsername(req)
validatePassword(req.body.password);
return res.json(await User.add(req.body));
// User.create (not the nonexistent User.add) — the drift here meant every
// API-created account threw, so the new credentials never existed to log
// in with (issue #48).
return res.json(await User.create(req.body));
}catch(error){
next(error);
}
@@ -62,6 +74,7 @@ router.get('/me', async function(req, res, next){
// Self-service: change your own password.
router.put('/password', async function(req, res, next){
try{
validatePassword(req.body.password);
return res.json({results: await req.user.setPassword(req.body)})
}catch(error){
next(error);
@@ -71,6 +84,7 @@ router.put('/password', async function(req, res, next){
// Admin: reset another user's password.
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
try{
validatePassword(req.body.password);
let user = await User.get(req.params.username);
return res.json({results: await user.setPassword(req.body)});
}catch(error){
@@ -78,32 +92,4 @@ router.put('/password/:username', authz.requireAdmin, async function(req, res, n
}
});
router.post('/invite', authz.requireAdmin, async function(req, res, next){
try{
let token = await req.user.invite();
return res.json({token: token.token});
}catch(error){
next(error);
}
});
// Self-service: add an SSH key to your own account.
router.post('/key', async function(req, res, next){
try{
let added = await User.addSSHkey({
username: authz.reqUsername(req),
key: req.body.key
});
return res.status(added === true ? 200 : 400).json({
message: added
});
}catch(error){
next(error);
}
});
module.exports = router;
+7
View File
@@ -2,6 +2,7 @@
const conf = require('@simpleworkjs/conf');
const {Host} = require('../models/host');
const {DnsProvider} = require('../models').models;
function hostSchedulerService(){
@@ -29,8 +30,14 @@ function hostSchedulerService(){
// Ensures certificates are renewed well before expiration
setInterval(Host.checkWildcardForRenew.bind(Host), conf.service.hostScheduler.interval);
// Refresh each DNS provider's domain list on the same cadence so domains
// added/removed at the provider are picked up without a manual refresh.
setTimeout(DnsProvider.refreshAllDomains.bind(DnsProvider), conf.service.hostScheduler.initial);
setInterval(DnsProvider.refreshAllDomains.bind(DnsProvider), conf.service.hostScheduler.interval);
console.log('Host scheduler service initialized');
console.log('- Wildcard cert check: 30s after start, then every 24h');
console.log('- DNS provider domain refresh: 30s after start, then every 24h');
}
if(conf.service.hostScheduler.enabled !== false) hostSchedulerService();
+100
View File
@@ -0,0 +1,100 @@
'use strict';
const {describe, test} = require('node:test');
const assert = require('node:assert');
const {hashPassword, hashBasicAuthUsers} = require('../../utils/basicauth');
const {
parseBasicAuthLines,
sanitizeBasicAuthObject,
sanitizeRealm,
parseAllowList,
normalizeHostFeatures,
} = require('../../utils/host_features');
/**
* Per-host basic auth (#57). The hash must match what OpenResty computes in
* ops/nginx_conf/hostfeatures.lua: base64(sha1(password)) (htpasswd "{SHA}").
*/
describe('basicauth hashing', () => {
test('base64(sha1(password)) matches the known htpasswd {SHA} vector', () => {
assert.strictEqual(hashPassword('secret'), '5en6G6MezRroT3XKqkdPOmY/BfQ=');
});
test('hashBasicAuthUsers hashes each password, skips empties', () => {
assert.deepStrictEqual(
hashBasicAuthUsers({alice: 'secret', bob: '', carol: null}),
{alice: '5en6G6MezRroT3XKqkdPOmY/BfQ='}
);
});
});
describe('parseBasicAuthLines', () => {
test('parses user:password lines; passwords may contain colons', () => {
assert.deepStrictEqual(
parseBasicAuthLines('alice:secret\nbob:pw:with:colons'),
{alice: 'secret', bob: 'pw:with:colons'}
);
});
test('drops blank lines, lines without a colon, and empty passwords', () => {
assert.deepStrictEqual(
parseBasicAuthLines('\nalice:secret\nnopassword\nbob:\n \n'),
{alice: 'secret'}
);
});
test('rejects usernames with spaces/control chars', () => {
assert.deepStrictEqual(parseBasicAuthLines('a b:secret'), {});
});
});
describe('sanitizeRealm', () => {
test('strips CR/LF and quotes and trims', () => {
assert.strictEqual(sanitizeRealm('My "Realm"\r\n'), 'My Realm');
assert.strictEqual(sanitizeRealm(undefined), '');
});
});
describe('normalizeHostFeatures (basic auth)', () => {
test('coerces enabled, parses users to plaintext object, sanitizes realm', () => {
let body = {
basicauth_enabled: 'true',
basicauth_realm: 'Admins\n',
basicauth_users: 'alice:secret\nbob:pw',
};
normalizeHostFeatures(body);
assert.strictEqual(body.basicauth_enabled, true);
assert.strictEqual(body.basicauth_realm, 'Admins');
assert.deepStrictEqual(body.basicauth_users, {alice: 'secret', bob: 'pw'});
});
test('empty users input is dropped so a blank edit keeps existing users', () => {
let body = {basicauth_enabled: 'true', basicauth_users: ' \n'};
normalizeHostFeatures(body);
assert.ok(!('basicauth_users' in body));
});
test('object input is sanitized like text input', () => {
let body = {basicauth_users: {alice: 'secret', 'bad user': 'x', bob: ''}};
normalizeHostFeatures(body);
assert.deepStrictEqual(body.basicauth_users, {alice: 'secret'});
});
});
describe('SSO allow-lists (#57)', () => {
test('parseAllowList splits on commas/whitespace/newlines and dedupes', () => {
assert.deepStrictEqual(
parseAllowList('alice@x.com, bob@x.com\ncarol@x.com alice@x.com'),
['alice@x.com', 'bob@x.com', 'carol@x.com']
);
assert.deepStrictEqual(parseAllowList(['a', 'a', ' b ', '']), ['a', 'b']);
assert.deepStrictEqual(parseAllowList(''), []);
});
test('normalizeHostFeatures coerces sso_enabled and parses allow-lists', () => {
let body = {
sso_enabled: 'true',
sso_allow_users: 'alice@x.com\nbob@x.com',
sso_allow_groups: 'dns-team, admins',
};
normalizeHostFeatures(body);
assert.strictEqual(body.sso_enabled, true);
assert.deepStrictEqual(body.sso_allow_users, ['alice@x.com', 'bob@x.com']);
assert.deepStrictEqual(body.sso_allow_groups, ['dns-team', 'admins']);
});
});
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const {describe, test} = require('node:test');
const assert = require('node:assert');
const {identityAllowed} = require('../../utils/host_sso');
describe('identityAllowed (per-host SSO authorization)', () => {
const id = {username: 'alice', email: 'alice@x.com', groups: ['dns-team', 'staff']};
test('empty allow-lists allow any authenticated user', () => {
assert.strictEqual(identityAllowed(id, [], []), true);
assert.strictEqual(identityAllowed(id, undefined, undefined), true);
});
test('allows by username', () => {
assert.strictEqual(identityAllowed(id, ['bob', 'alice'], []), true);
});
test('allows by email', () => {
assert.strictEqual(identityAllowed(id, ['alice@x.com'], []), true);
});
test('allows by group membership', () => {
assert.strictEqual(identityAllowed(id, [], ['dns-team']), true);
});
test('denies when neither user nor group matches a non-empty list', () => {
assert.strictEqual(identityAllowed(id, ['bob'], ['admins']), false);
});
test('is case-insensitive', () => {
assert.strictEqual(identityAllowed(id, ['ALICE'], []), true);
assert.strictEqual(identityAllowed(id, [], ['DNS-Team']), true);
assert.strictEqual(identityAllowed({username: 'A', email: 'A@X.com'}, ['a@x.com'], []), true);
});
test('handles a missing/empty identity gracefully', () => {
assert.strictEqual(identityAllowed({}, ['bob'], ['admins']), false);
assert.strictEqual(identityAllowed({}, [], []), true);
});
});
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const {describe, test} = require('node:test');
const assert = require('node:assert');
const {passwordError} = require('../../utils/password_policy');
/**
* The old rule (issue #48) rejected strong passwords and accepted weak ones.
* These pin the corrected behavior: length-forward, 3-of-4 character classes.
*/
describe('passwordError', () => {
test('accepts a strong mixed password (previously rejected)', () => {
assert.strictEqual(passwordError('@123Caplowercase'), null);
});
test('accepts a 12+ char passphrase on length alone', () => {
assert.strictEqual(passwordError('correcthorsebattery'), null);
});
test('rejects a weak two-class password (previously accepted)', () => {
assert.notStrictEqual(passwordError('lowercase1'), null);
});
test('rejects too-short passwords', () => {
assert.notStrictEqual(passwordError('Ab3$xy'), null); // 6 chars
assert.notStrictEqual(passwordError(''), null);
assert.notStrictEqual(passwordError(undefined), null);
});
test('accepts 8 chars with 3 classes', () => {
assert.strictEqual(passwordError('Abcd123!'), null); // upper, lower, num, sym
assert.strictEqual(passwordError('Abcdefg1'), null); // upper, lower, num
});
test('rejects 8-11 chars with only 2 classes', () => {
assert.notStrictEqual(passwordError('abcdefg1'), null); // lower + num only
assert.notStrictEqual(passwordError('ABCDEFG1'), null); // upper + num only
});
});
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const crypto = require('crypto');
/**
* Server-only hashing for per-host basic-auth credentials. Kept out of the pure,
* browser-mirrored utils/host_features.js because it needs Node crypto.
*
* Passwords are stored as base64(SHA-1(password)) — the Apache htpasswd "{SHA}"
* scheme — so plaintext never lands in Redis. OpenResty verifies with the same
* hash (ops/nginx_conf/hostfeatures.lua): base64(sha1(password)).
*
* SHA-1 is weak for password storage in general, but this is a lightweight proxy
* gate (not the app's own accounts) and matches htpasswd; upgrading the scheme is
* a follow-up. Enforce strong passwords operationally.
*/
function hashPassword(password){
return crypto.createHash('sha1').update(String(password)).digest('base64');
}
// { username: plaintext } -> { username: base64sha1 }. Skips empty passwords.
function hashBasicAuthUsers(users){
let out = {};
if(!users || typeof users !== 'object') return out;
for(let user of Object.keys(users)){
let pass = users[user];
if(pass === undefined || pass === null || pass === '') continue;
out[user] = hashPassword(pass);
}
return out;
}
module.exports = {hashPassword, hashBasicAuthUsers};
+104 -1
View File
@@ -11,6 +11,13 @@
const MAX_HEADERS = 50; // per direction (req/resp)
const MAX_HEADER_VALUE = 2048; // chars
const MAX_CIDRS = 200; // per list (allow/deny)
const MAX_BASICAUTH_USERS = 100;
const MAX_PASSWORD = 256;
const MAX_REALM = 128;
// Basic-auth username: printable ASCII, no space or control chars. ':' can't
// appear (we split on the first ':'), but the class excludes it anyway.
const BASICAUTH_USER_RE = /^[\x21-\x39\x3B-\x7e]+$/;
// RFC 7230 header field-name token characters.
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
@@ -125,6 +132,79 @@ function stringifyCidrs(arr){
return arr.join('\n');
}
/**
* "username:password" lines -> { username: password } (plaintext). The first
* ':' splits; usernames are validated and CR/LF is stripped from passwords.
* Lines without a password are dropped. Hashing happens server-side
* (utils/basicauth.js) — this stays pure so the browser can share it.
*/
function parseBasicAuthLines(text){
let out = {};
if(text === undefined || text === null) return out;
for(let line of String(text).split(/\r?\n/)){
line = line.replace(/[\r\n]/g, '');
if(!line.trim()) continue;
let idx = line.indexOf(':');
if(idx === -1) continue;
let user = line.slice(0, idx).trim();
let pass = line.slice(idx + 1).slice(0, MAX_PASSWORD);
if(!user || !pass) continue;
if(!BASICAUTH_USER_RE.test(user)) continue;
out[user] = pass;
if(Object.keys(out).length >= MAX_BASICAUTH_USERS) break;
}
return out;
}
/** Sanitize an already-object credential map ({user: password}) the same way. */
function sanitizeBasicAuthObject(obj){
let out = {};
if(!obj || typeof obj !== 'object') return out;
for(let user of Object.keys(obj)){
if(!BASICAUTH_USER_RE.test(user)) continue;
let pass = String(obj[user]).replace(/[\r\n]/g, '').slice(0, MAX_PASSWORD);
if(!pass) continue;
out[user] = pass;
if(Object.keys(out).length >= MAX_BASICAUTH_USERS) break;
}
return out;
}
const MAX_ALLOW_ENTRIES = 500;
/**
* Newline/comma/whitespace-separated text -> deduped array of trimmed entries
* (usernames, emails, or group names for the SSO allow-lists). CR/LF stripped.
*/
function parseAllowList(input){
let items = Array.isArray(input)
? input
: String(input === undefined || input === null ? '' : input).split(/[\s,]+/);
let seen = new Set();
let out = [];
for(let raw of items){
let s = String(raw).replace(/[\r\n]/g, '').trim();
if(!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
if(out.length >= MAX_ALLOW_ENTRIES) break;
}
return out;
}
/** Realm goes into a WWW-Authenticate header; strip CR/LF and quotes, cap len. */
function sanitizeRealm(value){
return String(value === undefined || value === null ? '' : value)
.replace(/[\r\n"]/g, '')
.trim()
.slice(0, MAX_REALM);
}
function toBool(v){
return v === true || v === 'true';
}
@@ -151,6 +231,27 @@ function normalizeHostFeatures(body){
if('ratelimit_enabled' in body) body.ratelimit_enabled = toBool(body.ratelimit_enabled);
if('respcache_enabled' in body) body.respcache_enabled = toBool(body.respcache_enabled);
if('hsts_enabled' in body) body.hsts_enabled = toBool(body.hsts_enabled);
if('basicauth_enabled' in body) body.basicauth_enabled = toBool(body.basicauth_enabled);
if('basicauth_realm' in body) body.basicauth_realm = sanitizeRealm(body.basicauth_realm);
if('basicauth_users' in body){
let users = typeof body.basicauth_users === 'string'
? parseBasicAuthLines(body.basicauth_users)
: sanitizeBasicAuthObject(body.basicauth_users);
// Empty input means "leave the existing users untouched" (passwords are
// never echoed to the form, so a blank textarea must not wipe them). Drop
// the key so the partial update skips it. Disable basic auth to clear.
if(Object.keys(users).length === 0){
delete body.basicauth_users;
}else{
body.basicauth_users = users;
}
}
if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled);
if('sso_allow_users' in body) body.sso_allow_users = parseAllowList(body.sso_allow_users);
if('sso_allow_groups' in body) body.sso_allow_groups = parseAllowList(body.sso_allow_groups);
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
@@ -181,8 +282,10 @@ function normalizeHostFeatures(body){
}
module.exports = {
MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS,
MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS, MAX_BASICAUTH_USERS,
parseHeaderLines, stringifyHeaders, sanitizeHeaderObject,
isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs,
parseBasicAuthLines, sanitizeBasicAuthObject, sanitizeRealm,
parseAllowList,
normalizeHostFeatures,
};
+35
View File
@@ -0,0 +1,35 @@
'use strict';
/**
* Pure authorization check for per-host SSO (#57).
*
* After the OIDC dance, the callback decides whether the authenticated identity
* may access the host, based on the host's allow-lists. Enforcing here (at
* session creation) keeps the OpenResty side simple — the Lua gate only has to
* confirm a valid session exists for the host.
*
* Semantics: empty allow-lists mean "any authenticated user". Otherwise the
* identity is allowed if its username OR email is in sso_allow_users, or any of
* its groups is in sso_allow_groups. All comparisons are case-insensitive.
*/
function identityAllowed(identity, allowUsers, allowGroups){
identity = identity || {};
allowUsers = Array.isArray(allowUsers) ? allowUsers : [];
allowGroups = Array.isArray(allowGroups) ? allowGroups : [];
if(allowUsers.length === 0 && allowGroups.length === 0) return true;
let lc = s => String(s).trim().toLowerCase();
let ids = [identity.username, identity.email].filter(Boolean).map(lc);
let users = allowUsers.map(lc);
if(ids.some(id => users.includes(id))) return true;
let groups = (Array.isArray(identity.groups) ? identity.groups : []).map(lc);
let allow = allowGroups.map(lc);
if(groups.some(g => allow.includes(g))) return true;
return false;
}
module.exports = {identityAllowed};
+8 -6
View File
@@ -36,13 +36,14 @@ function createAuthRequest(){
return {state, codeVerifier, codeChallenge};
}
// Build the SSO authorize URL the browser is redirected to.
function buildAuthUrl(state, codeChallenge){
// Build the SSO authorize URL the browser is redirected to. `redirectUri`
// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback).
function buildAuthUrl(state, codeChallenge, redirectUri){
let o = conf.oidc;
let params = new URLSearchParams({
response_type: 'code',
client_id: o.clientId,
redirect_uri: o.redirectUri,
redirect_uri: redirectUri || o.redirectUri,
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
state,
code_challenge: codeChallenge,
@@ -51,13 +52,14 @@ function buildAuthUrl(state, codeChallenge){
return `${o.authorizationEndpoint}?${params.toString()}`;
}
// Exchange an authorization code for tokens at the token endpoint.
async function exchangeCode(code, codeVerifier){
// Exchange an authorization code for tokens at the token endpoint. `redirectUri`
// must match the one used in buildAuthUrl (per-host for per-host SSO).
async function exchangeCode(code, codeVerifier, redirectUri){
let o = conf.oidc;
let body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: o.redirectUri,
redirect_uri: redirectUri || o.redirectUri,
client_id: o.clientId,
client_secret: o.clientSecret,
code_verifier: codeVerifier,
+42
View File
@@ -0,0 +1,42 @@
'use strict';
/**
* Local-account password policy.
*
* The previous rule was a single opaque regex that rejected strong passwords
* (e.g. "@123Caplowercase") while accepting weak ones (e.g. "lowercase1") — see
* issue #48. This replaces it with a clear, length-forward policy:
*
* - at least MIN characters, and
* - either PASSPHRASE+ characters (a long passphrase passes on length alone),
* or at least 3 of the 4 character classes (lowercase, uppercase, number,
* symbol).
*
* Pure and dependency-free so it can run server-side (routes/user.js) and be
* mirrored client-side (public/lib/js/val.js) and unit tested.
*/
const MIN = 8;
const PASSPHRASE = 12;
// Returns a human-readable error message if the password is unacceptable, else
// null when it passes.
function passwordError(value){
if(typeof value !== 'string' || value.length < MIN){
return `Password must be at least ${MIN} characters.`;
}
if(value.length >= PASSPHRASE) return null;
let classes = 0;
if(/[a-z]/.test(value)) classes++;
if(/[A-Z]/.test(value)) classes++;
if(/[0-9]/.test(value)) classes++;
if(/[^A-Za-z0-9]/.test(value)) classes++;
if(classes < 3){
return 'Use at least 3 of: lowercase, uppercase, number, symbol — or make it 12+ characters.';
}
return null;
}
module.exports = {passwordError, MIN, PASSPHRASE};
+420 -384
View File
@@ -11,7 +11,11 @@
}
div.form-group{
margin-bottom: 1em;
margin-bottom: 1.1em;
}
.field-help{
font-size: .82rem;
}
/* my Div class for my search bar */
@@ -22,25 +26,16 @@
margin-top: 10px;
}
/* The input bar */
input {
font-size: 1rem;
border-top-left-radius: 5px !important;
border-bottom-left-radius: 5px !important;
border-top-right-radius: 5px !important;
border-bottom-right-radius: 5px !important;
}
/* Greys out a challenge/matching option that isn't available for the host. */
.challengeType-container {
pointer-events: none; /* Prevents clicking */
opacity: 0.5; /* Greys it out */
filter: grayscale(1); /* Removes blue/color tint */
cursor: not-allowed;
}
pointer-events: none; /* Prevents clicking */
opacity: 0.5; /* Greys it out */
filter: grayscale(1); /* Removes blue/color tint */
cursor: not-allowed;
}
</style>
<script type="text/javascript">
var $editHostForm;
// Parse the JSON object for a host to something the UI wants
function hostParseRow(host) {
@@ -82,53 +77,113 @@
});
}
function hostEditCancle(){
$('tr.jq-repeat-hosts').each(function(idx, el){
$(el).removeClass('table-warning');
});
$.scope.editHost.remove(0);
}
// Mirror of utils/host_features.js stringify* helpers for populating the edit
// form's textareas. The server re-parses the posted text authoritatively.
function hostFeatureHeadersToText(obj){
if(!obj || typeof obj != 'object') return '';
return Object.keys(obj).map(function(name){ return name + ': ' + obj[name]; }).join('\n');
}
function hostFeatureCidrsToText(arr){
function hostFeatureListToText(arr){
return Array.isArray(arr) ? arr.join('\n') : '';
}
function hostEditOpen(btn, host){
hostEditCancle();
console.log('host:', host)
host = $.scope.hosts.getByKey(host);
host.__jq_$el.addClass('table-warning');
$editHostForm.find('[name=is_wildcard').attr('disabled', true);
$.scope.editHost.update({...host, form: $editHostForm.html()});
// ----- Add / Edit modal --------------------------------------------------
if(host.is_wildcard){
$('.hostEditPanel [name="host"]').attr('disabled', true);
// Allow toggling the wildcard matching mode when editing a wildcard host.
$('.hostEditPanel #wildcard_matchAny-container').removeClass('challengeType-container');
}
function hostModal(){
return bootstrap.Modal.getOrCreateInstance(document.getElementById('hostModal'));
}
function hostModalClose(){ hostModal().hide(); }
$.each(host, function( key, value ) { if(typeof value == "boolean"){
$(".hostEditPanel #"+ key +"-"+ value).prop('checked', true)
function hostShowTab(id){
bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show();
}
// Append a picked/typed value to one of the SSO allow-list textareas (deduped).
function allowListAdd(input, name){
let val = (input.value || '').trim();
if(!val) return;
let $ta = $('#hostForm textarea[name="' + name + '"]');
let lines = ($ta.val() || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if(lines.indexOf(val) === -1) lines.push(val);
$ta.val(lines.join('\n'));
input.value = '';
input.focus();
}
// Fill the user/group datalists that back the allow-list autocomplete.
function hostLoadAuthSuggestions(){
app.api.get('host/auth-suggestions', function(error, data){
if(error || !data) return;
let $u = $('#hostSsoUsers').empty();
for(let u of (data.users || [])) $u.append($('<option>').val(u));
let $g = $('#hostSsoGroups').empty();
for(let g of (data.groups || [])) $g.append($('<option>').val(g));
});
}
// Return the form to a clean "add" state.
function hostFormReset(){
let form = document.getElementById('hostForm');
form.reset();
let $f = $(form);
$f.attr('method', 'POST').attr('action', 'host').attr('evalAJAX', 'hostModalClose()');
$f.find('[name=host]').prop('disabled', false);
if($f.validateClear) $f.validateClear();
// A fresh host only qualifies for HTTP-01 until the name says otherwise.
$('#challengeType-child-container, #challengeType-DNS-01-wildcard-container, #wildcard_matchAny-container')
.addClass('challengeType-container');
$('#challengeType-child-relatedHost').text('');
$('.basicauth-current').text('none');
hostShowTab('hostTab-general-btn');
}
function hostAddOpen(){
hostFormReset();
$('#hostModalTitle').text('Add host');
$('#hostModalSubmitText').text('Add host');
hostModal().show();
}
function hostEditOpen(host){
hostFormReset();
let h = $.scope.hosts.getByKey(host);
let $f = $('#hostForm');
$f.attr('method', 'PUT').attr('action', 'host/' + encodeURIComponent(host));
$('#hostModalTitle').text('Edit ' + host);
$('#hostModalSubmitText').text('Save changes');
// Scalar fields: booleans drive the matching radio, everything else the
// input with that name. Object/array fields are handled as text below.
$.each(h, function(key, value){
if(typeof value === 'boolean'){
$f.find('#' + key + '-' + value).prop('checked', true);
}else{
$(".hostEditPanel input[name='" + key + "']").val(value);
$f.find("input[name='" + key + "']").val(value);
}
});
// Object/array proxy-control fields render into textareas as text. Server
// (utils/host_features.js) parses the same text/shape back on save.
$(".hostEditPanel textarea[name='req_headers']").val(hostFeatureHeadersToText(host.req_headers));
$(".hostEditPanel textarea[name='resp_headers']").val(hostFeatureHeadersToText(host.resp_headers));
$(".hostEditPanel textarea[name='ip_allow']").val(hostFeatureCidrsToText(host.ip_allow));
$(".hostEditPanel textarea[name='ip_deny']").val(hostFeatureCidrsToText(host.ip_deny));
$f.find("textarea[name='req_headers']").val(hostFeatureHeadersToText(h.req_headers));
$f.find("textarea[name='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers));
$f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow));
$f.find("textarea[name='ip_deny']").val(hostFeatureListToText(h.ip_deny));
$f.find("textarea[name='sso_allow_users']").val(hostFeatureListToText(h.sso_allow_users));
$f.find("textarea[name='sso_allow_groups']").val(hostFeatureListToText(h.sso_allow_groups));
$('.hostEditPanel').scrollTo();
};
// Never echo basic-auth passwords; show current usernames as a hint.
$f.find("textarea[name='basicauth_users']").val('');
$('.basicauth-current').text(Object.keys(h.basicauth_users || {}).join(', ') || 'none');
// The host name is the key; it can't change on edit. Wildcard hosts can
// still toggle their matching mode.
$f.find('[name=host]').prop('disabled', true);
if(h.is_wildcard){
$('#wildcard_matchAny-container').removeClass('challengeType-container');
}
hostModal().show();
}
function hostDownloadCert(host, type){
app.host.getCert({host}, function(error, data){
@@ -138,13 +193,7 @@
}
function hostSearchInput(){
//search bar html event logic stolen from here
// https://github.com/WebDevSimplified/js-search-bar/blob/main/script.js
let inputValue = $(event.target).val().toLowerCase();
// on each input detected we need to get all host list that was called by the hostPopulate function which is store in
// $.scope.hosts and then we need to loop through each host and check if the host name is equal to the input value
// if it is we will display the host if not we will hide it
for(let hostObj of $.scope.hosts){
if (hostObj.host.toLowerCase().includes(inputValue)) {
hostObj.__jq_$el.show();
@@ -156,9 +205,7 @@
async function verifyWildcardRequirements(host){
try{
let res = await app.api.get(`dns/domain/${host}`);
return res.results.length === 1;
}catch(error){
return false;
@@ -180,7 +227,6 @@
async function hostMatchWildcard(host){
try{
let res = await app.api.get(`host/lookup/${host}`);
if(res.results && res.results.is_wildcard){
return res.results;
}
@@ -190,17 +236,13 @@
}
$(document).ready(function(){
// Clone the new host form to be used on edit requests.
$editHostForm = $('#addHost').clone();
$editHostForm.find('hr.buttonBreak').nextAll().remove();
// $editHostForm.find('.autoSll').addClass('bg-secondary');
// Populate the host UI table
// Populate the host UI table
hostPopulate();
hostLoadAuthSuggestions();
// Determine what lets encrypt challenge type the given host name can use
$hostField = $('[name=host');
$hostField.keyup(async function(){
// Determine what Let's Encrypt challenge type the given host name can use.
let $hostField = $('#hostForm [name=host]');
$hostField.on('keyup', async function(){
// Reset the allowed types on start
$('#challengeType-child-container').addClass('challengeType-container');
$('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container');
@@ -208,36 +250,31 @@
let host = $hostField.val();
// If its a wild card, we must check if the domain has a registered
// provider.
// If it's a wildcard, we must check the domain has a registered provider.
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
// Wildcard matching mode only applies to wildcard hosts.
$('#wildcard_matchAny-container').removeClass('challengeType-container');
return;
}
// Check if a wildcard cert is available for the given host.
let wildcardParent = await hostMatchWildcard($hostField.val());
// Check if a wildcard cert is available for the given host. When it is,
// make "Parent Wildcard" the default choice (it reuses an existing cert).
let wildcardParent = await hostMatchWildcard(host);
if(wildcardParent){
$('#challengeType-child-container').removeClass('challengeType-container');
$('#challengeType-child-relatedHost').text(wildcardParent.host);
$('#challengeType-wildcardChild').prop('checked', true);
return;
}
// If we hit here, make sure the form is reverted to a valid state
// Revert the form to a valid state.
$('#challengeType-child-relatedHost').text('');
$('#challengeType-HTTP-01').prop('checked', true);
});
//
$.scope.hosts.take = function($el, item, list){
$el.addClass('table-danger');
$el.fadeOut(500, function(){
$el.remove()
});
$el.fadeOut(500, function(){ $el.remove() });
};
$.scope.hosts.putUpdate = function($el, $render, item, list){
@@ -245,21 +282,8 @@
$el.replaceWith($render);
};
$.scope.editHost.put = function($el, item, list){
$el.slideDown();
};
$.scope.editHost.take = function($el, item, list){
$el.slideUp();
};
// app.subscribe(/^model:Host/, function(data, topic){
// console.log(topic, data);
// });
app.subscribe(/^model:Host:create/, function(data, topic){
let [a,b, action, host] = topic.split(':');
if($.scope.hosts.indexOf(host) >= 0){
$.scope.hosts.update(host, hostParseRow(data));
}else{
@@ -269,7 +293,6 @@
app.subscribe(/^model:Host:update/, function(data, topic){
let [a,b, action, host] = topic.split(':');
if($.scope.hosts.indexOf(host) >= 0){
$.scope.hosts.update(host, hostParseRow(data));
}else{
@@ -279,290 +302,26 @@
app.subscribe(/^model:Host:remove/, function(data, topic){
let [a,b, action, host] = topic.split(':');
$.scope.hosts.remove(host);
});
});
</script>
<div class="row" style="display:none">
<div class="col col-md-12 col-lg-4 col-xl-3 col-xxl-2">
<!--
left column
-->
<div jq-repeat="editHost" class="card shadow-lg border-warning hostEditPanel mb-3" style="display:none">
<!--
Edit host card
-->
<div class="card-header text-center bg-warning">
<span class="card-icon float-start">
<i class="fa-solid fa-pencil"></i>
</span>
<span class="card-title">
Edit {{ host }}
</span>
<span class="float-end">
<i class="fa-solid fa-circle-minus"></i>
<i class="fa-solid fa-circle-xmark" onclick="hostEditCancle()"></i>
</span>
</div>
<div class="card-body">
<form class="addHost" method="PUT" action="host/{{ host }}" onsubmit="formAJAX(this)" evalAJAX="hostEditCancle()">
{{{ form }}}
<input type="hidden" name="edit_host" />
<button type="submit" data-type="edit" class="btn btn-warning">
<i class="fa-solid fa-pencil"></i>
Update
</button>
<button class="btn btn-secondary" type="reset" onclick="hostEditCancle()">
<i class="fa-solid fa-ban"></i>
Cancel
</button>
</form>
</div>
</div>
<div class="card shadow-lg mb-3 hostAddPanel">
<!--
Add new host card
-->
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-plus"></i>
</span>
<span class="card-title">
New Entry
</span>
<span class="float-end">
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body d-none d-md-block">
<form class="addHost" id="addHost" method="POST" action="host" onsubmit="formAJAX(this)">
<div class="form-group">
<label class="form-label">
Incoming SSL
</label>
<br />
<div class="radio">
<label>
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
Force incoming connections over HTTPS <b>Recommended</b>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="forcessl" id="forcessl-false" value="false">
Allow use of both HTTP and HTTPS
</label>
</div>
</div>
<div class="form-group">
<label for="host" class="form-label">
Incoming Host Name
</label>
<div>
<input type="text" name="host" class="form-control" placeholder="ex: proxy.cloud-ops.net, *.cloud-ops.net, **.cloud-ops.net, or **" validate="host" >
<b class="invalid-feedback"></b>
</div>
</div>
<div class="form-group autoSll">
<label class="form-label">
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">Validation Type</a>:
</label>
<div class="radio" id="challengeType-HTTP-01-container">
<label>
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
HTTP-01
</label>
</div>
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container">
<label>
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
DNS-01 Wildcard
</label>
</div>
<div class="radio challengeType-container" id="challengeType-child-container">
<label>
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
</label>
</div>
</div>
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
<label class="form-label">
Wildcard Matching
</label>
<div class="radio">
<label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
Match only subdomains defined here <b>Recommended</b>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
Match any subdomain and proxy to this host
</label>
</div>
</div>
<div class="mb-3 form-group">
<label for="ip" class="form-label">
Target IP or Host Name
</label>
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10 or app.internal.net" validate="target:3" />
<b class="invalid-feedback"></b>
</div>
<div class="form-group">
<label for="targetPort" class="form-label">
Target TCP Port
</label>
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
<b class="invalid-feedback"></b>
</div>
<div class="form-group">
<label class="form-label">
Target SSL
</label>
<div class="radio">
<label>
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
Proxy to HTTP <b>Recommended</b>
</label>
</div>
<b class="invalid-feedback"></b>
</div>
<hr />
<h6 class="text-muted">Proxy controls</h6>
<div class="form-group">
<label class="form-label">Rate limiting</label>
<div class="radio">
<label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
Off <b>Recommended</b>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
Limit requests per client IP
</label>
</div>
</div>
<div class="row">
<div class="col form-group">
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
</div>
<div class="col form-group">
<label for="ratelimit_burst" class="form-label">Burst</label>
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
</div>
</div>
<div class="form-group">
<label class="form-label">Response caching</label>
<div class="radio">
<label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
Off <b>Recommended</b>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
Cache cacheable responses
</label>
</div>
</div>
<div class="form-group">
<label class="form-label">HSTS</label>
<div class="radio">
<label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
Off
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
Send Strict-Transport-Security
</label>
</div>
</div>
<div class="form-group">
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
</div>
<div class="form-group">
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
</div>
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
</div>
<hr class="buttonBreak" />
<button type="submit" class="btn btn-success">
<i class="fa-solid fa-plus"></i>
Add
</button>
</form>
</div>
</div>
</div>
<div class="col col-md-12 col-lg-8 col-xl-9 col-xxl-10">
<!--
Right column
-->
<div class="col-12">
<div class="card shadow-lg hostListPanel">
<!--
List current hosts
-->
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-network-wired"></i>
</span>
<span class="card-title">
Proxy List
</span>
<span class="float-end">
<div class="card-header d-flex align-items-center">
<span class="card-icon me-2"><i class="fa-solid fa-network-wired"></i></span>
<span class="card-title fw-bold">Proxy List</span>
<span class="ms-auto">
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="hostClearCache(this)" title="Clear cached wildcard subdomain lookups">
<i class="fa-solid fa-broom"></i>
Clear Cache
Clear cache
</button>
<button type="button" class="btn btn-sm btn-success" onclick="hostAddOpen()">
<i class="fa-solid fa-plus"></i>
Add host
</button>
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
@@ -575,7 +334,6 @@
</div>
<div class='table-responsive'>
<table class="m-0 card-body table table-striped overflow-x-scroll">
<thead>
<th>
<input type="checkbox"
@@ -590,21 +348,11 @@
<i class="fa-solid fa-trash-can"></i>
</button>
</th>
<th>
SSL Expire
</th>
<th>
Host Name
</th>
<th>
target
</th>
<th class="hidden-xs">
Updated
</th>
<th>
Actions
</th>
<th>SSL Expire</th>
<th>Host Name</th>
<th>target</th>
<th class="hidden-xs">Updated</th>
<th>Actions</th>
</thead>
<tbody>
@@ -639,7 +387,6 @@
</td>
<td>
<div class="btn-group">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-brands fa-expeditedssl"></i>
@@ -670,7 +417,7 @@
</ul>
</div>
<button type="button" onclick="hostEditOpen(this, '{{ host }}');" class="btn btn-sm btn-warning">
<button type="button" onclick="hostEditOpen('{{ host }}');" class="btn btn-sm btn-warning">
<i class="fa-solid fa-pencil"></i>
Edit
</button>
@@ -687,4 +434,293 @@
</div>
</div>
</div>
<!-- Add / Edit host modal ------------------------------------------------- -->
<div class="modal fade" id="hostModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content card border-0">
<div class="modal-header">
<h5 class="modal-title" id="hostModalTitle">Add host</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="card-header actionMessage m-0" style="display:none"></div>
<div class="modal-body">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS &amp; Wildcard</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-headers-btn" data-bs-toggle="tab" data-bs-target="#hostTab-headers" type="button" role="tab">Headers</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-access-btn" data-bs-toggle="tab" data-bs-target="#hostTab-access" type="button" role="tab">Access</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-auth-btn" data-bs-toggle="tab" data-bs-target="#hostTab-auth" type="button" role="tab">Authentication</button></li>
</ul>
<form class="addHost" id="hostForm" method="POST" action="host" onsubmit="formAJAX(this)" evalAJAX="hostModalClose()">
<div class="tab-content pt-3">
<!-- General -->
<div class="tab-pane fade show active" id="hostTab-general" role="tabpanel">
<div class="form-group">
<label for="host" class="form-label">Incoming host name</label>
<input type="text" name="host" class="form-control" placeholder="ex: app.example.com, *.example.com, **.example.com, or **" validate="host">
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">
The public hostname clients request. Use <code>*.example.com</code>
for one subdomain level, <code>**.example.com</code> for any depth,
or <code>**</code> as a catch-all.
</small>
</div>
<div class="form-group">
<label class="form-label">Incoming SSL</label>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
Force HTTPS <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-false" value="false">
Allow both HTTP and HTTPS
</label></div>
<small class="field-help text-muted d-block">Redirect plain HTTP requests to HTTPS.</small>
</div>
<hr>
<div class="form-group">
<label for="ip" class="form-label">Target IP or host name</label>
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10 or app.internal.net" validate="target:3" />
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">Where matching requests are proxied. Hostname or IP only &mdash; no protocol, port, or path.</small>
</div>
<div class="row">
<div class="col form-group">
<label for="targetPort" class="form-label">Target TCP port</label>
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
<b class="invalid-feedback"></b>
</div>
<div class="col form-group">
<label class="form-label">Target SSL</label>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
Proxy to HTTP <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label></div>
</div>
</div>
</div>
<!-- TLS & Wildcard -->
<div class="tab-pane fade" id="hostTab-tls" role="tabpanel">
<div class="form-group autoSll">
<label class="form-label">
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">validation type</a>
</label>
<div class="radio" id="challengeType-HTTP-01-container"><label>
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
HTTP-01
</label></div>
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container"><label>
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
DNS-01 Wildcard
</label></div>
<div class="radio challengeType-container" id="challengeType-child-container"><label>
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
</label></div>
<small class="field-help text-muted d-block">
Options light up based on the host name: wildcard certs need a DNS
provider for the domain; child hosts reuse a parent wildcard.
</small>
</div>
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
<label class="form-label">Wildcard matching</label>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
Match only subdomains defined here <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
Match any subdomain and proxy to this host
</label></div>
</div>
</div>
<!-- Traffic -->
<div class="tab-pane fade" id="hostTab-traffic" role="tabpanel">
<div class="form-group">
<label class="form-label">Rate limiting</label>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
Limit requests per client IP
</label></div>
</div>
<div class="row">
<div class="col form-group">
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
</div>
<div class="col form-group">
<label for="ratelimit_burst" class="form-label">Burst</label>
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
</div>
</div>
<small class="field-help text-muted d-block mb-3">Token bucket per client IP; bursts above the rate are queued, then rejected with 429.</small>
<hr>
<div class="form-group">
<label class="form-label">Response caching</label>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
Cache cacheable responses
</label></div>
<small class="field-help text-muted d-block">Cache upstream responses that declare themselves cacheable.</small>
</div>
<div class="form-group">
<label class="form-label">HSTS</label>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
Off
</label></div>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
Send Strict-Transport-Security
</label></div>
<small class="field-help text-muted d-block">Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working.</small>
</div>
</div>
<!-- Headers -->
<div class="tab-pane fade" id="hostTab-headers" role="tabpanel">
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each request sent to the target. One <code>Name: value</code> per line.</small>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each response returned to the client.</small>
</div>
</div>
<!-- Access -->
<div class="tab-pane fade" id="hostTab-access" role="tabpanel">
<h6 class="text-muted">IP access</h6>
<div class="form-group">
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
<small class="field-help text-muted d-block">If non-empty, only these sources may connect (default-deny).</small>
</div>
<div class="form-group">
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
<small class="field-help text-muted d-block">These sources are always blocked (deny wins over allow).</small>
</div>
</div>
<!-- Authentication -->
<div class="tab-pane fade" id="hostTab-auth" role="tabpanel">
<p class="field-help text-muted">
Basic auth and SSO are OR'd &mdash; if either is enabled, a request
is allowed when it passes <b>either</b> one. Leave both off for a
public host.
</p>
<h6 class="text-muted">Basic authentication</h6>
<div class="form-group">
<div class="radio"><label>
<input type="radio" name="basicauth_enabled" id="basicauth_enabled-false" value="false" checked>
Off
</label></div>
<div class="radio"><label>
<input type="radio" name="basicauth_enabled" id="basicauth_enabled-true" value="true">
Require username / password
</label></div>
</div>
<div class="form-group">
<label for="basicauth_realm" class="form-label">Realm</label>
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
</div>
<div class="form-group">
<label for="basicauth_users" class="form-label">Users</label>
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
<small class="field-help text-muted d-block">
Current: <span class="basicauth-current">none</span>.
Passwords are stored hashed and never shown here. Leave blank to keep
the current users; entering any lines replaces the whole list.
</small>
</div>
<hr>
<h6 class="text-muted">Single sign-on (SSO)</h6>
<div class="form-group">
<div class="radio"><label>
<input type="radio" name="sso_enabled" id="sso_enabled-false" value="false" checked>
Off
</label></div>
<div class="radio"><label>
<input type="radio" name="sso_enabled" id="sso_enabled-true" value="true">
Require login via the configured OIDC provider
</label></div>
<small class="field-help text-muted d-block">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</small>
</div>
<div class="form-group">
<label for="sso_allow_users" class="form-label">Allowed users</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoUsers" placeholder="type to search users…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_users');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_users')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_users" class="form-control" rows="2" placeholder="one email/username per line; blank = any authenticated user"></textarea>
</div>
<div class="form-group">
<label for="sso_allow_groups" class="form-label">Allowed groups</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoGroups" placeholder="type to search groups…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_groups');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_groups')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_groups" class="form-control" rows="2" placeholder="one group per line; blank = any authenticated user"></textarea>
</div>
</div>
</div>
<datalist id="hostSsoUsers"></datalist>
<datalist id="hostSsoGroups"></datalist>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fa-solid fa-ban"></i> Cancel
</button>
<button type="submit" form="hostForm" class="btn btn-success">
<i class="fa-solid fa-floppy-disk"></i>
<span id="hostModalSubmitText">Add host</span>
</button>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+2 -2
View File
@@ -82,14 +82,14 @@
</div>
<div class="form-group">
<label class="control-label">Password</label>
<input type="password" class="form-control" name="password" placeholder="Atleast 5 char. long" validate="password:5"/>
<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>
</div>
<div class="form-group">
<label class="control-label">Again</label>
<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>
</div>
<hr />
<button type="button" class="btn btn-info">
<button type="submit" class="btn btn-info">
Add
</button>
</form>
+113
View File
@@ -10,6 +10,9 @@
-- req_headers (JSON object) -- added to the upstream request
-- resp_headers (JSON object) -- added to the client response
-- ip_allow / ip_deny (JSON arrays of CIDRs)
-- basicauth_enabled / basicauth_realm
-- basicauth_users (JSON object {username: base64(sha1(password))})
-- sso_enabled -- gate on a __proxy_sso session (established by /__proxy_auth)
local cjson = require "cjson.safe"
@@ -80,6 +83,115 @@ local function apply_ratelimit(res, host, ip)
end
end
-- ---- Per-host authentication (basic auth OR SSO) -----------------------
--
-- Both are optional. If either is enabled, a request must satisfy at least one.
-- A "Basic" Authorization header routes to the basic-auth path (401 challenge on
-- failure); otherwise a browser is redirected into the SSO login. Basic-auth
-- creds are stored as {user: base64(sha1(pw))} (htpasswd "{SHA}"; hashed in
-- nodejs). SSO relies on a Redis-backed session established by /__proxy_auth
-- (nodejs); the allow-list was enforced there, so here we only confirm a valid
-- session for this host.
-- True when the request carries valid basic-auth credentials for this host.
local function basic_auth_ok(res)
local users = decode_table(res["basicauth_users"])
if not users then return false end
local header = ngx.var.http_authorization
if not header then return false end
local b64 = header:match("^%s*[Bb]asic%s+(%S+)%s*$")
if not b64 then return false end
local decoded = ngx.decode_base64(b64)
if not decoded then return false end
local user, pass = decoded:match("^([^:]*):(.*)$")
if not user or user == "" then return false end
local stored = users[user]
if not stored then return false end
local sha1 = require "resty.sha1"
local hasher = sha1:new()
if not hasher then return false end
hasher:update(pass or "")
return ngx.encode_base64(hasher:final()) == stored
end
local function basic_challenge(res)
local realm = res["basicauth_realm"]
if not realm or realm == "" then realm = "Restricted" end
realm = realm:gsub('[\r\n"]', "") -- defense in depth for the header
ngx.header["WWW-Authenticate"] = 'Basic realm="' .. realm .. '"'
return ngx.exit(401)
end
-- Read an SSO session hash from Redis. Returns the table or nil. The sid comes
-- from a cookie (attacker-controlled), so it is character-restricted before use.
local function sso_get_session(sid)
if not sid or not sid:match("^[%w_%-]+$") then return nil end
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "hostfeatures: sso redis connect ", err)
return nil
end
local arr = red:hgetall("proxy_SsoSession_" .. sid)
local sess = arr and red:array_to_hash(arr) or nil
red:set_keepalive(10000, 100)
if sess and next(sess) ~= nil then return sess end
return nil
end
-- True when a valid SSO session cookie exists for THIS host. (The session
-- auto-expires via Redis TTL; a missing key reads as no session.)
local function sso_session_ok()
local sid = ngx.var.cookie___proxy_sso
if not sid or sid == "" then return false end
local sess = sso_get_session(sid)
if not sess or not sess["sub"] then return false end
if sess["host"] ~= ngx.var.host then return false end
return true
end
-- Send a browser into the SSO login, preserving where it was headed. Non-idempotent
-- methods get a 401 instead of a redirect they couldn't safely replay.
local function sso_redirect()
local m = ngx.req.get_method()
if m ~= "GET" and m ~= "HEAD" then
return ngx.exit(401)
end
local rd = ngx.var.scheme .. "://" .. ngx.var.host .. ngx.var.request_uri
return ngx.redirect("/__proxy_auth/start?rd=" .. ngx.escape_uri(rd), 302)
end
-- Enforce whichever auth methods are enabled; allow if EITHER passes.
local function apply_auth(res)
local basic_on = res["basicauth_enabled"] == "true"
local sso_on = res["sso_enabled"] == "true"
if not basic_on and not sso_on then return end
if basic_on and basic_auth_ok(res) then return end
if sso_on and sso_session_ok() then return end
-- Not authenticated. Pick the right challenge for the client.
local auth = ngx.var.http_authorization
local has_basic_header = auth and auth:match("^%s*[Bb]asic%s") ~= nil
if sso_on and not has_basic_header then
return sso_redirect()
end
if basic_on then
return basic_challenge(res)
end
return sso_redirect()
end
-- Extra request headers sent to the upstream.
local function apply_req_headers(res)
local headers = decode_table(res["req_headers"])
@@ -97,6 +209,7 @@ function M.access(ngx_, res)
apply_ip_access(res, ip)
apply_ratelimit(res, host, ip)
apply_auth(res)
apply_req_headers(res)
-- Cache gate for proxy_no_cache / proxy_cache_bypass. Opt-in per host.
+7
View File
@@ -30,6 +30,13 @@ http {
resolver 8.8.4.4 8.8.8.8;
# Backend for per-host SSO endpoints (/__proxy_auth, see proxy.conf). Point
# this at the nodejs app that serves the admin UI. Default assumes it is
# colocated on this box; change the address for a split deployment.
upstream proxy_auth_backend {
server 127.0.0.1:3000;
}
init_by_lua_block {
auto_ssl = (require "resty.auto-ssl").new()
+13
View File
@@ -12,6 +12,19 @@ server {
real_ip_header X-Real-IP;
real_ip_recursive on;
# Per-host SSO endpoints (#57), served on EVERY proxied host by the nodejs app.
# This location deliberately sits OUTSIDE the auth gate in `location /` (so the
# login flow itself is never gated) and forwards to the app, which runs the
# OIDC flow and sets the __proxy_sso session cookie for this host.
location /__proxy_auth/ {
proxy_pass http://proxy_auth_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
set $target '';