Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60dbfe5b9b | |||
| ecdced16fc | |||
| aecf275031 | |||
| 3f46a807e5 | |||
| bb1b84b56d | |||
| 93cf034e61 | |||
| 2102b309de | |||
| c3fe25335f | |||
| 4321826dc8 | |||
| 21e295615b | |||
| 7452ccd655 | |||
| 5acea6fcc2 | |||
| fcd97b12aa |
@@ -6,6 +6,30 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.2] - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
- Multi-target load balancing (added in 1.2.0) crashed every request to a load-balanced host: `ops/nginx_conf/targetinfo.lua` required a nonexistent `resty.balancer.round_robin` module. The `lua-resty-balancer` rock installed by the Dockerfile/`install.sh` doesn't provide that path — it provides `resty.roundrobin` (constructed as `roundrobin:new(nodes)`, not `:new()` + `:reinit(nodes)`). Fixed `targetinfo.lua` to use the real module and API; verified end-to-end that requests now round-robin across targets with no Lua errors.
|
||||
|
||||
## [1.2.1] - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
- The bootstrap anti-lockout admin account was always created as `proxyadmin2` regardless of `conf.auth.adminUsers`, while `migrations/permission_bootstrap.js` grants the global-admin permission to `conf.auth.adminUsers[0]`. If an operator customized `adminUsers` away from the default, the bootstrapped account and the permissioned account were two different (non-matching) usernames, so the anti-lockout account ended up with no admin access. `models/user_redis.js` now derives the bootstrap username from `conf.auth.adminUsers[0]` (falling back to `proxyadmin2`), matching `permission_bootstrap.js`.
|
||||
- Corrected a `secrets.js.example` comment that claimed the bootstrap admin's password "defaults to the username itself" — it actually generates a random password printed to the container log on first boot.
|
||||
|
||||
### Changed
|
||||
- Refreshed all README screenshots (hosts, per-host SSO auth, per-host basic auth) against the current UI, and added a new load-balancing screenshot for the multi-target feature.
|
||||
|
||||
## [1.2.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
- Multi-target load balancing: hosts can now specify additional backend targets (`IP:port`, one per line) alongside the primary target; the proxy distributes requests across all of them round-robin via `lua-resty-balancer`. Fixes #47.
|
||||
|
||||
## [1.1.17] - 2026-07-20
|
||||
|
||||
### Fixed
|
||||
- An existing single-label subdomain host (e.g. `sso.nl.wgnode.com`) could not be attached to a wildcard cert added later (e.g. `*.nl.wgnode.com`): `Host.lookUpWildcardParent()` only checked the wildcard-as-child position (the wildcard's own base domain) and missed the far more common wildcard-as-sibling case, so the edit form's "Parent Wildcard" option stayed permanently greyed out. It now checks both positions, and a regression test covers the sibling case.
|
||||
|
||||
## [1.1.16] - 2026-07-18
|
||||
|
||||
### Changed
|
||||
@@ -13,6 +37,10 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
- Genericized committed defaults in `conf/base.js` and `conf/development.js`: LDAP now defaults to `ldap://localhost` with `dc=example,dc=com`, and OIDC endpoints default to `https://sso.example.com` instead of internal theta42 infrastructure.
|
||||
- The bootstrap `proxyadmin2` account now gets a random, one-time password when `auth.localAdminPass` is unset, instead of the well-known default `proxyadmin2`. The password is printed to the log on first creation and can be made deterministic by setting `auth.localAdminPass` in the secrets file.
|
||||
|
||||
### Security
|
||||
- Sanitized rendered docs HTML via `xss` in `routes/docs.js` so malicious markdown cannot inject scripts or other dangerous markup into the in-app docs viewer.
|
||||
- The Unix socket JSON-RPC socket is now created with mode `660` instead of world-writable `777`.
|
||||
|
||||
### Fixed
|
||||
- The global error handler no longer leaks `err.keys`, stack traces, or other internal details in JSON responses; only `name` and `message` are returned to clients.
|
||||
- `DEPLOYMENT.md` and `docs/docker.md` now correctly describe the `CONF_SECRETS` env-var mechanism instead of the old symlink behavior.
|
||||
@@ -123,7 +151,9 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
||||
- Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention.
|
||||
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
|
||||
|
||||
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.15...HEAD
|
||||
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.17...HEAD
|
||||
[1.1.17]: https://github.com/theta42/proxy/compare/v1.1.16...v1.1.17
|
||||
[1.1.16]: https://github.com/theta42/proxy/compare/v1.1.15...v1.1.16
|
||||
[1.1.15]: https://github.com/theta42/proxy/compare/v1.1.14...v1.1.15
|
||||
[1.1.14]: https://github.com/theta42/proxy/compare/v1.1.13...v1.1.14
|
||||
[1.1.13]: https://github.com/theta42/proxy/compare/v1.1.12...v1.1.13
|
||||
|
||||
@@ -83,6 +83,7 @@ RUN apt-get update \
|
||||
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
||||
RUN luarocks install lua-resty-auto-ssl \
|
||||
&& luarocks install luasocket \
|
||||
&& luarocks install lua-resty-balancer \
|
||||
&& luarocks install lua-resty-ipmatcher
|
||||
|
||||
# ── Node app ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,6 +31,10 @@ management once basic auth is enabled:
|
||||
|
||||
[](docs/images/host-auth-basic.png)
|
||||
|
||||
Multiple backend targets per host, load balanced round-robin:
|
||||
|
||||
[](docs/images/load-balancing.png)
|
||||
|
||||
## Why this over the alternatives
|
||||
|
||||
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
|
||||
@@ -51,6 +55,7 @@ provider + LDAP directory you already run.
|
||||
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
|
||||
- Wildcard SSL certificate support with automatic renewal
|
||||
- Dynamic host routing with wildcard domain matching (*, **)
|
||||
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||
- Web-based management interface
|
||||
- RESTful API for automation
|
||||
- **OIDC login** — the proxy is an OpenID Connect client of an external SSO
|
||||
|
||||
@@ -68,6 +68,10 @@ host form whenever the name you're entering already has a matching
|
||||
wildcard available to reuse — including the wildcard's own bare base
|
||||
domain (e.g. `example.com` itself, not just `something.example.com`).
|
||||
|
||||
## Load Balancing
|
||||
|
||||
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
|
||||
|
||||
## Want more detail?
|
||||
|
||||
This page skips the system-internals (Redis, OpenResty, the lookup service)
|
||||
|
||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 428 KiB |
@@ -50,6 +50,7 @@ LDAP directory you already run.
|
||||
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
|
||||
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
|
||||
- Dynamic host routing with wildcard domain matching (`*`, `**`)
|
||||
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||
- **OIDC login** and **direct LDAP lookups**, independently of each other
|
||||
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
|
||||
it's never ambiguous which one gated a request)
|
||||
|
||||
@@ -28,6 +28,7 @@ class Host extends Table{
|
||||
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||
'ip': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||
'targetPort': {isRequired: true, type: 'number', min:0, max:65535},
|
||||
'targets': {default: function(){return []}, isRequired: false, type: 'object'},
|
||||
'forcessl': {isRequired: false, default: true, type: 'boolean'},
|
||||
'targetssl': {isRequired: false, default: false, type: 'boolean'},
|
||||
|
||||
@@ -518,24 +519,38 @@ class Host extends Table{
|
||||
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||
}
|
||||
|
||||
// Find the wildcard covering @host as its own base domain (e.g.
|
||||
// "*.cool.mysite.com" for host="cool.mysite.com"), regardless of whether
|
||||
// @host is already registered as its own host. Unlike lookUp(), which
|
||||
// walks to and returns @host's own exact-match leaf when one exists, this
|
||||
// walks to that exact position and looks one level deeper at its "*"
|
||||
// child -- the sibling wildcard slot -- so it still finds the parent
|
||||
// wildcard even when @host already has its own (non-wildcard) record.
|
||||
// Used when attaching an already-created host to a wildcard after the
|
||||
// fact (see update() below); Host.create()'s own wildcardChild handling
|
||||
// can keep using plain lookUp() since a host being newly created hasn't
|
||||
// claimed its own leaf yet.
|
||||
// Find the wildcard that could cover @host, regardless of whether @host is
|
||||
// already registered as its own host. Unlike lookUp(), which walks to and
|
||||
// returns @host's own exact-match leaf when one exists, this keeps looking
|
||||
// for a sibling/child "*" slot, so it still finds the parent wildcard even
|
||||
// when @host already has its own (non-wildcard) record. Used when attaching
|
||||
// an already-created host to a wildcard after the fact (see update() below);
|
||||
// Host.create()'s own wildcardChild handling can keep using plain lookUp()
|
||||
// since a host being newly created hasn't claimed its own leaf yet.
|
||||
//
|
||||
// Two tree positions qualify, and we must check BOTH:
|
||||
// 1. Child "*" of @host's own node -- @host is the wildcard's base domain
|
||||
// (e.g. "*.cool.mysite.com" covers host="cool.mysite.com").
|
||||
// 2. Sibling "*" one level up -- @host is a single-label subdomain of the
|
||||
// wildcard (e.g. "*.nl.wgnode.com" covers host="sso.nl.wgnode.com").
|
||||
// Case 2 is the common one and was previously missed: the walk consumed the
|
||||
// leftmost label ("sso") and only inspected that leaf's "*" child, so an
|
||||
// already-existing sibling subdomain could never be attached to its wildcard.
|
||||
static lookUpWildcardParent(host){
|
||||
let place = this.lookUpObj;
|
||||
let parent = undefined;
|
||||
for(let fragment of host.split('.').reverse()){
|
||||
if(!place[fragment]) return undefined;
|
||||
// @host may have no leaf of its own (brand-new subdomain); that case
|
||||
// is already handled by plain lookUp()'s wildcard fallback in the
|
||||
// caller, so just stop -- we've still tracked `parent` for case 2.
|
||||
if(!place[fragment]){ place = undefined; break; }
|
||||
parent = place;
|
||||
place = place[fragment];
|
||||
}
|
||||
if(place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||
// Case 1: wildcard is a child of @host's own node.
|
||||
if(place && place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||
// Case 2: wildcard is a sibling of @host's leftmost label.
|
||||
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||
}
|
||||
|
||||
static async lookUpReady(){
|
||||
|
||||
@@ -87,7 +87,11 @@ class User extends Table{
|
||||
User.register();
|
||||
|
||||
(async function(){
|
||||
var defaultUser = 'proxyadmin2'
|
||||
// Matches migrations/permission_bootstrap.js: the anti-lockout account is
|
||||
// the first entry in conf.auth.adminUsers (default 'proxyadmin2'), NOT a
|
||||
// hardcoded name -- otherwise an operator who customizes adminUsers ends
|
||||
// up with a bootstrap account that has no admin permissions.
|
||||
var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'proxyadmin2';
|
||||
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
|
||||
// auth.localAdminPass in proxy-secrets.js to a generated password so this
|
||||
// bootstrap account isn't left at a well-known default. Only used on first
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.16",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.16",
|
||||
"version": "1.2.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.16",
|
||||
"version": "1.2.2",
|
||||
"author": [
|
||||
{
|
||||
"name": "William Mantly",
|
||||
|
||||
@@ -185,6 +185,35 @@ describe('Host wildcard base-domain lookup', () => {
|
||||
await populateTree(Host, ['*.cool.mysite.com']);
|
||||
assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined);
|
||||
});
|
||||
|
||||
// Regression: the common case -- an already-existing single-label subdomain
|
||||
// (its own auto-SSL/HTTP-01 host) sitting beside a wildcard, e.g.
|
||||
// sso.nl.wgnode.com under *.nl.wgnode.com. The wildcard is a SIBLING of the
|
||||
// subdomain's leftmost label, not a child of its node, so the old walk (which
|
||||
// consumed "sso" and only checked that leaf's "*" child) never found it and
|
||||
// the edit form's "Parent Wildcard" option stayed permanently greyed out.
|
||||
test('lookUpWildcardParent finds a sibling wildcard for an existing single-label subdomain', async () => {
|
||||
await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']);
|
||||
const result = Host.lookUpWildcardParent('sso.nl.wgnode.com');
|
||||
assert.ok(result, 'Should find the sibling wildcard');
|
||||
assert.strictEqual(result.host, '*.nl.wgnode.com');
|
||||
});
|
||||
|
||||
// A subdomain with no leaf of its own (never created) is deliberately NOT
|
||||
// this method's job -- the walk stops before reaching the sibling "*" slot.
|
||||
// The route resolves that case via plain lookUp()'s wildcard fallback first
|
||||
// (covered in the route-fallback describe block below).
|
||||
test('lookUpWildcardParent returns undefined for a subdomain with no leaf of its own', async () => {
|
||||
await populateTree(Host, ['*.nl.wgnode.com']);
|
||||
assert.strictEqual(Host.lookUpWildcardParent('api.nl.wgnode.com'), undefined);
|
||||
});
|
||||
|
||||
test('lookUpWildcardParent does not treat a deeper wildcard as covering a shallower host', async () => {
|
||||
// *.deep.nl.wgnode.com must NOT be offered as a parent for sso.nl.wgnode.com
|
||||
// (a single-level wildcard covers only its own direct children).
|
||||
await populateTree(Host, ['sso.nl.wgnode.com', '*.deep.nl.wgnode.com']);
|
||||
assert.strictEqual(Host.lookUpWildcardParent('sso.nl.wgnode.com'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -231,6 +260,24 @@ describe('Host wildcard-parent route fallback (lookUp then lookUpWildcardParent)
|
||||
await populateTree(Host, ['cool.mysite.com']);
|
||||
assert.strictEqual(findWildcardParent('cool.mysite.com'), null);
|
||||
});
|
||||
|
||||
// The user's scenario: sso.nl.wgnode.com already exists as its own host, and
|
||||
// a *.nl.wgnode.com wildcard is added afterward. lookUp() resolves to sso's
|
||||
// own (non-wildcard) leaf, so the fallback to lookUpWildcardParent() is what
|
||||
// surfaces the sibling wildcard and lets the edit form offer conversion.
|
||||
test('finds the sibling wildcard for an already-existing single-label subdomain', async () => {
|
||||
await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']);
|
||||
const result = findWildcardParent('sso.nl.wgnode.com');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.host, '*.nl.wgnode.com');
|
||||
});
|
||||
|
||||
test('finds the sibling wildcard for a never-created single-label subdomain', async () => {
|
||||
await populateTree(Host, ['*.nl.wgnode.com']);
|
||||
const result = findWildcardParent('api.nl.wgnode.com');
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.host, '*.nl.wgnode.com');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -265,11 +312,17 @@ function createMockHostClassWithWildcardParentFix() {
|
||||
|
||||
static lookUpWildcardParent(host) {
|
||||
let place = this.lookUpObj;
|
||||
let parent = undefined;
|
||||
for(let fragment of host.split('.').reverse()){
|
||||
if(!place[fragment]) return undefined;
|
||||
if(!place[fragment]){ place = undefined; break; }
|
||||
parent = place;
|
||||
place = place[fragment];
|
||||
}
|
||||
if(place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||
// Case 1: wildcard is a child of host's own node (base domain).
|
||||
if(place && place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||
// Case 2: wildcard is a sibling of host's leftmost label
|
||||
// (single-label subdomain, e.g. sso.nl.wgnode.com -> *.nl.wgnode.com).
|
||||
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,6 +252,7 @@ function normalizeHostFeatures(body){
|
||||
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('targets' in body) body.targets = parseAllowList(body.targets);
|
||||
|
||||
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);
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
});
|
||||
|
||||
$f.find("textarea[name='req_headers']").val(hostFeatureHeadersToText(h.req_headers));
|
||||
$f.find("textarea[name='targets']").val(hostFeatureListToText(h.targets));
|
||||
$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));
|
||||
@@ -632,6 +633,14 @@
|
||||
<small class="field-help text-muted d-block">Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="targets" class="form-label">Additional Targets (Load Balancing)</label>
|
||||
<textarea name="targets" class="form-control" rows="2" placeholder="10.0.0.2:8080 10.0.0.3:8080"></textarea>
|
||||
<small class="field-help text-muted d-block">Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS & Wildcard -->
|
||||
|
||||
@@ -123,6 +123,7 @@ apt-get install -y nodejs openresty
|
||||
echo "==> Lua modules"
|
||||
luarocks install lua-resty-auto-ssl
|
||||
luarocks install luasocket
|
||||
luarocks install lua-resty-balancer
|
||||
# CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua).
|
||||
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
||||
luarocks install lua-resty-ipmatcher
|
||||
|
||||
@@ -62,6 +62,7 @@ function M.get(ngx, domain, targetInfo)
|
||||
|
||||
local json = require "cjson"
|
||||
local redis = require "resty.redis"
|
||||
local roundrobin = require "resty.roundrobin"
|
||||
|
||||
if not domain then
|
||||
return nil, 499
|
||||
@@ -95,6 +96,44 @@ function M.get(ngx, domain, targetInfo)
|
||||
return nil, 406
|
||||
end
|
||||
|
||||
-- Load balancing
|
||||
local target_list = {}
|
||||
table.insert(target_list, res["ip"] .. ":" .. tostring(res["targetPort"]))
|
||||
|
||||
if res["targets"] and res["targets"] ~= "" and res["targets"] ~= "[]" then
|
||||
local decodeOk, decodedTargets = pcall(json.decode, res["targets"])
|
||||
if decodeOk and type(decodedTargets) == "table" then
|
||||
for _, t in ipairs(decodedTargets) do
|
||||
table.insert(target_list, t)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #target_list > 1 then
|
||||
if not M.host_balancers then M.host_balancers = {} end
|
||||
local cache_key = domain .. "_" .. (res["updated_on"] or "0")
|
||||
|
||||
if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then
|
||||
local nodes = {}
|
||||
for _, t in ipairs(target_list) do
|
||||
nodes[t] = 1
|
||||
end
|
||||
local b = roundrobin:new(nodes)
|
||||
M.host_balancers[domain] = { b = b, key = cache_key }
|
||||
end
|
||||
|
||||
local peer = M.host_balancers[domain].b:find()
|
||||
if peer then
|
||||
local colon = peer:find(":")
|
||||
if colon then
|
||||
res["ip"] = peer:sub(1, colon - 1)
|
||||
res["targetPort"] = peer:sub(colon + 1)
|
||||
else
|
||||
res["ip"] = peer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ngx.ctx.targetInfo = res
|
||||
-- Remember which host this target was resolved for, so the reuse guard at
|
||||
-- the top can tell a genuine cache hit from a coalesced request for a
|
||||
|
||||
@@ -67,12 +67,14 @@ module.exports = {
|
||||
adminUsers: ['proxyadmin'],
|
||||
groupRoleMap: {},
|
||||
// Optional: the local anti-lockout admin's initial password, used
|
||||
// ONLY the first time that account is created. Leave unset and it
|
||||
// defaults to the username itself ("proxyadmin2") — fine for a quick
|
||||
// local test, but change it (or set this) before exposing the proxy
|
||||
// publicly. Once the account exists, this key is never read again;
|
||||
// change the password via the app itself (or delete the Redis user
|
||||
// to force it to be re-bootstrapped with a new value here).
|
||||
// ONLY the first time that account is created. Leave unset and a
|
||||
// random password is generated and printed to the container log on
|
||||
// first boot — fine for a quick local test if you copy it from the
|
||||
// log right away, but set this (or change the password afterward)
|
||||
// before exposing the proxy publicly. Once the account exists, this
|
||||
// key is never read again; change the password via the app itself
|
||||
// (or delete the Redis user to force it to be re-bootstrapped with a
|
||||
// new value here).
|
||||
// localAdminPass: 'change-me',
|
||||
},
|
||||
|
||||
|
||||