Compare commits

..

26 Commits

Author SHA1 Message Date
wmantly aecf275031 Merge pull request #174 from theta42/release/v1.2.1
Release v1.2.1: fix bootstrap admin lockout, refresh screenshots
2026-07-21 02:23:06 -04:00
wmantly 3f46a807e5 Correct version to 1.2.1: v1.2.0 was already tagged for load balancing
The load-balancing feature (merged in #172/#173) was tagged v1.2.0 on
GitHub, but nodejs/package.json and CHANGELOG.md were never bumped for
it. Backfill a 1.2.0 CHANGELOG entry and bump this release to 1.2.1
instead of 1.1.18, so package.json/CHANGELOG stay ahead of the highest
existing tag rather than behind it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:07:28 -04:00
wmantly bb1b84b56d Release 1.1.18: fix bootstrap admin lockout, refresh screenshots
models/user_redis.js hardcoded the bootstrap anti-lockout admin
username to 'proxyadmin2', while migrations/permission_bootstrap.js
grants the global-admin permission to conf.auth.adminUsers[0]. An
operator who customized adminUsers away from the default ended up
with a bootstrapped account that had no admin permissions -- a
silent lockout. user_redis.js now derives the bootstrap username the
same way permission_bootstrap.js does.

Also corrected a secrets.js.example comment that claimed the
bootstrap password defaults to the username itself (it actually
generates and logs a random password), and refreshed all README
screenshots against the current UI, including a new load-balancing
screenshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:04:26 -04:00
wmantly 93cf034e61 Merge pull request #173 from theta42/docs/load-balancing
docs: Document load balancing feature
2026-07-21 00:59:09 -04:00
wmantly 2102b309de docs: Document load balancing feature 2026-07-21 00:58:27 -04:00
wmantly c3fe25335f Merge pull request #172 from theta42/feature/multi-target-load-balancing
feat: Multi-target load balancing support
2026-07-21 00:49:24 -04:00
wmantly 4321826dc8 feat: Add multi-target load balancing support
Fixes #47.
- Added lua-resty-balancer to dependencies (Dockerfile & install.sh).
- Added 'targets' field to the Host model to hold additional targets.
- Updated the UI to allow inputting additional targets (IP:port).
- Updated targetinfo.lua to parse the additional targets and load balance between them and the primary target using resty.balancer.round_robin.
2026-07-21 00:48:43 -04:00
wmantly 21e295615b Merge pull request #171 from theta42/release-1.1.17
Bump version to 1.1.17; update CHANGELOG
2026-07-20 00:29:06 -04:00
wmantly 7452ccd655 Bump version to 1.1.17; update CHANGELOG
Move the wildcard sibling-parent fix into its own 1.1.17 section (it is not
in the v1.1.16 tag) and bump nodejs/package.json + lockfile to 1.1.17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:27:48 -04:00
wmantly 5acea6fcc2 Merge pull request #170 from theta42/release/public-release-readiness
Fix attaching an existing sibling subdomain to a parent wildcard
2026-07-20 00:18:31 -04:00
wmantly fcd97b12aa Fix attaching an existing sibling subdomain to a parent wildcard
Host.lookUpWildcardParent() walked all labels of the host down to its own
leaf and only inspected that leaf's "*" child, so it found a wildcard
nested under the host (the base-domain case, e.g. *.cool.mysite.com for
cool.mysite.com) but missed the common case where the wildcard is a
SIBLING of the host's leftmost label (e.g. *.nl.wgnode.com covering an
already-existing sso.nl.wgnode.com). The /wildcard-parent route then
returned nothing and the edit form's "Parent Wildcard" option stayed
greyed out, leaving no way to convert an existing auto-SSL host onto a
wildcard issued afterward.

Track the parent node during the walk and check the sibling "*" slot too.
The never-created-subdomain case is unchanged (plain lookUp()'s wildcard
fallback in the route still handles it). Sync the test mock and add
regression coverage for the sibling case (existing host, never-created
host via the route fallback, and a deeper-wildcard negative case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:13:13 -04:00
wmantly 289a9587d6 chore(release): public-release readiness fixes for 1.1.16
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-18 23:14:36 -04:00
wmantly 6ede072213 security: swap sanitizer to xss for Jest-compatible SSR output
- Replace isomorphic-dompurify with xss to avoid ESM-only jsdom/htmlparser2
  transitive deps that break the existing Jest test suite.
- Keep rendered docs HTML sanitized via xss() in routes/docs.js.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 23:02:11 -04:00
wmantly bdaba513a7 security: sanitize markdown output and harden unix socket permissions
- Add isomorphic-dompurify to sanitize rendered docs HTML
- Tighten SocketServerJson socket permissions from 777 to 660
- Keep package.json version at 1.1.16

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:41:47 -04:00
wmantly d1dd40d60a chore(release): public-release readiness fixes for 1.1.16
- Fix MIT LICENSE copyright placeholder
- Remove private flag and correct GitHub repository URL in package.json
- Bump version to 1.1.16
- Genericize committed config defaults (example.com/localhost)
- Harden global error handler against information leakage
- Generate random initial password for proxyadmin2 bootstrap account
- Correct docs to describe CONF_SECRETS instead of symlink behavior

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:02:27 -04:00
wmantly c0e1aa666e Merge pull request #168 from theta42/release-1.1.15
Bump version to 1.1.15
2026-07-18 01:16:49 -04:00
wmantly f8d620f4d3 Bump version to 1.1.15; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:15:31 -04:00
wmantly 899c4d91d6 Merge pull request #167 from theta42/install-script-rework
Move bare-metal install path to /opt/theta42/proxy, secrets to /etc/proxy/secrets.js
2026-07-18 01:14:34 -04:00
wmantly 0f268fdcae install.sh: never block on an interactive debconf prompt
redis-server (and other base packages) can pull in tzdata as a fresh
dependency on a box that's never configured it, which prompts
interactively (geographic area / city) and hangs the installer with
no TTY attached -- confirmed by reproducing the hang in a clean
container. DEBIAN_FRONTEND=noninteractive avoids it, matching the
same fix applied to sso-manager-node's install.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:12:25 -04:00
wmantly da0ed0e2ad Move install path to /opt/theta42/proxy, secrets to /etc/proxy/secrets.js
- ops/install.sh now installs to /opt/theta42/proxy (was /var/www/proxy)
  and seeds /etc/proxy/secrets.js from secrets.js.example on first run
  (never overwritten on later runs), instead of requiring a manual
  nodejs/conf/secrets.js edit inside the repo checkout.
- ops/proxy.service points at the new install path and sets
  CONF_SECRETS=/etc/proxy/secrets.js (requires @simpleworkjs/conf >=
  1.2.0, already the pinned version) so the app picks up the secrets
  file with no symlink into the repo checkout.
- install.sh now prints the version it's updating from/to (or "Already
  up to date") on every run, instead of a silent update.
- Updated README/DEPLOYMENT/installation docs to match the new paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 00:47:51 -04:00
wmantly f0eadbc2d7 Merge pull request #166 from theta42/release-1.1.14
Bump version to 1.1.14
2026-07-17 23:43:25 -04:00
wmantly a02ca4d3e7 Bump version to 1.1.14; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:42:00 -04:00
wmantly 2ff2bf9ea7 Merge pull request #165 from theta42/bump-conf-jqrepeat
Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
2026-07-17 23:40:17 -04:00
wmantly 355a9d68e5 Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
conf 1.2.0 adds CONF_SECRETS, an env var to point at the secrets file
directly -- use it in the Docker entrypoint instead of symlinking the
mounted file into /app/conf/secrets.js, so the app no longer needs
write access to its own conf/ directory to pick up mounted secrets.
jq-repeat 2.2.0 is a compatible feature release (sort(), replace(),
faster leading-edge update() timing); no call-site changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:35:24 -04:00
wmantly 11f6c4df36 Merge pull request #164 from theta42/fix-changelog-corruption
Fix CHANGELOG.md corruption (v1.1.7-1.1.13 merged headings)
2026-07-17 22:21:30 -04:00
wmantly a567bf6c51 Fix CHANGELOG.md: restore separate headings for v1.1.7-1.1.13
Repeated Edit-tool bumps had been overwriting the previous top version
heading instead of inserting a new one above it, silently merging
each version's release notes into the next under one heading. All the
underlying content was still present in the file, just missing its
separating "## [X.Y.Z]" headers -- reconstructed the correct
structure with no content loss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 22:19:54 -04:00
30 changed files with 436 additions and 112 deletions
+60 -6
View File
@@ -6,6 +6,54 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [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
- 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
- `ops/install.sh` now installs to `/opt/theta42/proxy` (was `/var/www/proxy`) and seeds `/etc/proxy/secrets.js` from `secrets.js.example` on first run (never overwritten on later runs), instead of requiring a manual `nodejs/conf/secrets.js` edit inside the repo checkout. `ops/proxy.service` sets `CONF_SECRETS=/etc/proxy/secrets.js` to match.
- `install.sh` now prints the version it's updating from/to (or "Already up to date") on every run, instead of updating silently.
### Fixed
- `install.sh` could hang indefinitely on a fresh host if a base package pulled in `tzdata` as a new dependency — it prompted interactively for a timezone with no TTY attached. Set `DEBIAN_FRONTEND=noninteractive`.
## [1.1.14] - 2026-07-17
### Changed
- Bumped `@simpleworkjs/conf` to 1.2.0 and `jq-repeat` to 2.2.0. The Docker entrypoint now sets the new `CONF_SECRETS` env var to point directly at a mounted `proxy-secrets.js` instead of symlinking it into `/app/conf/secrets.js` — the app no longer needs write access to its own `conf/` directory to pick up mounted secrets.
## [1.1.13] - 2026-07-17 ## [1.1.13] - 2026-07-17
### Added ### Added
@@ -14,24 +62,24 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
### Fixed ### Fixed
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL (by registered slug, falling back to the doc's real filename), the same way image paths already were. - The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL (by registered slug, falling back to the doc's real filename), the same way image paths already were.
Bumps to v1.1.13. ## [1.1.12] - 2026-07-17
### Fixed ### Fixed
- The host edit form's "Parent Wildcard" option stayed greyed out even when a valid wildcard actually existed for that host, so an already-created host could never be switched onto one from the edit modal (only brand-new hosts, via the field's `keyup` handler, ever saw it become available). The underlying `/host/lookup/:item` check also had the same self-match issue as the recently-fixed backend bug: it resolved an already-existing host to its own record instead of a sibling wildcard. Added a dedicated `/host/wildcard-parent/:item` endpoint that checks both directions, and the edit form now actually runs the check when it opens. - The host edit form's "Parent Wildcard" option stayed greyed out even when a valid wildcard actually existed for that host, so an already-created host could never be switched onto one from the edit modal (only brand-new hosts, via the field's `keyup` handler, ever saw it become available). The underlying `/host/lookup/:item` check also had the same self-match issue as the recently-fixed backend bug: it resolved an already-existing host to its own record instead of a sibling wildcard. Added a dedicated `/host/wildcard-parent/:item` endpoint that checks both directions, and the edit form now actually runs the check when it opens.
- Fixed an nginx startup warning: `the "listen ... http2" directive is deprecated, use the "http2" directive instead`. Migrated to the standalone `http2 on;` directive (nginx 1.25.1+). - Fixed an nginx startup warning: `the "listen ... http2" directive is deprecated, use the "http2" directive instead`. Migrated to the standalone `http2 on;` directive (nginx 1.25.1+).
Bumps to v1.1.12. ## [1.1.11] - 2026-07-17
### Changed ### Changed
- Moved the help (❓) link out of the global header and onto each relevant card individually (Proxy List, Add/Edit host, Add DNS Provider, Dynamic A Records, Add New User, User List, Add Permission, Permissions, Add Group) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon. - Moved the help (❓) link out of the global header and onto each relevant card individually (Proxy List, Add/Edit host, Add DNS Provider, Dynamic A Records, Add New User, User List, Add Permission, Permissions, Add Group) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
Bumps to v1.1.11. ## [1.1.10] - 2026-07-17
### Added ### Added
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere). - A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access. - The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
Bumps to v1.1.10. ## [1.1.9] - 2026-07-17
### Added ### Added
- The host list now shows who created each host, and when. - The host list now shows who created each host, and when.
@@ -42,7 +90,7 @@ Bumps to v1.1.10.
- The host create/edit modal's tabs could overflow awkwardly on narrow (mobile) screens — they now scroll horizontally instead. - The host create/edit modal's tabs could overflow awkwardly on narrow (mobile) screens — they now scroll horizontally instead.
- Fixed a bug in the vendored `model-redis` library's record-rename path: renaming a record's primary key while another `always`-type field (e.g. `updated_on`) is defined earlier in the schema left a stray, incomplete hash behind under the old key, making that name permanently unavailable for reuse. Worked around in `Host.prototype.update()`. - Fixed a bug in the vendored `model-redis` library's record-rename path: renaming a record's primary key while another `always`-type field (e.g. `updated_on`) is defined earlier in the schema left a stray, incomplete hash behind under the old key, making that name permanently unavailable for reuse. Worked around in `Host.prototype.update()`.
Bumps to v1.1.9. ## [1.1.8] - 2026-07-17
### Fixed ### Fixed
- **Couldn't attach an existing host to a parent wildcard.** The host edit form's "Parent Wildcard" option submitted correctly, but `Host.prototype.update()` had no `challengeType` handling at all (only `Host.create()` did) — selecting it and saving silently did nothing. Added the same wildcard-parent lookup to `update()`. - **Couldn't attach an existing host to a parent wildcard.** The host edit form's "Parent Wildcard" option submitted correctly, but `Host.prototype.update()` had no `challengeType` handling at all (only `Host.create()` did) — selecting it and saving silently did nothing. Added the same wildcard-parent lookup to `update()`.
@@ -50,6 +98,8 @@ Bumps to v1.1.9.
Both required a corrected lookup: attaching an *existing* host (which already has its own tree leaf) needed a new `Host.lookUpWildcardParent()` that checks the sibling wildcard slot instead of resolving to the host's own record. Both required a corrected lookup: attaching an *existing* host (which already has its own tree leaf) needed a new `Host.lookUpWildcardParent()` that checks the sibling wildcard slot instead of resolving to the host's own record.
## [1.1.7] - 2026-07-16
### Changed ### Changed
- Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout. - Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout.
@@ -96,7 +146,11 @@ 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. - 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. - Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.13...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 [1.1.13]: https://github.com/theta42/proxy/compare/v1.1.12...v1.1.13
[1.1.12]: https://github.com/theta42/proxy/compare/v1.1.11...v1.1.12 [1.1.12]: https://github.com/theta42/proxy/compare/v1.1.11...v1.1.12
[1.1.11]: https://github.com/theta42/proxy/compare/v1.1.10...v1.1.11 [1.1.11]: https://github.com/theta42/proxy/compare/v1.1.10...v1.1.11
+20 -10
View File
@@ -75,11 +75,12 @@ $EDITOR config/proxy-secrets.js # set oidc.clientId/clientSecret, ldap.bindP
docker compose up -d --build docker compose up -d --build
``` ```
`docker-entrypoint.sh` symlinks `/config/proxy-secrets.js` `/app/conf/secrets.js` `docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so
so `@simpleworkjs/conf` reads it. No `app_*` env is passed — `app_*` env would `@simpleworkjs/conf` reads it directly. No `app_*` env is passed — `app_*` env
override the file (env beats secrets.js in `@simpleworkjs/conf`), so the file is would override the file (env beats secrets.js in `@simpleworkjs/conf`), so the
kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` / `NODE_PORT` are file is kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` /
OpenResty-runtime / process env, not `app_*` config, so they stay in the compose. `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 > Running the unified `theta-env` stack? Its `setup.sh` generates
> `./config/proxy-secrets.js` (+ `./config/sso-secrets.js`) for you and > `./config/proxy-secrets.js` (+ `./config/sso-secrets.js`) for you and
@@ -227,16 +228,25 @@ docker compose logs --tail=200 --since=10m proxy # recent context
`ops/install.sh` is an idempotent installer: it installs Node.js 22.x, OpenResty `ops/install.sh` is an idempotent installer: it installs Node.js 22.x, OpenResty
(from openresty.org), Lua modules (luarocks), Redis, force-syncs the repo to (from openresty.org), Lua modules (luarocks), Redis, force-syncs the repo to
`/var/www/proxy`, symlinks the OpenResty + systemd config from the repo, and `/opt/theta42/proxy`, symlinks the OpenResty + systemd config from the repo, and
starts `proxy.service`. Re-run it to update. starts `proxy.service`. Re-run it to update — it prints the version you're
updating from and to (or "Already up to date" if there's nothing new).
```bash
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
```
or, if you already have the repo checked out:
```bash ```bash
sudo ./ops/install.sh sudo ./ops/install.sh
``` ```
Configuration is file-based: write `nodejs/conf/secrets.js` with the OIDC + Configuration is file-based: on first run the installer seeds
LDAP values (see `nodejs/conf/base.js` for the shape), then `/etc/proxy/secrets.js` from `secrets.js.example` (placeholders you must fill
`sudo systemctl restart proxy`. in — OIDC + LDAP values, see `nodejs/conf/base.js` for the shape). Edit it,
then `sudo systemctl restart proxy`. Later runs never touch an existing
secrets file.
--- ---
+1
View File
@@ -83,6 +83,7 @@ RUN apt-get update \
# resty.limit.req is bundled with OpenResty, so no rock is needed for it. # resty.limit.req is bundled with OpenResty, so no rock is needed for it.
RUN luarocks install lua-resty-auto-ssl \ RUN luarocks install lua-resty-auto-ssl \
&& luarocks install luasocket \ && luarocks install luasocket \
&& luarocks install lua-resty-balancer \
&& luarocks install lua-resty-ipmatcher && luarocks install lua-resty-ipmatcher
# ── Node app ───────────────────────────────────────────────────────────────── # ── Node app ─────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License 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: 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:
+22 -3
View File
@@ -31,6 +31,10 @@ management once basic auth is enabled:
[![Per-host basic auth](docs/images/host-auth-basic.png)](docs/images/host-auth-basic.png) [![Per-host basic auth](docs/images/host-auth-basic.png)](docs/images/host-auth-basic.png)
Multiple backend targets per host, load balanced round-robin:
[![Load balancing](docs/images/load-balancing.png)](docs/images/load-balancing.png)
## Why this over the alternatives ## Why this over the alternatives
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with 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) - Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
- Wildcard SSL certificate support with automatic renewal - Wildcard SSL certificate support with automatic renewal
- Dynamic host routing with wildcard domain matching (*, **) - 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 - Web-based management interface
- RESTful API for automation - RESTful API for automation
- **OIDC login** — the proxy is an OpenID Connect client of an external SSO - **OIDC login** — the proxy is an OpenID Connect client of an external SSO
@@ -127,10 +132,15 @@ This installer will:
- Install and configure Redis - Install and configure Redis
- Set up SSL fallback certificates - Set up SSL fallback certificates
- Install Lua dependencies (lua-resty-auto-ssl, luasocket) - Install Lua dependencies (lua-resty-auto-ssl, luasocket)
- Clone and install the proxy application - Clone/update the proxy application at `/opt/theta42/proxy`
- Seed `/etc/proxy/secrets.js` on first run (edit it, then re-run or `systemctl restart proxy`)
- Configure systemd service - Configure systemd service
- Start the proxy service - Start the proxy service
It's idempotent and safe to re-run — re-running it updates the app in place and
prints the version you're updating from and to (e.g. `Updated v1.1.13 ->
v1.1.14`), or `Already up to date` if there's nothing new.
## Logs (Docker) ## Logs (Docker)
The all-in-one image runs OpenResty in the foreground and the Node app in the The all-in-one image runs OpenResty in the foreground and the Node app in the
@@ -224,15 +234,24 @@ cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
Clone and install: Clone and install:
```bash ```bash
cd /var/www mkdir -p /opt/theta42
cd /opt/theta42
git clone https://github.com/theta42/proxy.git git clone https://github.com/theta42/proxy.git
cd proxy/nodejs cd proxy/nodejs
npm install npm install
``` ```
Configure secrets:
```bash
mkdir -p /etc/proxy
cp ../secrets.js.example /etc/proxy/secrets.js
chmod 600 /etc/proxy/secrets.js
$EDITOR /etc/proxy/secrets.js
```
Create systemd service: Create systemd service:
```bash ```bash
cp ops/proxy.service /etc/systemd/system/proxy.service cp ../ops/proxy.service /etc/systemd/system/proxy.service
systemctl daemon-reload systemctl daemon-reload
systemctl enable proxy.service systemctl enable proxy.service
systemctl start proxy.service systemctl start proxy.service
+10 -9
View File
@@ -9,13 +9,14 @@
# 3. OpenResty (80/443/4443) — exec'd in the foreground as PID 2 (under # 3. OpenResty (80/443/4443) — exec'd in the foreground as PID 2 (under
# dumb-init, PID 1) so it receives SIGTERM from `docker stop`. # dumb-init, PID 1) so it receives SIGTERM from `docker stop`.
# #
# The app reads its config from conf/base.js deep-merged with conf/secrets.js # The app reads its config from conf/base.js deep-merged with a secrets file
# and `app_*` env vars (requires @simpleworkjs/conf >= 1.1.0, pinned in # and `app_*` env vars (requires @simpleworkjs/conf >= 1.2.0, pinned in
# nodejs/package-lock.json). No secrets.js is baked into the image. The unified # nodejs/package-lock.json). No secrets.js is baked into the image. The unified
# theta-env stack mounts ./config/proxy-secrets.js at /config; this entrypoint # theta-env stack mounts ./config/proxy-secrets.js at /config; this entrypoint
# symlinks it into /app/conf/secrets.js so the app reads oidc/ldap/auth config # points CONF_SECRETS at it so the app reads oidc/ldap/auth config straight
# from the file (no app_* env needed). Without the mount, supply the same config # from the mounted file (no app_* env needed, no write access to /app/conf
# via `app_*` env (compose `environment:` / `env_file:`). # required). Without the mount, supply the same config via `app_*` env
# (compose `environment:` / `env_file:`).
# #
# OpenResty config: the committed ops/nginx_conf/*.conf carry the bare-metal # OpenResty config: the committed ops/nginx_conf/*.conf carry the bare-metal
# home-LAN values (set_real_ip_from 192.168.1.0/24; resolver 192.168.1.1). They # home-LAN values (set_real_ip_from 192.168.1.0/24; resolver 192.168.1.1). They
@@ -30,14 +31,14 @@ error() { echo "[ERROR] $*" >&2; }
# ── Optional: mount proxy secrets.js ───────────────────────────────────────── # ── Optional: mount proxy secrets.js ─────────────────────────────────────────
# When /config/proxy-secrets.js is present (unified theta-env stack, or any # When /config/proxy-secrets.js is present (unified theta-env stack, or any
# deployment that bind-mounts ./config), symlink it into /app/conf/secrets.js so # deployment that bind-mounts ./config), point CONF_SECRETS at it so
# @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_* env # @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_*
# should then be passed — app_* env beats secrets.js in @simpleworkjs/conf # env should then be passed — app_* env beats secrets.js in @simpleworkjs/conf
# (precedence: base.js < <env>.js < secrets.js < app_* env), so the file is # (precedence: base.js < <env>.js < secrets.js < app_* env), so the file is
# authoritative only if the matching app_* env is absent. When the file is # authoritative only if the matching app_* env is absent. When the file is
# absent the app falls back to app_* env (compose environment / env_file). # absent the app falls back to app_* env (compose environment / env_file).
if [[ -f /config/proxy-secrets.js ]]; then if [[ -f /config/proxy-secrets.js ]]; then
ln -sf /config/proxy-secrets.js /app/conf/secrets.js export CONF_SECRETS=/config/proxy-secrets.js
info "Loaded config from /config/proxy-secrets.js (secrets.js authoritative)" info "Loaded config from /config/proxy-secrets.js (secrets.js authoritative)"
fi fi
+4
View File
@@ -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 wildcard available to reuse — including the wildcard's own bare base
domain (e.g. `example.com` itself, not just `something.example.com`). 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? ## Want more detail?
This page skips the system-internals (Redis, OpenResty, the lookup service) This page skips the system-internals (Redis, OpenResty, the lookup service)
+5 -5
View File
@@ -39,11 +39,11 @@ which deep-merges, in order:
3. `conf/secrets.js` (gitignored) 3. `conf/secrets.js` (gitignored)
4. **`app_*` environment variables** — the highest-precedence layer 4. **`app_*` environment variables** — the highest-precedence layer
The bundled `docker-compose.yml` mount `./config/proxy-secrets.js` at `/config`, The bundled `docker-compose.yml` mounts `./config/proxy-secrets.js` at `/config`,
and `docker-entrypoint.sh` symlinks it into `/app/conf/secrets.js` so the app and `docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so the
reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is passed** app reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is
`app_*` env beats `secrets.js`, so the file is authoritative only if the matching passed** — `app_*` env beats `secrets.js`, so the file is authoritative only if
`app_*` env is absent. See `secrets.js.example` for the shape. 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 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 name splits on **double-underscore** (`__`) into a nested path. Values are
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

+1
View File
@@ -50,6 +50,7 @@ LDAP directory you already run.
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges - Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free) - Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
- Dynamic host routing with wildcard domain matching (`*`, `**`) - 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 - **OIDC login** and **direct LDAP lookups**, independently of each other
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so - Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
it's never ambiguous which one gated a request) it's never ambiguous which one gated a request)
+16 -3
View File
@@ -146,7 +146,8 @@ openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
Clone the repository and copy configuration files: Clone the repository and copy configuration files:
```bash ```bash
cd /var/www mkdir -p /opt/theta42
cd /opt/theta42
git clone https://github.com/theta42/proxy.git git clone https://github.com/theta42/proxy.git
cd proxy cd proxy
@@ -161,14 +162,26 @@ cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
### Step 7: Install Application ### Step 7: Install Application
```bash ```bash
cd /var/www/proxy/nodejs cd /opt/theta42/proxy/nodejs
npm install npm install
``` ```
### Step 7b: Configure Secrets
```bash
mkdir -p /etc/proxy
cp /opt/theta42/proxy/secrets.js.example /etc/proxy/secrets.js
chmod 600 /etc/proxy/secrets.js
$EDITOR /etc/proxy/secrets.js # set oidc.clientId/clientSecret, ldap.bindPassword, ...
```
`@simpleworkjs/conf` reads this file via the `CONF_SECRETS` env var, which the
systemd unit below sets to `/etc/proxy/secrets.js`.
### Step 8: Configure Systemd Service ### Step 8: Configure Systemd Service
```bash ```bash
cp /var/www/proxy/ops/proxy.service /etc/systemd/system/proxy.service cp /opt/theta42/proxy/ops/proxy.service /etc/systemd/system/proxy.service
systemctl daemon-reload systemctl daemon-reload
systemctl enable proxy.service systemctl enable proxy.service
systemctl start proxy.service systemctl start proxy.service
+12 -6
View File
@@ -100,15 +100,21 @@ app.use(async function(req, res, next) {
// Error handler. This is where `next()` will go on error // Error handler. This is where `next()` will go on error
app.use(async function(err, req, res, next) { app.use(async function(err, req, res, next) {
try{ try{
console.error(err.status || res.status, err.name, req.method, req.url); const status = err.status || 500;
console.error(status, err.name, req.method, req.url);
console.error(err.message); console.error(err.message);
console.error(err.stack); if (err.stack) console.error(err.stack);
console.error('========================================='); console.error('=========================================');
res.status(err.status || 500); res.status(status);
res.json({name: err.name, message: err.message, keys: err.keys}); // Only expose safe, non-internal fields to the client.
const body = { name: err.name, message: err.message };
res.json(body);
}catch(error){ }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
View File
@@ -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 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 userModel: 'redis', // pam, redis, ldap
ldap: { ldap: {
url: 'ldap://192.168.1.55:389', url: 'ldap://localhost',
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com', bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
bindPassword: '__IN SRECREST FILE__', bindPassword: '__IN SRECREST FILE__',
searchBase: 'ou=people,dc=theta42,dc=com', searchBase: 'ou=people,dc=example,dc=com',
userFilter: '(objectClass=inetOrgPerson)', userFilter: '(objectClass=inetOrgPerson)',
userNameAttribute: 'uid' userNameAttribute: 'uid'
}, },
@@ -29,11 +29,11 @@ module.exports = {
// redirectUri MUST be registered on the SSO client and match exactly. // redirectUri MUST be registered on the SSO client and match exactly.
oidc: { oidc: {
enabled: true, enabled: true,
issuer: 'https://sso.theta42.com', issuer: 'https://sso.example.com',
authorizationEndpoint: 'https://sso.theta42.com/oauth/authorize', authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
tokenEndpoint: 'https://sso.theta42.com/oauth/token', tokenEndpoint: 'https://sso.example.com/oauth/token',
userinfoEndpoint: 'https://sso.theta42.com/oauth/userinfo', userinfoEndpoint: 'https://sso.example.com/oauth/userinfo',
endSessionEndpoint: 'https://sso.theta42.com/oauth/logout', endSessionEndpoint: 'https://sso.example.com/oauth/logout',
clientId: '__SET_ME__', clientId: '__SET_ME__',
// Where the SSO sends the user back. Must be an absolute URL reachable // Where the SSO sends the user back. Must be an absolute URL reachable
// by the browser and registered on the SSO client. // by the browser and registered on the SSO client.
+3 -3
View File
@@ -4,10 +4,10 @@
module.exports = { module.exports = {
userModel: 'redis', // pam, redis, ldap userModel: 'redis', // pam, redis, ldap
ldap: { ldap: {
url: 'ldap://192.168.1.55:389', url: 'ldap://localhost',
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com', bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
bindPassword: '__IN SRECREST FILE__', bindPassword: '__IN SRECREST FILE__',
searchBase: 'ou=people,dc=theta42,dc=com', searchBase: 'ou=people,dc=example,dc=com',
userFilter: '(objectClass=inetOrgPerson)', userFilter: '(objectClass=inetOrgPerson)',
userNameAttribute: 'uid' userNameAttribute: 'uid'
}, },
+28 -13
View File
@@ -28,6 +28,7 @@ class Host extends Table{
'host': {isRequired: true, type: 'string', min: 1, max: 500}, 'host': {isRequired: true, type: 'string', min: 1, max: 500},
'ip': {isRequired: true, type: 'string', min: 3, max: 500}, 'ip': {isRequired: true, type: 'string', min: 3, max: 500},
'targetPort': {isRequired: true, type: 'number', min:0, max:65535}, 'targetPort': {isRequired: true, type: 'number', min:0, max:65535},
'targets': {default: function(){return []}, isRequired: false, type: 'object'},
'forcessl': {isRequired: false, default: true, type: 'boolean'}, 'forcessl': {isRequired: false, default: true, type: 'boolean'},
'targetssl': {isRequired: false, default: false, 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']; if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
} }
// Find the wildcard covering @host as its own base domain (e.g. // Find the wildcard that could cover @host, regardless of whether @host is
// "*.cool.mysite.com" for host="cool.mysite.com"), regardless of whether // already registered as its own host. Unlike lookUp(), which walks to and
// @host is already registered as its own host. Unlike lookUp(), which // returns @host's own exact-match leaf when one exists, this keeps looking
// walks to and returns @host's own exact-match leaf when one exists, this // for a sibling/child "*" slot, so it still finds the parent wildcard even
// walks to that exact position and looks one level deeper at its "*" // when @host already has its own (non-wildcard) record. Used when attaching
// child -- the sibling wildcard slot -- so it still finds the parent // an already-created host to a wildcard after the fact (see update() below);
// wildcard even when @host already has its own (non-wildcard) record. // Host.create()'s own wildcardChild handling can keep using plain lookUp()
// Used when attaching an already-created host to a wildcard after the // since a host being newly created hasn't claimed its own leaf yet.
// fact (see update() below); Host.create()'s own wildcardChild handling //
// can keep using plain lookUp() since a host being newly created hasn't // Two tree positions qualify, and we must check BOTH:
// claimed its own leaf yet. // 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){ static lookUpWildcardParent(host){
let place = this.lookUpObj; let place = this.lookUpObj;
let parent = undefined;
for(let fragment of host.split('.').reverse()){ 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]; 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(){ static async lookUpReady(){
+19 -6
View File
@@ -87,13 +87,26 @@ class User extends Table{
User.register(); User.register();
(async function(){ (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 // Optional: an orchestrator (e.g. theta-env's setup.sh) can set
// auth.localAdminPass in proxy-secrets.js to a generated password so this // auth.localAdminPass in proxy-secrets.js to a generated password so this
// bootstrap account isn't left at the well-known default (username == // bootstrap account isn't left at a well-known default. Only used on first
// password == "proxyadmin2"). Only used on first creation -- once the // creation -- once the account exists this is never read again, so it's
// account exists this is never read again, so it's safe to leave set. // safe to leave set. If unset, a random password is generated and printed
var defaultPass = (conf.auth && conf.auth.localAdminPass) || defaultUser; // 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{ try{
let user = await User.get(defaultUser); let user = await User.get(defaultUser);
}catch(error){ }catch(error){
@@ -103,7 +116,7 @@ User.register();
password: defaultPass, password: defaultPass,
created_by: defaultUser created_by: defaultUser
}); });
console.log(defaultUser, 'created', user); console.log(defaultUser, 'created');
}catch(error){ }catch(error){
console.error(error) console.error(error)
} }
+40 -11
View File
@@ -1,17 +1,17 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.13", "version": "1.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.13", "version": "1.2.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.1.0", "@simpleworkjs/conf": "^1.2.0",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -21,7 +21,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
"jq-repeat": "^2.1.0", "jq-repeat": "^2.2.0",
"jquery": "^4.0.0", "jquery": "^4.0.0",
"ldapts": "^8.1.8", "ldapts": "^8.1.8",
"linux-sys-user": "^1.2.0", "linux-sys-user": "^1.2.0",
@@ -32,7 +32,8 @@
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
"redis": "^6.1.0", "redis": "^6.1.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"tld-extract": "^2.1.0" "tld-extract": "^2.1.0",
"xss": "^1.0.15"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.11" "nodemon": "^3.1.11"
@@ -281,9 +282,9 @@
} }
}, },
"node_modules/@simpleworkjs/conf": { "node_modules/@simpleworkjs/conf": {
"version": "1.1.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
"integrity": "sha512-MKRQQ4JAH2tbEm87NdkmfikTT58Tyk/SFbvCC7zKja0bK6j8zYyBXTQUJ0rnvFOVEalDWd/au4AEiptOCEqgvA==", "integrity": "sha512-X4u1oRb0A0x7wzmyiIH5hPYYIFJYUXhYVe9CPX6G6INouRIeZuHlx0pthHlihiAAIc3+KqZBx18qirFN8RoJwA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"extend": "^3.0.2" "extend": "^3.0.2"
@@ -611,6 +612,12 @@
"node": ">= 0.8" "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": { "node_modules/compressible": {
"version": "2.0.18", "version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -722,6 +729,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1375,9 +1388,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/jq-repeat": { "node_modules/jq-repeat": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.1.0.tgz", "resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
"integrity": "sha512-e1OmSWeBEHEtyOhNVysx0bnT5wd6HlZ37JZgPcGPmACJ0K9bXDPq0xOwrM1slQMSTw7FOSNDX+MD6VwvPeeZyQ==", "integrity": "sha512-OdKAQJ8SOTZzoNL/76o5+WJehXnMCoP8aXbDtZCmDh3vuGGdXfN14FkPTqLpZC5xmlv+QVfTXu/UaIRsDjVuhA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -2251,6 +2264,22 @@
"optional": true "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"
}
} }
} }
} }
+6 -6
View File
@@ -1,7 +1,6 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.13", "version": "1.2.1",
"private": true,
"author": [ "author": [
{ {
"name": "William Mantly", "name": "William Mantly",
@@ -22,7 +21,7 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.1.0", "@simpleworkjs/conf": "^1.2.0",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -32,7 +31,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
"jq-repeat": "^2.1.0", "jq-repeat": "^2.2.0",
"jquery": "^4.0.0", "jquery": "^4.0.0",
"ldapts": "^8.1.8", "ldapts": "^8.1.8",
"linux-sys-user": "^1.2.0", "linux-sys-user": "^1.2.0",
@@ -43,12 +42,13 @@
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
"redis": "^6.1.0", "redis": "^6.1.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"tld-extract": "^2.1.0" "tld-extract": "^2.1.0",
"xss": "^1.0.15"
}, },
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.theta42.com/wmantly/proxy.git" "url": "https://github.com/theta42/proxy.git"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.11" "nodemon": "^3.1.11"
+2 -1
View File
@@ -5,6 +5,7 @@ const path = require('path');
const router = require('express').Router(); const router = require('express').Router();
const {rateLimit} = require('express-rate-limit'); const {rateLimit} = require('express-rate-limit');
const {marked} = require('marked'); const {marked} = require('marked');
const xss = require('xss');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info'); const buildInfo = require('../utils/build_info');
@@ -140,7 +141,7 @@ router.get('/:slug', function(req, res, next) {
docs: docList, docs: docList,
currentSlug: req.params.slug, currentSlug: req.params.slug,
docTitle: doc.title, docTitle: doc.title,
docHtml: fixDocLinks(fixImagePaths(marked(content))), docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
}); });
} catch (error) { } catch (error) {
next(error); next(error);
+55 -2
View File
@@ -185,6 +185,35 @@ describe('Host wildcard base-domain lookup', () => {
await populateTree(Host, ['*.cool.mysite.com']); await populateTree(Host, ['*.cool.mysite.com']);
assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined); 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']); await populateTree(Host, ['cool.mysite.com']);
assert.strictEqual(findWildcardParent('cool.mysite.com'), null); 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) { static lookUpWildcardParent(host) {
let place = this.lookUpObj; let place = this.lookUpObj;
let parent = undefined;
for(let fragment of host.split('.').reverse()){ for(let fragment of host.split('.').reverse()){
if(!place[fragment]) return undefined; if(!place[fragment]){ place = undefined; break; }
parent = place;
place = place[fragment]; 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'];
} }
}; };
} }
+1
View File
@@ -252,6 +252,7 @@ function normalizeHostFeatures(body){
if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled); 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_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('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_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); if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
+6 -4
View File
@@ -27,12 +27,14 @@ class SocketServerJson {
this.onClientClose = new CallbackQueue(args.onClientClose); this.onClientClose = new CallbackQueue(args.onClientClose);
this.onClientError = new CallbackQueue(args.onClientError); this.onClientError = new CallbackQueue(args.onClientError);
// Set socket file permissions after listening // Set socket file permissions after listening. 660 (owner + group read/write)
// 777 is acceptable here for single-use container environments // is the safest default; the Docker image runs both processes as root, and
// Wrapped in try-catch as chmod may fail in test/restricted environments // 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(() => { this.onListen.push(() => {
try { try {
fs.chmodSync(this.socketFile, '777'); fs.chmodSync(this.socketFile, '660');
} catch(err) { } catch(err) {
// Chmod may fail in test environments or certain filesystems // Chmod may fail in test environments or certain filesystems
// Socket will still work with default permissions // Socket will still work with default permissions
+9
View File
@@ -232,6 +232,7 @@
}); });
$f.find("textarea[name='req_headers']").val(hostFeatureHeadersToText(h.req_headers)); $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='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers));
$f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow)); $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='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> <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>
</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;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> </div>
<!-- TLS & Wildcard --> <!-- TLS & Wildcard -->
+49 -3
View File
@@ -9,19 +9,29 @@
# update is just "sync the repo + reload" -- the files under /etc always track # update is just "sync the repo + reload" -- the files under /etc always track
# the repo, so there is nothing to re-copy. # the repo, so there is nothing to re-copy.
# #
# Secrets live at $SECRETS_FILE (/etc/proxy/secrets.js by default), outside the
# repo checkout so they survive the hard reset below. First run seeds it from
# secrets.js.example (placeholders you must fill in); later runs never touch
# an existing file.
#
# Intended to be driven by CI/CD with no human writes on prod: the checkout is # Intended to be driven by CI/CD with no human writes on prod: the checkout is
# hard-reset to origin/$BRANCH on every run, so the box deterministically mirrors # hard-reset to origin/$BRANCH on every run, so the box deterministically mirrors
# the repo (any drift on the box is discarded). # the repo (any drift on the box is discarded).
# #
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=) # Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
# SECRETS_FILE=)
set -euo pipefail set -euo pipefail
# Never block on an interactive git credential prompt in CI. # Never block on an interactive git credential prompt in CI.
export GIT_TERMINAL_PROMPT=0 export GIT_TERMINAL_PROMPT=0
# Never block on an interactive debconf prompt (e.g. tzdata, pulled in as a
# dependency on a box that's never configured it).
export DEBIAN_FRONTEND=noninteractive
REPO_URL="${REPO_URL:-https://github.com/theta42/proxy.git}" REPO_URL="${REPO_URL:-https://github.com/theta42/proxy.git}"
REPO_DIR="${REPO_DIR:-/var/www/proxy}" REPO_DIR="${REPO_DIR:-/opt/theta42/proxy}"
BRANCH="${BRANCH:-master}" BRANCH="${BRANCH:-master}"
NODE_MAJOR=22 NODE_MAJOR=22
SECRETS_FILE="${SECRETS_FILE:-/etc/proxy/secrets.js}"
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (try: sudo $0)" >&2 echo "This script must be run as root (try: sudo $0)" >&2
@@ -34,6 +44,19 @@ link(){
echo "linked $2 -> $1" echo "linked $2 -> $1"
} }
# Read the "version" field out of a package.json without depending on Node
# being installed yet (this runs before the Node.js install step below).
pkg_version(){
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
# Installed version before this run touches anything, for the upgrade banner
# at the end. Empty on a fresh install (no prior checkout).
CURRENT_VERSION=""
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
fi
echo "==> Base packages" echo "==> Base packages"
apt-get update apt-get update
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
@@ -100,6 +123,7 @@ apt-get install -y nodejs openresty
echo "==> Lua modules" echo "==> Lua modules"
luarocks install lua-resty-auto-ssl luarocks install lua-resty-auto-ssl
luarocks install luasocket luarocks install luasocket
luarocks install lua-resty-balancer
# CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua). # 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. # resty.limit.req is bundled with OpenResty, so no rock is needed for it.
luarocks install lua-resty-ipmatcher luarocks install lua-resty-ipmatcher
@@ -134,6 +158,20 @@ else
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR" git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
fi fi
NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
echo "==> Secrets file at ${SECRETS_FILE}"
install -d -m 0750 "$(dirname "$SECRETS_FILE")"
if [ ! -f "$SECRETS_FILE" ]; then
cp "$REPO_DIR/secrets.js.example" "$SECRETS_FILE"
chmod 600 "$SECRETS_FILE"
echo " seeded ${SECRETS_FILE} from secrets.js.example -- EDIT IT before the proxy will work:"
echo " \$EDITOR ${SECRETS_FILE}"
echo " then re-run this script (or: sudo systemctl restart proxy)"
else
echo " ${SECRETS_FILE} already exists, leaving it untouched"
fi
echo "==> Symlink config from the repo" echo "==> Symlink config from the repo"
install -d /etc/openresty/sites-enabled /var/log/nginx install -d /etc/openresty/sites-enabled /var/log/nginx
link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf
@@ -162,4 +200,12 @@ else
exit 1 exit 1
fi fi
echo "==> Done. Update later with: sudo BRANCH=${BRANCH} $0" echo "==> Done."
if [ -z "$CURRENT_VERSION" ]; then
echo " Installed v${NEW_VERSION}."
elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
echo " Already up to date (v${NEW_VERSION})."
else
echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
fi
echo " Update later with: sudo BRANCH=${BRANCH} $0"
+40
View File
@@ -62,6 +62,7 @@ function M.get(ngx, domain, targetInfo)
local json = require "cjson" local json = require "cjson"
local redis = require "resty.redis" local redis = require "resty.redis"
local round_robin = require "resty.balancer.round_robin"
if not domain then if not domain then
return nil, 499 return nil, 499
@@ -95,6 +96,45 @@ function M.get(ngx, domain, targetInfo)
return nil, 406 return nil, 406
end 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 ngx.ctx.targetInfo = res
-- Remember which host this target was resolved for, so the reuse guard at -- 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 -- the top can tell a genuine cache hit from a coalesced request for a
+3 -2
View File
@@ -8,9 +8,10 @@ Type=simple
Restart=always Restart=always
RestartSec=1 RestartSec=1
User=root User=root
WorkingDirectory=/var/www/proxy/nodejs WorkingDirectory=/opt/theta42/proxy/nodejs
Environment="NODE_ENV=production" Environment="NODE_ENV=production"
ExecStart=/usr/bin/env node /var/www/proxy/nodejs/bin/www Environment="CONF_SECRETS=/etc/proxy/secrets.js"
ExecStart=/usr/bin/env node /opt/theta42/proxy/nodejs/bin/www
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+15 -10
View File
@@ -6,13 +6,16 @@
// direct LDAP client for user lookups. This file supplies that wiring. // direct LDAP client for user lookups. This file supplies that wiring.
// //
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount // Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks // ./config at /config (see docker-compose.yml); docker-entrypoint.sh points the
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. No app_* env // CONF_SECRETS env var at it so @simpleworkjs/conf reads it. No app_* env
// should be passed — app_* env beats this file in @simpleworkjs/conf, so the // should be passed — app_* env beats this file in @simpleworkjs/conf, so the
// file is authoritative only if the matching app_* env is absent. // file is authoritative only if the matching app_* env is absent.
// //
// Bare-metal: copy to nodejs/conf/secrets.js and fill in your values. Values // Bare-metal: ops/install.sh seeds this file at /etc/proxy/secrets.js on first
// here override conf/base.js and win over <environment>.js. // run (with placeholders for the values it can't guess) and points the
// systemd unit's CONF_SECRETS env var at it. Fill in your values, then
// `sudo systemctl restart proxy`. Values here override conf/base.js and win
// over <environment>.js.
// //
// Only the keys the app reads are listed below. The `stack` key is read by the // Only the keys the app reads are listed below. The `stack` key is read by the
// theta-env orchestrator (setup.sh) and ignored by the app. // theta-env orchestrator (setup.sh) and ignored by the app.
@@ -64,12 +67,14 @@ module.exports = {
adminUsers: ['proxyadmin'], adminUsers: ['proxyadmin'],
groupRoleMap: {}, groupRoleMap: {},
// Optional: the local anti-lockout admin's initial password, used // Optional: the local anti-lockout admin's initial password, used
// ONLY the first time that account is created. Leave unset and it // ONLY the first time that account is created. Leave unset and a
// defaults to the username itself ("proxyadmin2") — fine for a quick // random password is generated and printed to the container log on
// local test, but change it (or set this) before exposing the proxy // first boot — fine for a quick local test if you copy it from the
// publicly. Once the account exists, this key is never read again; // log right away, but set this (or change the password afterward)
// change the password via the app itself (or delete the Redis user // before exposing the proxy publicly. Once the account exists, this
// to force it to be re-bootstrapped with a new value here). // 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', // localAdminPass: 'change-me',
}, },