Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93cf034e61 | |||
| 2102b309de | |||
| c3fe25335f | |||
| 4321826dc8 | |||
| 21e295615b | |||
| 7452ccd655 | |||
| 5acea6fcc2 | |||
| fcd97b12aa | |||
| 289a9587d6 | |||
| 6ede072213 | |||
| bdaba513a7 | |||
| d1dd40d60a |
+23
-1
@@ -6,6 +6,26 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
- Public-release packaging: removed `"private": true` from `nodejs/package.json`, corrected the repository URL to `https://github.com/theta42/proxy.git`, and fixed the MIT `LICENSE` copyright line.
|
||||
- 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.
|
||||
|
||||
## [1.1.15] - 2026-07-18
|
||||
|
||||
### Changed
|
||||
@@ -112,7 +132,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
|
||||
|
||||
+6
-5
@@ -75,11 +75,12 @@ $EDITOR config/proxy-secrets.js # set oidc.clientId/clientSecret, ldap.bindP
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`docker-entrypoint.sh` symlinks `/config/proxy-secrets.js` → `/app/conf/secrets.js`
|
||||
so `@simpleworkjs/conf` reads it. No `app_*` env is passed — `app_*` env would
|
||||
override the file (env beats secrets.js in `@simpleworkjs/conf`), so the file is
|
||||
kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` / `NODE_PORT` are
|
||||
OpenResty-runtime / process env, not `app_*` config, so they stay in the compose.
|
||||
`docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so
|
||||
`@simpleworkjs/conf` reads it directly. No `app_*` env is passed — `app_*` env
|
||||
would override the file (env beats secrets.js in `@simpleworkjs/conf`), so the
|
||||
file is kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` /
|
||||
`NODE_PORT` are OpenResty-runtime / process env, not `app_*` config, so they
|
||||
stay in the compose.
|
||||
|
||||
> Running the unified `theta-env` stack? Its `setup.sh` generates
|
||||
> `./config/proxy-secrets.js` (+ `./config/sso-secrets.js`) for you and
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) <year> <copyright holders>
|
||||
Copyright (c) 2026 theta42
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
@@ -51,6 +51,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)
|
||||
|
||||
+5
-5
@@ -39,11 +39,11 @@ which deep-merges, in order:
|
||||
3. `conf/secrets.js` (gitignored)
|
||||
4. **`app_*` environment variables** — the highest-precedence layer
|
||||
|
||||
The bundled `docker-compose.yml` mount `./config/proxy-secrets.js` at `/config`,
|
||||
and `docker-entrypoint.sh` symlinks it into `/app/conf/secrets.js` so the app
|
||||
reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is passed** —
|
||||
`app_*` env beats `secrets.js`, so the file is authoritative only if the matching
|
||||
`app_*` env is absent. See `secrets.js.example` for the shape.
|
||||
The bundled `docker-compose.yml` mounts `./config/proxy-secrets.js` at `/config`,
|
||||
and `docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so the
|
||||
app reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is
|
||||
passed** — `app_*` env beats `secrets.js`, so the file is authoritative only if
|
||||
the matching `app_*` env is absent. See `secrets.js.example` for the shape.
|
||||
|
||||
Any env var starting with `app_` overrides the merged config; the rest of the
|
||||
name splits on **double-underscore** (`__`) into a nested path. Values are
|
||||
|
||||
@@ -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)
|
||||
|
||||
+12
-6
@@ -100,15 +100,21 @@ app.use(async function(req, res, next) {
|
||||
|
||||
// Error handler. This is where `next()` will go on error
|
||||
app.use(async function(err, req, res, next) {
|
||||
try{
|
||||
console.error(err.status || res.status, err.name, req.method, req.url);
|
||||
try{
|
||||
const status = err.status || 500;
|
||||
console.error(status, err.name, req.method, req.url);
|
||||
console.error(err.message);
|
||||
console.error(err.stack);
|
||||
if (err.stack) console.error(err.stack);
|
||||
console.error('=========================================');
|
||||
|
||||
res.status(err.status || 500);
|
||||
res.json({name: err.name, message: err.message, keys: err.keys});
|
||||
res.status(status);
|
||||
// Only expose safe, non-internal fields to the client.
|
||||
const body = { name: err.name, message: err.message };
|
||||
res.json(body);
|
||||
}catch(error){
|
||||
console.log('error in the catch all error fn....', error);
|
||||
console.error('error in the catch-all error handler', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ name: 'Error', message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+8
-8
@@ -6,10 +6,10 @@ module.exports = {
|
||||
logo: "/static/img/theta42.svg", // shown in the nav; point at your own file under public/ (or an absolute URL) to white-label
|
||||
userModel: 'redis', // pam, redis, ldap
|
||||
ldap: {
|
||||
url: 'ldap://192.168.1.55:389',
|
||||
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com',
|
||||
url: 'ldap://localhost',
|
||||
bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
|
||||
bindPassword: '__IN SRECREST FILE__',
|
||||
searchBase: 'ou=people,dc=theta42,dc=com',
|
||||
searchBase: 'ou=people,dc=example,dc=com',
|
||||
userFilter: '(objectClass=inetOrgPerson)',
|
||||
userNameAttribute: 'uid'
|
||||
},
|
||||
@@ -29,11 +29,11 @@ module.exports = {
|
||||
// redirectUri MUST be registered on the SSO client and match exactly.
|
||||
oidc: {
|
||||
enabled: true,
|
||||
issuer: 'https://sso.theta42.com',
|
||||
authorizationEndpoint: 'https://sso.theta42.com/oauth/authorize',
|
||||
tokenEndpoint: 'https://sso.theta42.com/oauth/token',
|
||||
userinfoEndpoint: 'https://sso.theta42.com/oauth/userinfo',
|
||||
endSessionEndpoint: 'https://sso.theta42.com/oauth/logout',
|
||||
issuer: 'https://sso.example.com',
|
||||
authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
|
||||
tokenEndpoint: 'https://sso.example.com/oauth/token',
|
||||
userinfoEndpoint: 'https://sso.example.com/oauth/userinfo',
|
||||
endSessionEndpoint: 'https://sso.example.com/oauth/logout',
|
||||
clientId: '__SET_ME__',
|
||||
// Where the SSO sends the user back. Must be an absolute URL reachable
|
||||
// by the browser and registered on the SSO client.
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
module.exports = {
|
||||
userModel: 'redis', // pam, redis, ldap
|
||||
ldap: {
|
||||
url: 'ldap://192.168.1.55:389',
|
||||
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com',
|
||||
url: 'ldap://localhost',
|
||||
bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
|
||||
bindPassword: '__IN SRECREST FILE__',
|
||||
searchBase: 'ou=people,dc=theta42,dc=com',
|
||||
searchBase: 'ou=people,dc=example,dc=com',
|
||||
userFilter: '(objectClass=inetOrgPerson)',
|
||||
userNameAttribute: 'uid'
|
||||
},
|
||||
|
||||
+28
-13
@@ -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(){
|
||||
|
||||
@@ -90,10 +90,19 @@ User.register();
|
||||
var defaultUser = '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 the well-known default (username ==
|
||||
// password == "proxyadmin2"). Only used on first creation -- once the
|
||||
// account exists this is never read again, so it's safe to leave set.
|
||||
var defaultPass = (conf.auth && conf.auth.localAdminPass) || defaultUser;
|
||||
// bootstrap account isn't left at a well-known default. Only used on first
|
||||
// creation -- once the account exists this is never read again, so it's
|
||||
// safe to leave set. If unset, a random password is generated and printed
|
||||
// once; save it from the log or set auth.localAdminPass explicitly.
|
||||
var defaultPass = (conf.auth && conf.auth.localAdminPass);
|
||||
if (!defaultPass) {
|
||||
defaultPass = crypto.randomBytes(16).toString('hex');
|
||||
console.warn(`====================================================================`);
|
||||
console.warn(`Bootstrap admin "${defaultUser}" created with random password:`);
|
||||
console.warn(`${defaultPass}`);
|
||||
console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`);
|
||||
console.warn(`====================================================================`);
|
||||
}
|
||||
try{
|
||||
let user = await User.get(defaultUser);
|
||||
}catch(error){
|
||||
@@ -103,7 +112,7 @@ User.register();
|
||||
password: defaultPass,
|
||||
created_by: defaultUser
|
||||
});
|
||||
console.log(defaultUser, 'created', user);
|
||||
console.log(defaultUser, 'created');
|
||||
}catch(error){
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
Generated
+34
-5
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.17",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/conf": "^1.1.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"acme-client": "^5.4.0",
|
||||
"axios": "^1.13.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -21,7 +21,7 @@
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
"jq-repeat": "^2.1.0",
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^4.0.0",
|
||||
"ldapts": "^8.1.8",
|
||||
"linux-sys-user": "^1.2.0",
|
||||
@@ -32,7 +32,8 @@
|
||||
"p2psub": "^0.2.0",
|
||||
"redis": "^6.1.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"tld-extract": "^2.1.0"
|
||||
"tld-extract": "^2.1.0",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.11"
|
||||
@@ -611,6 +612,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
@@ -722,6 +729,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cssfilter": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
|
||||
"integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2251,6 +2264,22 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xss": {
|
||||
"version": "1.0.15",
|
||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||
"integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^2.20.3",
|
||||
"cssfilter": "0.0.10"
|
||||
},
|
||||
"bin": {
|
||||
"xss": "bin/xss"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.15",
|
||||
"private": true,
|
||||
"version": "1.1.17",
|
||||
"author": [
|
||||
{
|
||||
"name": "William Mantly",
|
||||
@@ -43,12 +42,13 @@
|
||||
"p2psub": "^0.2.0",
|
||||
"redis": "^6.1.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"tld-extract": "^2.1.0"
|
||||
"tld-extract": "^2.1.0",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.theta42.com/wmantly/proxy.git"
|
||||
"url": "https://github.com/theta42/proxy.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.11"
|
||||
|
||||
@@ -5,6 +5,7 @@ const path = require('path');
|
||||
const router = require('express').Router();
|
||||
const {rateLimit} = require('express-rate-limit');
|
||||
const {marked} = require('marked');
|
||||
const xss = require('xss');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('../utils/build_info');
|
||||
|
||||
@@ -140,7 +141,7 @@ router.get('/:slug', function(req, res, next) {
|
||||
docs: docList,
|
||||
currentSlug: req.params.slug,
|
||||
docTitle: doc.title,
|
||||
docHtml: fixDocLinks(fixImagePaths(marked(content))),
|
||||
docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,12 +27,14 @@ class SocketServerJson {
|
||||
this.onClientClose = new CallbackQueue(args.onClientClose);
|
||||
this.onClientError = new CallbackQueue(args.onClientError);
|
||||
|
||||
// Set socket file permissions after listening
|
||||
// 777 is acceptable here for single-use container environments
|
||||
// Wrapped in try-catch as chmod may fail in test/restricted environments
|
||||
// Set socket file permissions after listening. 660 (owner + group read/write)
|
||||
// is the safest default; the Docker image runs both processes as root, and
|
||||
// bare-metal operators should ensure the proxy service and openresty share a
|
||||
// group when running as separate users. Wrapped in try-catch as chmod may
|
||||
// fail in test/restricted environments.
|
||||
this.onListen.push(() => {
|
||||
try {
|
||||
fs.chmodSync(this.socketFile, '777');
|
||||
fs.chmodSync(this.socketFile, '660');
|
||||
} catch(err) {
|
||||
// Chmod may fail in test environments or certain filesystems
|
||||
// Socket will still work with default permissions
|
||||
|
||||
@@ -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 round_robin = require "resty.balancer.round_robin"
|
||||
|
||||
if not domain then
|
||||
return nil, 499
|
||||
@@ -95,6 +96,45 @@ 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 b = round_robin:new()
|
||||
local nodes = {}
|
||||
for _, t in ipairs(target_list) do
|
||||
nodes[t] = 1
|
||||
end
|
||||
b:reinit(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
|
||||
|
||||
Reference in New Issue
Block a user