Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60dbfe5b9b | |||
| ecdced16fc | |||
| aecf275031 | |||
| 3f46a807e5 | |||
| bb1b84b56d | |||
| 93cf034e61 | |||
| 2102b309de | |||
| c3fe25335f | |||
| 4321826dc8 |
@@ -6,6 +6,25 @@ 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
|
||||
|
||||
@@ -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'},
|
||||
|
||||
|
||||
@@ -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.17",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.17",
|
||||
"version": "1.2.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.17",
|
||||
"version": "1.2.2",
|
||||
"author": [
|
||||
{
|
||||
"name": "William Mantly",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||