Compare commits

..

89 Commits

Author SHA1 Message Date
wmantly 3cc769cc3c fix: remove DEPLOYMENT.md and docs/ from Docker build context
Pull Request Tests / Run Tests (18.x) (push) Successful in 27s
Pull Request Tests / Run Tests (20.x) (push) Successful in 27s
Pull Request Tests / Run Tests (22.x) (push) Successful in 29s
Pull Request Tests / Test Summary (push) Successful in 4s
2026-08-02 11:53:48 -04:00
wmantly 94ff5c76eb docs: remove standalone deployment and docs folder 2026-08-02 00:51:30 -04:00
wmantly d74e3168ed Merge pull request #202 from theta42/release-v1.14.0
Release v1.14.0
2026-08-02 00:39:12 -04:00
wmantly 9029de825c test: add tests for DNS API Keys in OpenBao 2026-08-02 00:34:56 -04:00
wmantly b50a1de76f feat: securely store DNS API keys in OpenBao instead of Redis 2026-08-02 00:26:22 -04:00
wmantly c419249e98 chore: release v1.13.2 2026-08-02 00:16:19 -04:00
wmantly 4aa994121a Merge pull request #201 from theta42/fix/bao-conf-1.0.1
v1.13.1: bump @simpleworkjs/bao-conf to 1.0.1 (fix standalone boot crash)
2026-08-01 12:50:47 -04:00
wmantly 2e92f58750 v1.13.1: bump @simpleworkjs/bao-conf to 1.0.1
bao-conf 1.0.0's init() threw when VAULT_TOKEN was unset, crashing boot
(.catch -> process.exit(1)) in any deployment without an OpenBao sidecar
(standalone Docker, bare metal). 1.0.1 makes init() fail-soft on a
missing token (warn + continue from CONF_SECRETS). The theta-env stack
is unaffected (it always sets a scoped VAULT_TOKEN).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:48:10 -04:00
wmantly aeccbcbbe9 Merge pull request #200 from theta42/feature/openbao-secrets
v1.13.0: load secrets from OpenBao at boot
2026-08-01 12:34:55 -04:00
wmantly 15b154fc8d v1.13.0: load secrets from OpenBao at boot via @simpleworkjs/bao-conf
bin/www now defers require('../app') until bao-conf.init({ path: 'proxy' })
resolves, so models + createOidcClient see the OpenBao-merged config (the
OIDC clientSecret is captured at require time). Authenticates to OpenBao
with a scoped VAULT_TOKEN (policy proxy), never the root token; fail-soft
to CONF_SECRETS if OpenBao is unreachable. config/proxy-secrets.js becomes
an operator-edit seed artifact (OpenBao authoritative). README gains a
Secrets section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:29:22 -04:00
wmantly a44d7ef7ab Merge pull request #199 from theta42/release-v1.12.1
Release v1.12.1
2026-08-01 11:12:56 -04:00
wmantly 144efdb5dd chore(release): v1.12.1 2026-08-01 11:11:37 -04:00
dependabot[bot] b54a738524 Bump brace-expansion and ejs in /nodejs (#179)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) to 5.0.9 and updates ancestor dependency [ejs](https://github.com/mde/ejs). These dependencies need to be updated together.


Updates `brace-expansion` from 5.0.7 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.7...v5.0.9)

Updates `ejs` from 3.1.10 to 6.0.1
- [Release notes](https://github.com/mde/ejs/releases)
- [Changelog](https://github.com/mde/ejs/blob/main/RELEASE_NOTES_v5.md)
- [Commits](https://github.com/mde/ejs/compare/v3.1.10...v6.0.1)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.8
  dependency-type: indirect
- dependency-name: ejs
  dependency-version: 6.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William Mantly <wmantly@gmail.com>
2026-08-01 11:11:21 -04:00
dependabot[bot] 8bf963f48b Bump body-parser from 2.2.2 to 2.3.0 in /nodejs (#175)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.2.2 to 2.3.0.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William Mantly <wmantly@gmail.com>
2026-08-01 11:02:25 -04:00
wmantly 30835baaeb Merge pull request #197 from theta42/release-v1.12.0
chore(release): v1.12.0
2026-08-01 01:21:50 -04:00
wmantly 2319ac3a0e chore(release): v1.12.0 2026-08-01 01:20:22 -04:00
wmantly 8c0eaf0d07 Merge pull request #196 from theta42/release/1.9.0
Release 1.9.0: cross-app super admin, modal add-buttons, LE key persistence
2026-07-30 12:03:01 -04:00
wmantly 8c81128235 Release 1.9.0: cross-app super admin, modal add-buttons, LE key persistence 2026-07-30 12:01:50 -04:00
wmantly 4f23d301bf Merge pull request #195 from theta42/feat/super-admin-add-buttons-le-persist
Recognize app_super_admin; add-user/add-permission as modal buttons; persist LE key
2026-07-30 12:00:27 -04:00
wmantly be666f5b2f Recognize app_super_admin; add-user/add-permission as modal buttons; persist LE key
- app_super_admin is a new cross-app LDAP group (also recognized by
  sso-manager-node and jump-host): added to conf.auth.adminGroups so
  members are always global admins here, same as the existing anti-lockout
  adminUsers/adminGroups mechanism.
- Users and Permissions pages: the always-visible sidebar "Add" forms are
  now an "Add User"/"Add Permission" button in the list header that opens
  an app.modal dialog, matching the hosts.ejs convention.
- The Let's Encrypt ACME account key now defaults to the already-persisted
  /data volume (models/host.js) instead of a CWD-relative path
  (./le_key.cert -> /app/le_key.cert in the container), which was lost on
  every image rebuild. Falls back to the old relative path when /data isn't
  present (e.g. local dev outside docker).
2026-07-30 11:57:44 -04:00
wmantly 2bfba93e00 Merge pull request #194 from theta42/release/1.8.0
Release 1.8.0: page width standardization, card layouts, external-user marking
2026-07-29 22:13:59 -04:00
wmantly 8cee583da3 Release 1.8.0: page width standardization, card layouts, external-user marking 2026-07-29 22:12:30 -04:00
wmantly b5fab6c91a Merge pull request #193 from theta42/feat/ui-standardization-external-users
Standardize page width, card layouts; mark SSO users external and read-only
2026-07-29 22:11:39 -04:00
wmantly c7ec65e0d9 Standardize page width, card layouts; mark SSO users external and read-only
- All pages now wrap their content in <div class="container mt-4">,
  matching sso-manager-node's width instead of rendering full-bleed inside
  the fluid shell.
- Users and Permissions pages converted from bare <table>s to the same
  card-grid convention already used on the Groups page.
- Users backed by SSO/OIDC login (backing === 'oidc', set by the redis
  user model's JIT-provisioning path) are now marked "External (SSO)" and
  their password-change control is hidden; PUT /password/:username also
  rejects with 403 server-side for such users. Deletion stays allowed.
  Redis-backend only -- LDAP/PAM deployments have no per-record marker for
  this today.
- app-base.js (byte-identical across the 3 apps): added
  app.util.revealItem(), wired into the Users/Permissions create flows.
- Bumped @simpleworkjs/frontend to ^0.2.7.
2026-07-29 22:09:50 -04:00
wmantly 3f2ef57c78 Release 1.7.0: API-token modal-based create, Edit modal, reveal-modal fix (#192) 2026-07-28 20:43:42 -04:00
wmantly c0e04d1a56 Unify API-token UI: modal-based create, new Edit modal (#191)
Continues the cross-app API-token UI unification (jump-host landed first).
proxy already had the card grid and a description field, so this is a
smaller diff: converts the always-visible inline create-form card into a
"+ New Token" button + app.modal (matching the Add-Resource/Add-Host
convention used stack-wide, per explicit direction to standardize on the
modal-button approach rather than sso-manager-node's inline-card style),
adds a net-new Edit modal (proxy's PUT /api-token/:id already fully
supported it -- no route change needed), and replaces the static
#secretModal with the same bare app.modal showToken()/copyFieldValue()
pattern jump-host uses.

Found and fixed a real timing bug along the way: the create flow's
evalAJAX called app.modal.close() immediately before showToken() (which
calls app.modal.open()) in the same synchronous tick. app.modal is a
singleton, and close() immediately followed by open() collides with
Bootstrap's hide-transition guard -- show() silently no-ops while
_isTransitioning is still true from the just-started hide(), so the
"secret revealed" modal never actually appeared after creating a token.
Confirmed via a live click-through: the reveal modal stayed invisible
(title set, `.show` class never added) with the close() call, and rendered
correctly with it removed.

Also fixed the same latent bug in jump-host's already-shipped v1.10.0
(submitApiToken() had the identical close()-then-open() sequence) and in
sso-manager-node's directory.ejs (saveResource()'s OAuth-secret-reveal
path, softened there by an intervening `await loadResources()` but not
guaranteed race-free).

Verified live: create -> reveal modal now appears correctly; Edit modal
shows real created-by/on data, saves a description change, card refreshes.
2026-07-28 20:40:39 -04:00
wmantly 130d4a2c9b Release 1.6.0: host modal standardization (#190) 2026-07-28 19:06:51 -04:00
wmantly 3b729295b0 Standardize the host modal: tabs onto app.modal, footer, linkable URL (#189)
Migrates proxy's hostModal (the modal this session's app.modal tabs/footer/
url support was originally modeled on) off its old always-in-DOM static
element and onto the shared app.modal component (@simpleworkjs/frontend
0.2.6), continuing the entity-modal standardization started with
sso-manager-node's resource modal.

Unlike the resource modal, this one already had 6 tabs and Host's audit
trail (created_by/created_on/updated_by/updated_on) already existed and was
already populated correctly by routes/host.js -- no model or route changes
needed there. The tab markup itself is kept as one hand-built bodyHtml
string rather than going through app.modal's own `tabs` array option: that
option builds the tab-content div itself, and there'd be no way to wrap a
<form> around just that piece without extending app.modal again, whereas
this modal's tabs already are exactly the pattern app.modal's own tabs
option was modeled on.

Key correctness points, found and handled:
- The one JS binding that was unsafe under DOM rebuild -- a `keyup` handler
  driving the Let's-Encrypt challenge-type/wildcard visibility, previously
  bound once directly against a captured selector -- is now delegated via
  app.modal.on(), the same bug class already found and fixed in the
  resource modal.
- hostLoadAuthSuggestions() (fills the SSO allow-list autocomplete
  datalists) now re-runs on every modal open, not just once at page load,
  since the datalists are rebuilt fresh (empty) each time.
- .actionMessage moved from a sibling of the old modal's <form> into a
  descendant of it: formAJAX's error/success target resolution
  (app.messages.action's closest('div.card') then a find('.actionMessage')
  fallback) only succeeds via the fallback path once app.modal owns the
  DOM, since app.modal's .modal-content carries no 'card' class.
- The footer's audit dates needed the explicit 'x' (unix ms) format token
  for moment() -- Host's created_on/updated_on come back as redis-hash
  strings, and moment's bare fallback parser silently produces "Invalid
  date" for a numeric string without it (this app's own hostParseRow
  already does this correctly elsewhere; the new footer code needed the
  same treatment).

Also adds GET /hosts/:host (mirroring sso-manager-node's /directory/:slug)
plus a client-side deep-link check, threaded through a new onLoaded
callback on hostPopulate().

Verified live against the running dev stack: all 6 tabs render and save
correctly; the footer shows real created/updated-by/on dates; the SSO
autocomplete has options on a second modal open (not just the first);
the challenge-type keyup logic fires correctly on a second-ever modal
open (confirmed via the actual GET /api/host/wildcard-parent/... network
request); the address bar updates to /hosts/{host} and reverts on close;
a direct load of /hosts/{host} auto-opens the right host's modal; and a
real save (PUT) closes the modal and live-updates the row via the existing
pubsub subscription, end to end.
2026-07-28 19:04:56 -04:00
wmantly 44c2ec3fdd Merge pull request #188 from theta42/release/1.5.3
Release 1.5.3
2026-07-28 13:38:28 -04:00
wmantly e9032ec4fb Release 1.5.3: fix HTML-escaped loading indicator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:31:39 -04:00
wmantly a6e745cfc1 Merge pull request #187 from theta42/fix/loading-message-html-escaped
Fix HTML-escaped loading indicator in formAJAX
2026-07-28 13:07:46 -04:00
wmantly 8350ab9b57 Fix HTML-escaped loading indicator in formAJAX
Same fix as sso-manager-node: formAJAX's loading indicator passed a raw
<div class="spinner-border"> string to app.messages.action, which
HTML-escapes its message by design (@simpleworkjs/frontend) -- so every
form submit briefly showed the literal markup as text instead of a
spinner. Replaced with plain text ("Saving…").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:52:39 -04:00
wmantly 001fe84b99 Merge pull request #186 from theta42/release/1.5.2
Release 1.5.2
2026-07-28 00:20:35 -04:00
wmantly e05ec1d2ad Release 1.5.2: add no-native-dialogs regression test
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:18:35 -04:00
wmantly 652df4f3cf Merge pull request #185 from theta42/test/no-native-dialogs
Add regression test: no native alert()/confirm()/prompt()
2026-07-27 23:58:01 -04:00
wmantly 9370734084 Add regression test: no native alert()/confirm()/prompt()
Native confirm() blocks all further browser events on the page (found
live, mid browser-automation testing, on sso-manager-node's equivalent
secret-rotate flow -- it froze the tab). Every call site in this app was
already removed in favor of app.messages.action/confirm/toast and
app.modal.open; this static check (scans views/ and public/js|lib/js for
bare alert(/confirm(/prompt() calls) keeps a regression from shipping
unnoticed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 23:36:34 -04:00
wmantly 58204da3cb Merge pull request #184 from theta42/release/1.5.1
Release 1.5.1
2026-07-27 17:24:16 -04:00
wmantly 2d7eadec60 Release 1.5.1: remove native confirm() calls
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 17:23:07 -04:00
wmantly 0b9a1aabfd Merge pull request #183 from theta42/fix/no-native-dialogs
Remove native confirm() calls in revokeToken/rotateToken
2026-07-27 16:52:42 -04:00
wmantly 36c3f7a881 Remove native confirm() calls in revokeToken/rotateToken
Native confirm() blocks browser automation entirely (found live, mid
browser-test of the app.messages/app.modal adoption, on sso-manager-node's
equivalent flow). Both functions already receive btn, whose .closest('.card')
is already used for the error path, so app.messages.confirm targets the
same card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:50:33 -04:00
wmantly 3686a5ddb8 Merge pull request #182 from theta42/release/1.5.0
Release 1.5.0
2026-07-27 14:18:27 -04:00
wmantly ce013d7e31 Release 1.5.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:16:47 -04:00
wmantly f6bc38eef2 Merge pull request #181 from theta42/modernize/simpleworkjs-frontend
Adopt @simpleworkjs/frontend messages/modal/validate modules
2026-07-27 14:10:13 -04:00
wmantly 0a659428dd Adopt @simpleworkjs/frontend's messages/modal/validate modules
Same swap as sso-manager-node/jump-host: vendored app.util.actionMessage/
actionConfirm replaced by @simpleworkjs/frontend's app.messages.action/
confirm (real HTML-escaping, toast fallback); vendored val.js replaced by
the package's app.validate.js.

proxy's host/target/hostname validation rules (mirrored from the backend's
utils/hostname_validate.js — wildcard DNS patterns, not something other
apps need) move to public/js/app.js, registered via $.validateSettings,
since they're proxy-specific and don't belong in the shared package's
generic rule set (eq/user/password/ip).

app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched, same
reasoning as sso-manager-node's PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:07:14 -04:00
wmantly e770bbb41f Merge pull request #180 from theta42/docs/screenshots-refresh
docs: refresh proxy screenshots for the unified UI
2026-07-26 16:31:50 -04:00
wmantly 90619dd4ff docs: refresh proxy screenshots for the unified UI
hosts.png, host-auth-sso.png and host-auth-basic.png still showed the
pre-unification nav; replaced with the current shared shell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:28:44 -04:00
wmantly 75045b4b33 Merge pull request #178 from theta42/feature/ui-unification
Release 1.4.0: unified front-end UI shell
2026-07-26 00:30:08 -04:00
wmantly 2e959f7c48 Release 1.4.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly fcc09d1e4b logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:08 -04:00
wmantly a83a5fd39a app.api.delete: accept the (url, data, callback) form formAJAX uses; defer the login-card reveal to DOM ready
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (the host/DNS delete buttons) landed its callback in
the data slot and never ran.

The login page's "reveal the card once we know you're logged out" branch
touched an element further down the same page, which threw when
isLoggedIn answered before the parser got there (it always did without a
stored token). It now runs on DOM ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly 3a307c1563 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

proxy specifics:
- .group-required base rule added to styles.css; the admin nav items lost
  their inline display:none in favour of it.
- The brand link points at / instead of #.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly f055d4f567 Merge pull request #177 from theta42/release/v1.3.0
Release 1.3.0
2026-07-25 16:38:47 -04:00
wmantly f4efdfb957 Release 1.3.0: adopt shared @simpleworkjs/* packages; fix LDAP filter injection
Rewire onto the shared @simpleworkjs/oidc-client, /ldap, and /app-stack
packages (deleting the byte-identical local forks of the same code), close the
LDAP filter-injection in User.get by routing the username through escapeFilter
(RFC 4515), align model-redis ^1.6.0 and ldapts ^8.1.8, and unify build_info to
{buildVersion, buildHash, buildYear}. package-lock regenerated from the npm
registry (no file:/link:), so npm ci is clean in docker builds.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 15:53:30 -04:00
wmantly 60dbfe5b9b Merge pull request #176 from theta42/release/v1.2.2
Release v1.2.2: fix load-balancing crash (wrong lua-resty-balancer module)
2026-07-21 16:22:32 -04:00
wmantly ecdced16fc Release 1.2.2: fix load-balancing crash (wrong lua-resty-balancer module)
Every request to a host with additional load-balancing targets 500'd:
targetinfo.lua required 'resty.balancer.round_robin', which does not
exist in the lua-resty-balancer rock actually installed by the
Dockerfile/install.sh. That rock provides resty.roundrobin instead,
with a different constructor (roundrobin:new(nodes), not
:new() + :reinit(nodes)).

Verified end-to-end in a rebuilt image: requests to a load-balanced
host now return 200 and alternate across both backend targets, with
no Lua errors in the OpenResty log.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:21:03 -04:00
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
wmantly b9bdf36638 Merge pull request #163 from theta42/concept-docs
Add plain-language concept docs; fix docs viewer rendering; link API tokens
2026-07-17 22:11:28 -04:00
wmantly 426fa111ec Add plain-language concept docs; fix docs viewer rendering; link API tokens
- New docs/concepts-{hosts,dns,access,api-tokens}.md -- plain-language
  guides aimed at less technical readers, each linking onward to the
  existing system-design-level doc for anyone who wants that detail.
  Card help links (Proxy List, Add/Edit host, DNS Provider cards,
  Users/Permissions/Groups cards) now point here instead of straight at
  Installation/Architecture.
- The "New API Token" card had no help link at all -- added, pointing to
  the new API Tokens doc.
- Fixed the in-app docs viewer rendering every docs/*.md page with a
  garbled heading + stray <hr> at the top: Jekyll front matter (meant
  only for the GitHub Pages build) was never stripped before being
  handed to the markdown renderer.
- Fixed cross-doc links never resolving in-app, since this viewer serves
  docs at /docs/<slug> with no .html suffix: rewritten to the correct
  in-app URL, first by registered slug, falling back to the doc's real
  filename (the correct, working link form on the Jekyll/GitHub Pages
  build) -- same idea as the existing image-path fix, and lets one link
  written in a doc work on both targets.

Bumps to v1.1.13.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 22:09:53 -04:00
wmantly f0b282b679 Merge pull request #162 from theta42/wildcard-edit-and-nginx-fix
Fix wildcard-parent edit greying and deprecated nginx http2 directive
2026-07-17 21:35:28 -04:00
wmantly 4f1fce367e Fix wildcard-parent edit greying and deprecated nginx http2 directive
- The edit form's "Parent Wildcard" option stayed greyed out even when a
  valid wildcard existed, since hostEditOpen() never ran the eligibility
  check (only the host field's keyup handler did, which setting .val()
  programmatically doesn't fire) -- and the check itself, GET
  /host/lookup/:item, had the same self-match bug as the recently-fixed
  Host.prototype.update() case: it resolves an already-existing host to
  its own record instead of a sibling wildcard. Added a dedicated
  /host/wildcard-parent/:item route combining lookUp() (handles a
  brand-new subdomain) with lookUpWildcardParent() (handles an
  already-existing host), and hostEditOpen() now actually runs it.

- Migrated ops/nginx_conf/autossl.conf's deprecated "listen ... http2"
  directive to the standalone "http2 on;" directive (nginx 1.25.1+).

Bumps to v1.1.12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 21:33:26 -04:00
wmantly 9eb3dfa2e6 Merge pull request #161 from theta42/help-icon-relocate
Move help links from the global header onto each relevant card
2026-07-17 20:02:08 -04:00
wmantly a40da55993 Move help links from the global header onto each relevant card
The single header-wide help icon (added last release) pointed at a
per-page doc guess, but a page can have several cards covering different
topics. Removed it and added a small help icon directly to each card
that has real corresponding doc content, linking straight to that doc:
Proxy List + Add/Edit host modal (hosts.ejs), Add DNS Provider + Dynamic
A Records (dns.ejs), Add New User + User List (users.ejs), Add
Permission + Permissions (permissions.ejs), Add Group (groups.ejs).

Bumps to v1.1.11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 20:00:34 -04:00
wmantly e5df0d3370 Merge pull request #160 from theta42/docs-help-search
Add header help icon and in-app docs search
2026-07-17 19:29:22 -04:00
wmantly fcd73169e0 Add header help icon and in-app docs search
- A ? icon in the top-right header deep-links to the doc most relevant to
  the current page (client-side path mapping, same pattern already used
  for top-nav active-link highlighting -- no server-side "current section"
  local exists to key off of instead). Falls back to the docs index.
- GET /docs/search does a plain line-substring search over the existing
  allowlisted doc set. No new dependency, stays usable with no internet
  access.

Bumps to v1.1.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:28:07 -04:00
76 changed files with 2472 additions and 4491 deletions
+230 -2
View File
@@ -1,3 +1,9 @@
## v1.13.3
- fix: remove missing DEPLOYMENT.md and docs/ from Docker build context
## v1.13.2
- chore: Update CI pipeline integration
# Changelog
All notable changes to this project are documented here. Format loosely
@@ -6,6 +12,218 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [1.13.1] - 2026-08-01
### Fixed
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
proxy exit at boot in any deployment without an OpenBao sidecar (standalone
Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing token (warn
+ continue from `CONF_SECRETS`), matching the documented contract. The
theta-env stack is unaffected (it always sets a scoped `VAULT_TOKEN`).
## [1.13.0] - 2026-08-01
### Changed
- **Secrets now load from OpenBao at boot** via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/proxy/conf` over the file-loaded config. The proxy
authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy `proxy`
read-only on its own path), never the root token. Because the OIDC
`clientSecret` is captured at require time inside `createOidcClient` (during
`require('../models')`, which `require('../app')` triggers transitively),
`bin/www` now defers `require('../app')` until after `bao-conf.init()`
resolves. Fail-soft: if OpenBao is unreachable, boot continues from
`CONF_SECRETS`. The `config/proxy-secrets.js` file is now an operator-edit
seed artifact (gitignored); OpenBao is authoritative. See theta-env's
[Secrets docs](https://theta42.github.io/theta-env/secrets/).
- Bumped package version to track the release tag.
## [1.12.1] - 2026-08-01
### Changed
- Bumped `body-parser` 2.2.2 → 2.3.0 (Dependabot #175).
- Bumped `ejs` and `brace-expansion` (Dependabot #179, security maintenance).
## [1.12.0] - 2026-08-01
### Fixed
- Changed UNIX socket permission in `unix_socket_json.js` to `666` so OpenResty Nginx workers running as `nobody` can resolve targets properly.
## [1.9.0] - 2026-07-30
### Added
- **`app_super_admin` cross-app group** recognized as a global admin (`conf.auth.adminGroups`), same group also recognized by sso-manager-node and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
### Changed
- **Users and Permissions pages**: the always-visible sidebar "Add" forms are now an "Add User"/"Add Permission" button in the list header that opens an `app.modal` dialog, matching the hosts.ejs convention.
- **Let's Encrypt ACME account key** now defaults to the already-persisted `/data` volume instead of a CWD-relative path (`./le_key.cert` -> `/app/le_key.cert` in the container), which was lost on every image rebuild.
## [1.8.0] - 2026-07-28
### Added
- **Users backed by SSO/OIDC login are now marked "External (SSO)"** and read-only: their password-change control is hidden, and `PUT /password/:username` rejects with 403 server-side. Deletion stays allowed. Redis user-backend only.
- **`app.util.revealItem()`** (shared `app-base.js`): scrolls a just-added/-edited element into view and flashes its background. Wired into the Users/Permissions create flows.
### Changed
- **All pages now wrap their content in a standard-width container**, matching sso-manager-node instead of rendering full-bleed.
- **Users and Permissions pages converted from bare `<table>`s to the card-grid convention** already used on the Groups page.
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
## [1.7.0] - 2026-07-28
### Added
- **API tokens: "+ New Token" modal button (replacing the always-visible inline create-form card) and a new Edit modal** — continues the cross-app API-token UI unification started in jump-host. The Edit modal's footer shows real created-by/on data; the `PUT /api-token/:id` route already fully supported editing, so no backend change was needed.
### Fixed
- **Creating an API token didn't show the "save this secret now" reveal modal** — the create flow called `app.modal.close()` immediately before `app.modal.open()` (to show the secret) in the same tick; since `app.modal` is a singleton, that collided with Bootstrap's hide-transition guard and the reveal modal silently never appeared.
## [1.6.0] - 2026-07-28
### Added
- **Host modal: footer with created/updated-by/on metadata, linkable `/hosts/{host}` URL** — migrated onto the shared `app.modal` component (`@simpleworkjs/frontend` 0.2.6), continuing the entity-modal standardization started in sso-manager-node. Kept the existing 6-tab layout (General/TLS & Wildcard/Traffic/Headers/Access/Authentication) unchanged.
### Fixed
- **The Let's-Encrypt challenge-type/wildcard-matching visibility logic could stop reacting to the hostname field after the first Add/Edit** — its `keyup` handler was bound once directly against a selector captured at page load; now delegated so it survives the modal being rebuilt on every open.
- **The SSO allow-list autocomplete (users/groups) could go empty starting on the second Add/Edit** — its backing `<datalist>`s are rebuilt fresh on every modal open now, so the suggestions are refetched each time too.
## [1.5.3] - 2026-07-28
### Fixed
- **`formAJAX`'s loading indicator showed literal HTML**, not a spinner — same fix as sso-manager-node's companion release. Replaced with plain text.
## [1.5.2] - 2026-07-28
### Added
- **Regression test**: a static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page and were fully removed in 1.5.1.
## [1.5.1] - 2026-07-27
### Fixed
- **Removed native `confirm()` calls in `revokeToken`/`rotateToken`** (`profile.ejs`), replacing them with `app.messages.confirm`. Native `confirm()` blocks all further browser events on the page (discovered live, mid browser-verification of sso-manager-node's equivalent flow, where it froze the tab).
## [1.5.0] - 2026-07-27
### Changed
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js`. Message content is now HTML-escaped, and `app.messages.action` falls back to a page-wide toast when there's no inline `.actionMessage` target. proxy's `host`/`target`/`hostname` wildcard-DNS validation rules (mirroring `utils/hostname_validate.js`) moved to `public/js/app.js`, registered via `$.validateSettings`, since they're proxy-specific and don't belong in the shared package's generic rule set. `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched.
## [1.4.0] - 2026-07-26
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Added
- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against.
- Admin-only nav items lost their inline `display: none` in favour of that class, and the brand link points at `/` instead of `#`.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
## [1.3.0] - 2026-07-25
### Added
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org, replacing this app's byte-identical forks of the same code so the theta42 apps share one codebase and API schema:
- `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, and `routes/auth.js`; `models/index.js` now wires the factory. The per-host SSO in `routes/host_auth.js` is unchanged but consumes the shared OIDC utils.
- `@simpleworkjs/ldap` — the ldapts client + RFC 4515/4514 escaping.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/render.js` now use the shared helpers.
### Security
- **LDAP filter injection in `User.get`.** The user lookup built its search filter by interpolating `data.username` raw into `(&(objectClass=inetOrgPerson)(uid=<username>))`. A username containing `*`, `(`, `)`, `\`, or NUL could widen or alter the filter (e.g. `*` → match-all). The filter value is now passed through `escapeFilter` from `@simpleworkjs/ldap` (RFC 4515 escaping).
### Changed
- Dependency alignment: `model-redis` `^1.5``^1.6.0`, `ldapts` `^8.1.2``^8.1.8`. The four new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`.
## [1.2.2] - 2026-07-21
### Fixed
- Multi-target load balancing (added in 1.2.0) crashed every request to a load-balanced host: `ops/nginx_conf/targetinfo.lua` required a nonexistent `resty.balancer.round_robin` module. The `lua-resty-balancer` rock installed by the Dockerfile/`install.sh` doesn't provide that path — it provides `resty.roundrobin` (constructed as `roundrobin:new(nodes)`, not `:new()` + `:reinit(nodes)`). Fixed `targetinfo.lua` to use the real module and API; verified end-to-end that requests now round-robin across targets with no Lua errors.
## [1.2.1] - 2026-07-21
### Fixed
- The bootstrap anti-lockout admin account was always created as `proxyadmin2` regardless of `conf.auth.adminUsers`, while `migrations/permission_bootstrap.js` grants the global-admin permission to `conf.auth.adminUsers[0]`. If an operator customized `adminUsers` away from the default, the bootstrapped account and the permissioned account were two different (non-matching) usernames, so the anti-lockout account ended up with no admin access. `models/user_redis.js` now derives the bootstrap username from `conf.auth.adminUsers[0]` (falling back to `proxyadmin2`), matching `permission_bootstrap.js`.
- Corrected a `secrets.js.example` comment that claimed the bootstrap admin's password "defaults to the username itself" — it actually generates a random password printed to the container log on first boot.
### Changed
- Refreshed all README screenshots (hosts, per-host SSO auth, per-host basic auth) against the current UI, and added a new load-balancing screenshot for the multi-target feature.
## [1.2.0] - 2026-07-21
### Added
- Multi-target load balancing: hosts can now specify additional backend targets (`IP:port`, one per line) alongside the primary target; the proxy distributes requests across all of them round-robin via `lua-resty-balancer`. Fixes #47.
## [1.1.17] - 2026-07-20
### Fixed
- An existing single-label subdomain host (e.g. `sso.nl.wgnode.com`) could not be attached to a wildcard cert added later (e.g. `*.nl.wgnode.com`): `Host.lookUpWildcardParent()` only checked the wildcard-as-child position (the wildcard's own base domain) and missed the far more common wildcard-as-sibling case, so the edit form's "Parent Wildcard" option stayed permanently greyed out. It now checks both positions, and a regression test covers the sibling case.
## [1.1.16] - 2026-07-18
### Changed
- 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
### Added
- Four new plain-language docs aimed at less technical readers, replacing the system-design-level Architecture/Installation docs as the target of most card help links: **Hosts & HTTPS**, **DNS Providers**, **Users, Groups & Permissions**, and **API Tokens**. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed entirely) now has a help link.
### 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.
## [1.1.12] - 2026-07-17
### 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.
- 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+).
## [1.1.11] - 2026-07-17
### 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.
## [1.1.10] - 2026-07-17
### 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).
- 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.
## [1.1.9] - 2026-07-17
### Added
@@ -17,7 +235,7 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
- 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()`.
Bumps to v1.1.9.
## [1.1.8] - 2026-07-17
### 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()`.
@@ -25,6 +243,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.
## [1.1.7] - 2026-07-16
### 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.
@@ -71,7 +291,15 @@ 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.9...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.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.10]: https://github.com/theta42/proxy/compare/v1.1.9...v1.1.10
[1.1.9]: https://github.com/theta42/proxy/compare/v1.1.8...v1.1.9
[1.1.8]: https://github.com/theta42/proxy/compare/v1.1.7...v1.1.8
[1.1.7]: https://github.com/theta42/proxy/compare/v1.1.6...v1.1.7
-277
View File
@@ -1,277 +0,0 @@
# Deployment Guide — theta42/proxy
The proxy is an OpenID Connect-protected reverse proxy (OpenResty front + Node
management app + Redis) that is **both** an OIDC client of an SSO Manager *and*
a direct LDAP client for user lookups. Two deployment methods:
1. **Docker** — a single all-in-one image bundling OpenResty + the app + Redis
(`docker compose up`).
2. **Bare metal**`ops/install.sh` on Debian/Ubuntu (installs Node.js,
OpenResty, Lua modules, Redis, and a systemd unit).
## How configuration works
The app loads configuration via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf),
which deep-merges, in order:
1. `conf/base.js` (committed, generic defaults)
2. `conf/<NODE_ENV>.js` (optional)
3. `conf/secrets.js` (gitignored — secrets + per-deployment values)
4. **`app_*` environment variables** — the highest-precedence layer
Any env var whose name starts with `app_` overrides the merged config. The rest
of the name is split on **double-underscore** (`__`) into a nested path. Values
are `JSON.parse`-coerced when possible and kept as raw strings otherwise.
| Env var | Sets | Notes |
|---------|------|-------|
| `app_oidc__issuer` | `conf.oidc.issuer` | browser-facing SSO URL |
| `app_oidc__authorizationEndpoint` | `conf.oidc.authorizationEndpoint` | browser-facing |
| `app_oidc__tokenEndpoint` | `conf.oidc.tokenEndpoint` | server-to-server; can be internal |
| `app_oidc__userinfoEndpoint` | `conf.oidc.userinfoEndpoint` | server-to-server; can be internal |
| `app_oidc__endSessionEndpoint` | `conf.oidc.endSessionEndpoint` | browser-facing |
| `app_oidc__clientId` / `app_oidc__clientSecret` | OIDC client creds | register in the SSO first |
| `app_oidc__redirectUri` | `conf.oidc.redirectUri` | must match the SSO client exactly |
| `app_oidc__enabled` | `conf.oidc.enabled` | boolean |
| `app_ldap__url` | `conf.ldap.url` | `ldaps://…:636` or `ldap://…:389` |
| `app_ldap__bindDN` / `app_ldap__bindPassword` | LDAP service account | don't reuse the admin DN |
| `app_ldap__searchBase` / `app_ldap__userFilter` | user search | |
| `app_ldap__tlsOptions__rejectUnauthorized` | `conf.ldap.tlsOptions.rejectUnauthorized` | `false` for self-signed LDAPS |
| `app_ldap__tlsOptions__ca` | `conf.ldap.tlsOptions.ca` | path to a CA cert for strict trust |
| `app_auth__adminUsers` | `conf.auth.adminUsers` | local anti-lockout admin (uid) |
| `app_auth__adminGroups` | `conf.auth.adminGroups` | SSO/LDAP groups that are global admin (JSON array) |
| `app_auth__localAdminPass` | `conf.auth.localAdminPass` | initial password for the local anti-lockout admin (used once, on first creation only — defaults to the username itself if unset) |
| `app_redis__prefix` | `conf.redis.prefix` | default `proxy_` |
See [`docs/docker.md`](docs/docker.md) for a shorter, container-focused version
of this reference.
> **Requires `@simpleworkjs/conf` >= 1.1.0.** The Docker image will not honor
> `app_*` env vars on 1.0.0. The lock is already on `^1.1.0`; if you regenerate it:
> ```bash
> cd nodejs && npm install @simpleworkjs/conf@^1.1.0
> ```
---
## Method 1: Docker (all-in-one)
The image (`Dockerfile`) bundles OpenResty + the app + Redis in one container,
mirroring the bare-metal `ops/install.sh` layout. `docker-entrypoint.sh`:
generates the fallback SSL cert, parameterizes the OpenResty `resolver`/
`set_real_ip_from` directives, starts Redis + the node app, and execs OpenResty
in the foreground under `dumb-init`.
### Setup
The bundled `docker-compose.yml` reads the OIDC + LDAP + auth wiring from a
bind-mounted `./config/proxy-secrets.js` (not from `app_*` env). Copy the
example, fill in your secrets, then build + start:
```bash
mkdir -p config && chmod 700 config
cp secrets.js.example config/proxy-secrets.js
$EDITOR config/proxy-secrets.js # set oidc.clientId/clientSecret, ldap.bindPassword, ...
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.
> Running the unified `theta-env` stack? Its `setup.sh` generates
> `./config/proxy-secrets.js` (+ `./config/sso-secrets.js`) for you and
> registers the OAuth client with the SSO — see the theta-env README.
### Access
- Proxy (public): `https://<host>/` — OpenResty front, auto-SSL (Let's Encrypt)
- Management UI / API: `http://127.0.0.1:3000/` (bound to localhost; the front
proxies the UI under its own TLS)
- Health: `http://127.0.0.1:3000/health``{"status":"ok"}`
### API tokens (personal access tokens)
Any logged-in user can mint a long-lived bearer token to call the management
API from scripts/CI/other services, without an OIDC browser session. Tokens are
self-service and authenticate **as their creator**: the creator's groups are
snapshotted at mint time (mirroring how the proxy's browser session captures
groups at login — the proxy never re-queries the IdP), and the existing authz
layer (`Permission.effectiveFor` / `roles.resolveEffective`) applies unchanged.
Local groups and owned-domain rights are recomputed live each request; only the
SSO/LDAP group membership is the mint-time snapshot.
Create one in the UI under **API Tokens** (the token string is shown **once**),
then use it as a bearer token:
```bash
curl -H "Authorization: Bearer prx_<id>_<secret>" https://proxy.example.com/api/host
```
Format: `prx_<id>_<secret>` — the `id` is the lookup key, the `secret` is
bcrypt-hashed and never stored in plaintext. Rotate or revoke from the same page
(immediate effect). Optional expiry (in days) at creation. Tokens persist in the
bundled Redis (AOF — see *Backups and restore*), so they survive rebuilds.
The token carries the creator's effective rights: a global admin's token can
manage Hosts/Users/Groups; a domain manager's token can manage their own
domains but `requireAdmin` routes return 403. To tighten permissions after group
changes, revoke and re-mint the token.
### OpenResty runtime env
| Variable | Default | Description |
|----------|---------|-------------|
| `RESOLVER` | `127.0.0.11` | DNS for upstream names in Host records (Docker's embedded DNS) |
| `REAL_IP_FROM` | _empty_ | Trusted CIDR for `X-Real-IP`. Empty = proxy is the front (removes the real_ip block). Set to an upstream proxy's CIDR if one sits in front. |
### Auto-SSL / Let's Encrypt
`lua-resty-auto-ssl` stores certs in the bundled Redis. Redis is now AOF+RDB
persisted to the `proxy-data` volume (not in-memory), so **Let's Encrypt certs
survive container recreation** — no re-issue / rate-limit on rebuild. Port 80 is
required for HTTP-01 challenges (mapped in the compose).
### Backups and restore
**What lives where**
| State | Location | Persisted? |
|-------|----------|------------|
| Host records, permissions, DNS creds, local users | `proxy-data` volume (`/data`, Redis) | yes (AOF + RDB) |
| Let's Encrypt certs (auto-ssl) | `proxy-data` volume (`/data`, Redis) | yes — same Redis |
| nginx response cache / logs | `proxy-cache` / `proxy-logs` volumes | yes (volume) |
| Secrets (OIDC client secret, LDAP bind password) | `./config/proxy-secrets.js` (bind mount) | your responsibility — back up off-host |
**Automatic snapshots** — when run as part of the unified `theta-env` stack,
`setup.sh` snapshots Redis + `./config/` to `./backups/<timestamp>/` before every
rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone deployments
should run `ops/backup.sh` the same way (on a cron/systemd timer, or by hand
before an upgrade):
```bash
./ops/backup.sh # keeps the last 5 by default
./ops/backup.sh 10 # or override retention
BACKUP_KEEP=10 ./ops/backup.sh
```
It snapshots Redis (`BGSAVE`, falling back to a synchronous `SAVE` if that
doesn't complete quickly) and `./config/` to `./backups/<timestamp>/`,
pruning older backups beyond the retention count — the same approach
`theta-env`'s `setup.sh` uses, just scoped to this one container. Equivalent
manual steps, if you'd rather not use the script:
```bash
# Redis — hot snapshot: trigger a save, then copy the RDB out
docker compose exec proxy redis-cli BGSAVE
docker compose cp proxy:/data/dump.rdb proxy-redis-$(date +%F).rdb
# Secrets — copy the config dir (holds OIDC client secret, LDAP bind password)
cp -a ./config config-backup-$(date +%F) && chmod 700 config-backup-$(date +%F)
```
Store the backup **off the host** — it contains secrets and the whole
Host/permission/user dataset.
**Restore — Redis (full proxy state + certs)**
```bash
cp -a config-backup-<date> ./config && chmod 700 ./config
docker compose up -d
docker compose stop proxy
# AOF wins on startup — delete it so the RDB is loaded instead (see note).
docker compose run --rm --no-deps --entrypoint sh proxy -c \
'rm -f /data/appendonly.aof /data/appendonly.aof.*'
docker compose cp proxy-redis-<date>.rdb proxy:/data/dump.rdb
docker compose start proxy
```
> **AOF vs RDB (important):** with `--appendonly yes`, Redis loads
> `appendonly.aof` on startup and **ignores** `dump.rdb` if the AOF exists. To
> restore from an RDB snapshot you **must delete the AOF first** (the runbook
> does this); Redis then loads the RDB and writes a fresh AOF. Verify:
> `docker compose exec proxy redis-cli DBSIZE`.
>
> Restoring Redis restores cert state **at the snapshot time** — certs issued
> after the snapshot are lost and will be re-issued on next request.
**Upgrades**
```bash
./setup.sh # backs up, then rebuilds — proxy-data keeps Redis state
# (standalone) docker compose pull && docker compose up -d
```
Host records, permissions, DNS creds, local users, and Let's Encrypt certs all
survive the rebuild because they live on the `proxy-data` volume, not in the
image. **Migrations note:** if a release ships a `nodejs/migrations/` script,
run it after upgrading (it transforms in-Redis records); see the release notes.
### Logs
OpenResty runs in the foreground and the Node app in the background, both
writing to the container's stdout/stderr. nginx access/error logs go to files
(`/var/log/nginx`, on the `proxy-logs` volume), so they do **not** appear in
`docker logs`.
```bash
docker compose logs -f proxy # app + OpenResty (stdout/stderr)
docker compose exec proxy tail -f /var/log/nginx/error.log # nginx errors
docker compose exec proxy tail -f /var/log/nginx/access.log # nginx access
docker compose logs --tail=200 --since=10m proxy # recent context
```
---
## Method 2: Bare metal (Debian/Ubuntu)
`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
`/var/www/proxy`, symlinks the OpenResty + systemd config from the repo, and
starts `proxy.service`. Re-run it to update.
```bash
sudo ./ops/install.sh
```
Configuration is file-based: write `nodejs/conf/secrets.js` with the OIDC +
LDAP values (see `nodejs/conf/base.js` for the shape), then
`sudo systemctl restart proxy`.
---
## Fronting an SSO Manager
The proxy is a natural front for [`theta42/sso-manager-node`](https://github.com/theta42/sso-manager-node):
it terminates TLS for the SSO's UI and protects it with OIDC login, while also
binding to the SSO's LDAP directly for user lookups. To run both together:
1. **One Docker network** so the proxy can reach the SSO internally at
`http://sso-manager:3001` (token/userinfo, server-to-server) and
`ldaps://sso-manager:636` (LDAP).
2. **Set the SSO's `OAUTH_ISSUER`** to the browser-facing HTTPS URL the proxy
serves the SSO at (e.g. `https://sso.example.com`).
3. **Register the proxy as an OIDC client** in the SSO, with a `redirectUri`
matching the proxy's callback (`https://proxy.example.com/api/auth/oidc/callback`).
4. **LDAP**: point `app_ldap__url` at `ldaps://sso-manager:636` and create a
dedicated service account (`cn=ldapclient,ou=people,…`) — don't reuse the
admin DN. For the SSO's self-signed LDAPS cert, set
`app_ldap__tlsOptions__rejectUnauthorized=false` (or mount the cert and use
`app_ldap__tlsOptions__ca=<path>`).
The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo
automates all four steps with `./setup.sh`.
---
## Security notes
1. **Never commit `secrets.js`** — it's in `.gitignore`.
2. **Bind the management port to localhost** (the compose does: `127.0.0.1:3000`).
The OpenResty front proxies the UI under TLS; don't expose 3000 to the LAN.
3. **`REAL_IP_FROM` empty by default** — the proxy trusts no one to set `X-Real-IP`
(it's the front). Only set it if a trusted proxy sits in front.
4. **LDAPS for any LDAP that crosses the network.** Use `ldaps://`/StartTLS; plain
`ldap://` is fine only on a private docker network.
5. The image runs OpenResty workers as `nobody` and the node app as root (matches
the bare-metal systemd unit). Harden to a non-root user for production if needed.
+1 -2
View File
@@ -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 ─────────────────────────────────────────────────────────────────
@@ -114,8 +115,6 @@ COPY nodejs/api.md ./api.md
# docs/ mirrors the repo's own top-level docs/ folder.
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit
+1 -1
View File
@@ -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:
+39 -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)
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
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
@@ -51,6 +55,7 @@ provider + LDAP directory you already run.
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
- Wildcard SSL certificate support with automatic renewal
- Dynamic host routing with wildcard domain matching (*, **)
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
- Web-based management interface
- RESTful API for automation
- **OIDC login** — the proxy is an OpenID Connect client of an external SSO
@@ -127,10 +132,15 @@ This installer will:
- Install and configure Redis
- Set up SSL fallback certificates
- 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
- 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)
The all-in-one image runs OpenResty in the foreground and the Node app in the
@@ -152,6 +162,23 @@ docker compose exec proxy tail -f /var/log/nginx/error.log
docker compose logs --tail=200 --since=10m proxy
```
## Secrets
Secrets are loaded from **OpenBao** at boot via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/proxy/conf` over the file-loaded config. The proxy's OIDC
`clientSecret` is captured at require time (inside `createOidcClient` during
`require('../models')`), so `bin/www` runs `bao-conf.init()` **before**
`require('../app')` (which transitively loads models). Fail-soft: if OpenBao is
unreachable, boot continues from `CONF_SECRETS`. The proxy authenticates to
OpenBao with the scoped `VAULT_TOKEN` (env, policy `proxy` — read only
`secret/proxy/conf`), never the root token.
The `config/proxy-secrets.js` file is an operator-edit seed artifact
(gitignored); the bootstrap writes the generated OAuth client creds into
OpenBao, which is authoritative. For the full architecture see theta-env's
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
## Manual Installation
For manual installation or other distributions, see the detailed steps below.
@@ -224,15 +251,24 @@ cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
Clone and install:
```bash
cd /var/www
mkdir -p /opt/theta42
cd /opt/theta42
git clone https://github.com/theta42/proxy.git
cd proxy/nodejs
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:
```bash
cp ops/proxy.service /etc/systemd/system/proxy.service
cp ../ops/proxy.service /etc/systemd/system/proxy.service
systemctl daemon-reload
systemctl enable 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
# 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
# and `app_*` env vars (requires @simpleworkjs/conf >= 1.1.0, pinned in
# The app reads its config from conf/base.js deep-merged with a secrets file
# 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
# 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
# from the file (no app_* env needed). Without the mount, supply the same config
# via `app_*` env (compose `environment:` / `env_file:`).
# points CONF_SECRETS at it so the app reads oidc/ldap/auth config straight
# from the mounted file (no app_* env needed, no write access to /app/conf
# 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
# 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 ─────────────────────────────────────────
# 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
# @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_* env
# should then be passed — app_* env beats secrets.js in @simpleworkjs/conf
# 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 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
# 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).
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)"
fi
-39
View File
@@ -1,39 +0,0 @@
# Documentation
This directory contains the GitHub Pages documentation site for the Proxy project.
**Live site:** https://theta42.github.io/proxy/
## Pages
- `index.md` - Home page with project overview
- `installation.md` - Installation and setup guide
- `api.md` - Complete API reference
- `architecture.md` - System architecture and design
- `contributing.md` - Development and contribution guide
## Local Preview
To preview the site locally:
```bash
# Install Jekyll (one-time setup)
gem install jekyll bundler
# Run local server
cd docs
jekyll serve
# View at http://localhost:4000/proxy/
```
## Theme
The site uses the Cayman theme (`jekyll-theme-cayman`). Configuration is in `_config.yml`.
## Updating Documentation
1. Edit markdown files in this directory
2. Commit and push to master branch
3. GitHub Pages automatically rebuilds (may take 1-2 minutes)
4. Changes visible at https://theta42.github.io/proxy/
-47
View File
@@ -1,47 +0,0 @@
title: Proxy
description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with an OIDC + LDAP-aware management API and web GUI.
url: "https://theta42.github.io"
baseurl: "/proxy"
logo: /assets/img/theta42.svg
lang: en_US
plugins:
- jekyll-seo-tag
- jekyll-sitemap
github:
repository_url: https://github.com/theta42/proxy
zip_url: https://github.com/theta42/proxy/archive/refs/heads/master.zip
tar_url: https://github.com/theta42/proxy/archive/refs/heads/master.tar.gz
repository_name: theta42/proxy
nav:
- title: Home
page: /
icon: fa-house
- title: Installation
page: /installation.html
icon: fa-download
- title: Architecture
page: /architecture.html
icon: fa-sitemap
- title: API
page: /api.html
icon: fa-code
- title: Docker
page: /docker.html
icon: fa-box
- title: Contributing
page: /contributing.html
icon: fa-code-branch
- title: Changelog
url: https://github.com/theta42/proxy/blob/master/CHANGELOG.md
icon: fa-list
defaults:
- scope:
path: ""
type: "pages"
values:
layout: default
image: /assets/img/theta42.svg
-82
View File
@@ -1,82 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/favicon.svg' | relative_url }}">
{% seo title=false %}
<title>{% if page.title %}{{ page.title }} &middot; {% endif %}{{ site.title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<div class="container-fluid px-3">
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
{{ site.title }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navMain">
<ul class="navbar-nav">
{% for item in site.nav %}
<li class="nav-item">
{% if item.page %}
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% else %}
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1" style="margin-top: 4.5rem;">
<div class="container-fluid py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card shadow-lg">
<div class="card-body p-4 p-md-5 site-content">
{{ content }}
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="py-3 bg-dark text-light mt-auto">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank" rel="noopener">
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
</a>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-solid fa-list"></i> Changelog
</a>
</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
-972
View File
@@ -1,972 +0,0 @@
---
layout: default
title: API Reference
description: The proxy's management REST API — hosts, DNS providers, users, groups, and permissions.
---
# API Documentation
[← Back to Home](index.html)
All API endpoints require authentication unless otherwise noted. Three
authentication methods are supported:
- **`auth-token` header** — a browser-session token from `POST /api/auth/login`
or the OIDC flow (below).
- **`Authorization: Bearer <token>` header** — a self-service API token (PAT,
see [API Tokens](#api-tokens)), for scripts/CI without a browser session.
- **OIDC (browser)** — if the proxy is configured as an OIDC client of an SSO
(`app_oidc__*` / `conf.oidc`, see [DEPLOYMENT.md](https://github.com/theta42/proxy/blob/master/DEPLOYMENT.md)),
users can log in via `GET /api/auth/oidc/start` instead of posting a
username/password.
The proxy can also be configured as a **direct LDAP client** (`app_ldap__*` /
`conf.ldap`) for looking up/validating users, independent of the OIDC flow —
see DEPLOYMENT.md for the full configuration reference.
Authenticated requests also carry **RBAC** (role-based access control):
global admins can manage everything; other users are scoped to `viewer` or
`manager` rights on specific domains via [Permissions](#permissions) and
[Groups](#groups).
Base URL: `https://your-proxy-host.com/api`
---
## Authentication
### Login
**POST** `/api/auth/login`
Authenticate a user and receive an auth token.
```bash
curl -H "Content-Type: application/json" \
-X POST \
-d '{"username": "myuser", "password": "mypassword"}' \
https://proxy-host.com/api/auth/login
```
**Responses:**
- `200` `{"login": true, "token": "027d3964-7d81-4462-a6f9-2c1f9b40b4be", "message": "myuser logged in!"}`
- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}`
### Logout
**ALL** `/api/auth/logout`
Invalidate the current auth token.
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/auth/logout
```
**Responses:**
- `200` `{"message": "Bye"}`
### OIDC Login (start)
**GET** `/api/auth/oidc/start`
Begin the OIDC authorization-code flow: creates a PKCE + state challenge and
redirects the browser to the configured SSO's authorize endpoint. Only
available when `conf.oidc.enabled` is true.
**Query Parameters:**
- `redirect` - Internal path to return to after login (optional; sanitized to same-origin)
```bash
curl -i "https://proxy-host.com/api/auth/oidc/start?redirect=/hosts"
```
**Responses:**
- `302` Redirect to the SSO's authorization endpoint
- `404` `{"name": "OidcDisabled", "message": "OIDC login is not enabled."}`
### OIDC Callback
**GET** `/api/auth/oidc/callback`
Redirect target for the SSO after login. Validates the one-time `state`,
exchanges the authorization `code` for tokens, reads identity from the
userinfo endpoint, establishes a session, and redirects the browser back to
the login page with the app's own `auth-token` in a URL fragment.
**Query Parameters:**
- `code` (required) - Authorization code from the SSO
- `state` (required) - State value from the `start` step
```bash
# Not called directly — the SSO redirects the browser here after login.
```
**Responses:**
- `302` Redirect to `/login#token=...&redirect=...`
- `400` `{"name": "OidcCallbackInvalid", "message": "Missing code or state."}` or expired/unknown state
---
## API Tokens
Self-service personal access tokens (PATs) for scripting/CI without a browser
session. Every endpoint is owner-scoped: a user only sees/manages tokens they
created. Mounted at `/api/api-token`.
### List API Tokens
**GET** `/api/api-token`
List the current user's API tokens.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/api-token
```
**Responses:**
- `200` `{"results": [{"id": "...", "name": "ci", ...}, ...]}`
### Create API Token
**POST** `/api/api-token`
Create a new API token. The raw token string is only returned once, at
creation.
**Parameters:**
- `name` (required) - Display name
- `description` (optional)
- `expires_in_days` (optional) - `0` or omitted means no expiry
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "ci", "expires_in_days": 90}' \
https://proxy-host.com/api/api-token
```
**Responses:**
- `200` `{"results": {...}, "token": "prx_<id>_<secret>", "message": "API token 'ci' created. Save it now — it will not be shown again."}`
### Get API Token
**GET** `/api/api-token/:id`
Get a token's metadata (not the raw secret, which is never stored/returned again).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/api-token/<id>
```
**Responses:**
- `200` `{"results": {...}}`
- `403` Not your token
### Update API Token
**PUT** `/api/api-token/:id`
Update a token's name/description/expiry.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"name": "ci-updated"}' \
https://proxy-host.com/api/api-token/<id>
```
**Responses:**
- `200` `{"results": {...}, "message": "API token 'ci-updated' updated."}`
### Delete (Revoke) API Token
**DELETE** `/api/api-token/:id`
Revoke a token immediately.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/api-token/<id>
```
**Responses:**
- `200` `{"id": "<id>", "message": "API token 'ci' revoked."}`
### Rotate API Token
**POST** `/api/api-token/:id/rotate`
Issue a new secret for an existing token (same id, new raw value shown once).
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/api-token/<id>/rotate
```
**Responses:**
- `200` `{"token": "prx_<id>_<new-secret>", "message": "API token 'ci' rotated. Save it — it will not be shown again."}`
---
## Users
All user endpoints require authentication. `GET /me` and `PUT /password`
(self-service) work for any authenticated user; everything else (listing,
creating, deleting users, resetting another user's password) requires global
admin.
### List Users
**GET** `/api/user`
Get list of all users. Admin only.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/user
```
**Query Parameters:**
- `detail` - Include full user details (optional)
**Responses:**
- `200` `{"results": ["user1", "user2"]}`
- `200` `{"results": [{"username": "user1", ...}, ...]}` (with `?detail=true`)
- `403` Not an admin
### Get Current User
**GET** `/api/user/me`
Get the currently authenticated user's identity and effective RBAC rights
(drives the web UI's nav/button gating).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/user/me
```
**Responses:**
- `200` `{"username": "myuser", "groups": [...], "localGroups": [...], "externalGroups": [...], "isAdmin": false, "global": null, "domains": {...}}`
### Create User
**POST** `/api/user`
Create a new local user. Admin only.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"username": "newuser", "password": "newpassword"}' \
https://proxy-host.com/api/user
```
**Responses:**
- `200` User created successfully
- `403` Not an admin
- `409` Username already exists
- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error (also returned for weak passwords)
### Delete User
**DELETE** `/api/user/:username`
Delete a user account. Admin only.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/user/olduser
```
**Responses:**
- `200` `{"username": "olduser", "results": ...}`
- `403` Not an admin
- `404` User not found
### Change Password (Self)
**PUT** `/api/user/password`
Change the password for the currently authenticated user.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"password": "newpassword"}' \
https://proxy-host.com/api/user/password
```
**Responses:**
- `200` `{"results": ...}` Password changed successfully
- `422` Weak password rejected by the password policy
### Change Password (Other User)
**PUT** `/api/user/password/:username`
Change the password for another user. Admin only.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"password": "newpassword"}' \
https://proxy-host.com/api/user/password/otheruser
```
**Responses:**
- `200` `{"results": ...}` Password changed successfully
- `403` Not an admin
- `404` User not found
---
## Permissions
RBAC: grants a `viewer` or `manager` role to a user or group, either globally
or scoped to one domain. Global-admin-only. Mounted at `/api/permission`.
### List Permissions
**GET** `/api/permission`
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/permission
```
**Responses:**
- `200` `{"results": [{"id": "...", "subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com", ...}, ...]}`
### List Permission Subjects
**GET** `/api/permission/subjects`
Autocomplete source for the "Subject" field: known usernames plus known group
names (local groups, groups already used in permissions, and groups from
`conf.auth.adminGroups` / `conf.auth.groupRoleMap`).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/permission/subjects
```
**Responses:**
- `200` `{"users": ["alice", "bob"], "groups": ["ops", "sre"]}`
### Create Permission
**POST** `/api/permission`
Grant a role to a subject.
**Parameters:**
- `subjectType` (required) - `user` or `group`
- `subject` (required) - username or group name
- `role` (required) - `viewer` or `manager`
- `scope` (required) - `global` or `domain`
- `domain` (required if `scope` is `domain`)
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com"}' \
https://proxy-host.com/api/permission
```
**Responses:**
- `200` `{"message": "Granted manager to user \"alice\" on example.com.", ...}`
- `422` Validation error
### Delete Permission
**DELETE** `/api/permission/:id`
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/permission/<id>
```
**Responses:**
- `200` `{"message": "Permission <id> removed."}`
---
## Groups
Local groups (independent of any SSO/LDAP groups) used as subjects for
permission grants. Global-admin-only. Mounted at `/api/group`.
### List Groups
**GET** `/api/group`
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/group
```
**Responses:**
- `200` `{"results": [{"name": "ops", "members": ["alice", "bob"], ...}, ...]}`
### Create Group
**POST** `/api/group`
**Parameters:**
- `name` (required)
- `members` (optional) - array of usernames
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "ops", "members": ["alice"]}' \
https://proxy-host.com/api/group
```
**Responses:**
- `200` `{"message": "Group \"ops\" created.", ...}`
### Delete Group
**DELETE** `/api/group/:name`
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/group/ops
```
**Responses:**
- `200` `{"message": "Group \"ops\" removed."}`
### Add Group Member
**POST** `/api/group/:name/members`
**Parameters:**
- `username` (required)
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"username": "bob"}' \
https://proxy-host.com/api/group/ops/members
```
**Responses:**
- `200` `{"message": "Added \"bob\" to \"ops\".", ...}`
### Remove Group Member
**DELETE** `/api/group/:name/members/:username`
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/group/ops/members/bob
```
**Responses:**
- `200` `{"message": "Removed \"bob\" from \"ops\".", ...}`
---
## Hosts
Manage proxy host configurations.
### List Hosts
**GET** `/api/host`
Get list of all configured hosts.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host
```
**Query Parameters:**
- `detail` - Include full host details (optional)
**Responses:**
- `200` `{"results": ["example.com", "*.wildcard.com"]}`
- `200` `{"results": [{"host": "example.com", "ip": "192.168.1.10", ...}, ...]}` (with `?detail=true`)
### Get Host
**GET** `/api/host/:host`
Get configuration for a specific host.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host/example.com
```
**Responses:**
- `200` `{"item": "example.com", "results": {"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, ...}}`
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
### Lookup Host
**GET** `/api/host/lookup/:domain`
Test the host lookup algorithm (supports wildcard matching).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host/lookup/sub.example.com
```
**Responses:**
- `200` `{"string": "sub.example.com", "results": {"host": "*.example.com", ...}}`
- `200` `{"string": "sub.example.com", "results": null}` (no match)
### Get Lookup Tree
**GET** `/api/host/lookupobj`
Get the internal lookup tree structure (for debugging).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host/lookupobj
```
**Responses:**
- `200` `{"results": {"com": {"example": {...}}}}`
### Create Host
**POST** `/api/host`
Add a new host configuration.
**Parameters:**
- `host` (required) - Domain name (e.g., `example.com`, `*.example.com`)
- `ip` (required) - Target IP address or FQDN
- `targetPort` (required) - Target port number (1-65535)
- `forcessl` (optional) - Force HTTPS redirect (default: true)
- `targetssl` (optional) - Use HTTPS to backend (default: false)
- `challengeType` (optional) - For wildcards: `DNS-01-wildcard` or `wildcardChild`
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, "forcessl": true, "targetssl": false}' \
https://proxy-host.com/api/host
```
**Responses:**
- `200` `{"message": "\"example.com\" added.", "host": "example.com", ...}`
- `409` `{"name": "HostNameUsed", "message": "Host already exists"}`
- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error
### Update Host
**PUT** `/api/host/:host`
Update an existing host configuration.
**Parameters:** Same as Create Host (all optional)
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"ip": "192.168.1.20", "targetPort": 9000}' \
https://proxy-host.com/api/host/example.com
```
**Responses:**
- `200` `{"message": "\"example.com\" updated.", ...}`
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
- `422` Validation error
### Delete Host
**DELETE** `/api/host/:host`
Remove a host configuration.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/host/example.com
```
**Responses:**
- `200` `{"message": "example.com deleted", ...}`
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
### Clear Host Cache
**DELETE** `/api/host/cache`
Remove all cached wildcard-subdomain host lookups. Cache entries are created on
demand when a wildcard host serves a subdomain; clearing them forces the next
request for each subdomain to be resolved fresh through the lookup tree.
Admin only.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/host/cache
```
**Responses:**
- `200` `{"message": "Cleared 3 cached hosts.", "count": 3}`
### Renew Wildcard Certificate
**PUT** `/api/host/:host/renew`
Manually trigger wildcard certificate renewal.
```bash
curl -H "auth-token: your-token-here" \
-X PUT \
https://proxy-host.com/api/host/*.example.com/renew
```
**Responses:**
- `200` `{"message": "Requesting wildcard cert for *.example.com"}`
- `404` Host not found
---
## DNS Providers
Manage DNS provider integrations for wildcard SSL certificates.
### List DNS Providers
**GET** `/api/dns`
Get list of configured DNS providers.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns
```
**Query Parameters:**
- `detail` - Include full provider details (optional)
**Responses:**
- `200` `{"results": ["provider-id-1", "provider-id-2"]}`
### List Available Provider Types
**OPTIONS** `/api/dns`
Get list of supported DNS provider types and their configuration requirements.
```bash
curl -H "auth-token: your-token-here" \
-X OPTIONS \
https://proxy-host.com/api/dns
```
**Responses:**
- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}`
### Create DNS Provider
**POST** `/api/dns`
Configure a new DNS provider.
**Cloudflare:**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My Cloudflare", "dnsProvider": "Cloudflare", "token": "your-api-token"}' \
https://proxy-host.com/api/dns
```
**DigitalOcean:**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My DO", "dnsProvider": "DigitalOcean", "token": "your-api-token"}' \
https://proxy-host.com/api/dns
```
**PorkBun:**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My PorkBun", "dnsProvider": "PorkBun", "apiKey": "pk_xxx", "secretApiKey": "sk_xxx"}' \
https://proxy-host.com/api/dns
```
**DuckDNS (free):**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "subdomains": "myhost,myhost2"}' \
https://proxy-host.com/api/dns
```
`subdomains` is a comma-separated list of the subdomains you've registered at
[duckdns.org](https://www.duckdns.org) (e.g. `myhost` for
`myhost.duckdns.org`), since DuckDNS has no API to list them for you.
DuckDNS only supports one A/AAAA record and one TXT record per domain (no
arbitrary sub-records) — enough for dynamic DNS and DNS-01 wildcard certs.
**Responses:**
- `200` `{"message": "\"provider-id\" added.", ...}`
- `422` Validation error or invalid API credentials
### Get DNS Provider
**GET** `/api/dns/:id`
Get a specific DNS provider configuration.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/provider-id
```
**Responses:**
- `200` `{"item": "provider-id", "results": {...}}`
- `404` Provider not found
### Update DNS Provider
**PUT** `/api/dns/:id`
Update DNS provider configuration.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"name": "Updated Name"}' \
https://proxy-host.com/api/dns/provider-id
```
**Responses:**
- `200` `{"message": "\"provider-id\" updated.", ...}`
- `404` Provider not found
### Delete DNS Provider
**DELETE** `/api/dns/:id`
Remove a DNS provider and all associated domains.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/dns/provider-id
```
**Responses:**
- `200` `{"message": "provider-id deleted", ...}`
- `404` Provider not found
### List Domains
**GET** `/api/dns/domain`
List all domains from all configured providers.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/domain
```
**Query Parameters:**
- `detail` - Include full domain details (optional)
**Responses:**
- `200` `{"results": ["example.com", "test.com"]}`
### Get Domain
**GET** `/api/dns/domain/:domain`
Get details for a specific domain.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/domain/example.com
```
**Responses:**
- `200` `{"results": [{"domain": "example.com", "zoneId": "...", ...}]}`
- `404` Domain not found
### Refresh Domains
**POST** `/api/dns/domain/refresh/:providerId`
Refresh the domain list from a DNS provider's API.
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/dns/domain/refresh/provider-id
```
**Responses:**
- `200` `{"results": ...}` Updated domain list
- `404` Provider not found
### Dynamic DNS
A-records kept automatically pointed at this box's public (WAN) IP. All
`/api/dns/dynamic*` routes are viewer/manager scoped to the record's domain
(via [Permissions](#permissions)), not admin-only like the rest of `/api/dns`.
#### Get Current Public IP
**GET** `/api/dns/dynamic/ip`
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/dynamic/ip
```
**Responses:**
- `200` `{"ip": "203.0.113.5"}`
#### List Dynamic Records
**GET** `/api/dns/dynamic`
Lists records the caller may view (their own/granted domains, or all for admins).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/dynamic
```
**Responses:**
- `200` `{"results": [{"id": "...", "domain": "example.com", "name": "home", "last_status": "ok", ...}, ...]}`
#### Create Dynamic Record
**POST** `/api/dns/dynamic`
Requires `manager` rights on the target domain. Applies the record immediately
against the current public IP (best-effort — failures are recorded in
`last_status` and retried by the scheduler).
**Parameters:**
- `domain` (required)
- `name` (required) - sub-label, or `@` for the apex
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"domain": "example.com", "name": "home"}' \
https://proxy-host.com/api/dns/dynamic
```
**Responses:**
- `200` `{"message": "\"home.example.com\" added.", ...}`
- `403` Missing `manager` rights on the domain
- `422` Validation error
#### Refresh Dynamic Record
**POST** `/api/dns/dynamic/:id/refresh`
Force an immediate refresh of one record against the current public IP.
Requires `manager` rights on the record's domain.
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/dns/dynamic/<id>/refresh
```
**Responses:**
- `200` `{"message": "Refreshed \"home.example.com\".", "result": {...}}`
- `403` Missing `manager` rights on the domain
#### Delete Dynamic Record
**DELETE** `/api/dns/dynamic/:id`
Stop managing a record. Requires `manager` rights on the record's domain.
Leaves the provider's A record in place at its last value.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/dns/dynamic/<id>
```
**Responses:**
- `200` `{"message": "home.example.com removed.", ...}`
- `403` Missing `manager` rights on the domain
---
## Certificates
Retrieve SSL certificate information.
### Get Certificate
**GET** `/api/cert/:host`
Get the SSL certificate for a host.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/cert/example.com
```
**Responses:**
- `200` Certificate data including `cert_pem`, `fullchain_pem`, `privkey_pem`, expiry information
- `404` Certificate not found
---
## Error Responses
All endpoints may return the following error responses:
- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}` - Authentication required or invalid
- `404` `{"name": "NotFound", "message": "..."}` - Resource not found
- `422` `{"name": "ObjectValidateError", "message": [...], "keys": [...]}` - Validation errors
- `500` Internal server error
## Notes
- All timestamps are in milliseconds since epoch
- Authenticated endpoints accept either the `auth-token` header (browser
session / OIDC login) or an `Authorization: Bearer <token>` API token
- Host names support wildcards: `*` (single level) and `**` (multi-level)
- DNS providers are validated on creation - invalid API credentials will be rejected
- Wildcard certificates are automatically renewed 30 days before expiration
-286
View File
@@ -1,286 +0,0 @@
---
layout: default
title: Architecture
description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit together.
---
# Architecture
[← Back to Home](index.html)
## System Overview
The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management.
```
┌──────────────────────────────────────────────────────────────┐
│ Internet │
└─────────────────────────┬────────────────────────────────────┘
│ HTTPS/HTTP
┌──────────────────────────────────────────────────────────────┐
│ OpenResty/Nginx │
│ ┌────────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ SSL Termination│ │ Host Routing │ │ Request Proxying│ │
│ │ (lua-resty- │ │ (targetinfo. │ │ │ │
│ │ auto-ssl) │ │ lua) │ │ │ │
│ └────────────────┘ └──────┬───────┘ └─────────────────┘ │
└────────────┬──────────────────┼───────────────────────────┬──┘
│ │ │
Let's Encrypt 1. Check Redis FIRST Backend
HTTP-01 2. Unix Socket (fallback) Services
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────────────────┐
│ Redis │ │ Node.js Application │
│ (Primary Cache) │ │ ┌──────────────┐ ┌─────────┐ │
│ - Host configs ◄────┼──┼──┤ Services │ │ Routes │ │
│ - User accounts │ │ │ - host_lookup│ │ - /api/*│ │
│ - SSL certs │ │ │ - scheduler │ │ │ │
│ - Auth tokens │ │ └──────────────┘ └─────────┘ │
└──────────────────────┘ └─────────┬────────────────────────┘
┌──────────────────────┐
│ DNS Providers │
│ - Cloudflare │
│ - DigitalOcean │
│ - PorkBun │
│ - DuckDNS (free) │
│ (DNS-01 challenges) │
└──────────────────────┘
```
## Component Details
### OpenResty/Nginx (Frontend)
**Responsibilities:**
- Accept incoming HTTP/HTTPS requests
- SSL termination using lua-resty-auto-ssl
- Host-based routing decisions (Redis-first lookup)
- Proxy requests to backend services
**Key Features:**
- HTTP-01 ACME challenge handling for automatic SSL
- Redis-first host lookup with Node.js fallback via Unix socket
- High-performance event-driven architecture
- Support for WebSocket connections
- Continues serving cached hosts even if Node.js is down
**Configuration Files:**
- `/etc/openresty/nginx.conf` - Main configuration
- `/etc/openresty/autossl.conf` - Let's Encrypt integration
- `/etc/openresty/sites-enabled/000-proxy` - Proxy configuration
- `/usr/local/openresty/lualib/targetinfo.lua` - Host lookup module
### Node.js Application (Backend)
**Responsibilities:**
- API for host/user/DNS management
- Wildcard SSL certificate orchestration
- Host lookup tree maintenance
- User authentication and authorization
**Directory Structure:**
```
nodejs/
├── bin/www # Application entry point
├── conf/ # Configuration (base.js, environment overlays, secrets.js)
├── controller/ # App-level wiring (pubsub, startup)
├── migrations/ # One-off Redis data migration scripts
├── models/ # Data models
│ ├── host.js # Host configuration and lookup
│ ├── auth.js # Authentication logic
│ ├── user.js # User management
│ └── dns_provider/ # DNS provider implementations
├── routes/ # API endpoints
│ ├── host.js # Host CRUD operations
│ ├── dns.js # DNS provider management
│ ├── user.js # User management
│ ├── auth.js # Authentication (login + OIDC)
│ ├── permission.js # RBAC permission management
│ ├── group.js # Local group management
│ └── api_token.js # Self-service API (PAT) tokens
├── services/ # Background services
│ ├── host_lookup.js # Unix socket server
│ └── host_scheduler.js # Cert renewal scheduler
├── middleware/ # Express middleware
│ └── auth.js # Authentication middleware
└── utils/ # Utility modules
└── unix_socket_json.js # Unix socket server
```
### Redis (Data Store)
**ORM:** [model-redis](https://www.npmjs.com/package/model-redis) - A lightweight Redis ORM for Node.js with schema validation, relationships, and automatic key management.
**Stored Data:**
- Host configurations (domain, IP, port, SSL settings)
- User accounts and hashed passwords
- Authentication tokens
- SSL certificates (for wildcard domains)
- DNS provider credentials
- Domain-to-provider mappings
**Key Prefixes:**
```
proxy_Host_<hostname> # Host configuration
proxy_User_<username> # User account
proxy_AuthToken_<token> # Auth tokens
proxy_DnsProvider_<id> # DNS provider
proxy_Domain_<domain> # Domain info
<hostname>:latest # SSL certificate cache
```
## Request Flow
### Standard HTTP/HTTPS Request
1. **Client** sends HTTPS request to `app.example.com`
2. **OpenResty** receives request, terminates SSL
3. **Lua script** (`targetinfo.lua`) queries **Redis first** for host config
4. If **found in Redis**, jump to step 7 (Node.js not involved)
5. If **not in Redis**, Lua queries Node.js via Unix socket as fallback
6. **Node.js** performs host lookup (supports wildcards), caches result in Redis
7. **OpenResty** proxies request to backend service using target IP and port
8. **Response** proxied back to client
**Resilience**: If Node.js goes down, all hosts already cached in Redis continue to work. Only new/uncached hosts will fail until Node.js recovers.
### Wildcard SSL Certificate Request
1. **User** creates wildcard host (`*.example.com`) via API
2. **Node.js** validates domain has DNS provider configured
3. **Let's Encrypt** DNS-01 challenge initiated
4. **DNS provider** API creates TXT record (`_acme-challenge.example.com`)
5. **Let's Encrypt** validates TXT record
6. **Certificate** generated and stored in Redis
7. **DNS provider** cleans up TXT record
8. **Background scheduler** monitors expiration, renews 30 days before expiry
## Host Lookup Algorithm
The lookup tree enables sophisticated domain matching:
```
Input: "api.v1.example.com"
Tree Structure:
{
"com": {
"example": {
"*": { // Matches api.example.com
"#record": {...}
},
"v1": {
"api": { // Matches api.v1.example.com (exact)
"#record": {...}
}
}
}
}
}
Priority: Exact > Single wildcard (*) > Double wildcard (**)
```
**Wildcard Types:**
- `example.com` - Exact match only
- `*.example.com` - Matches `sub.example.com` (single level)
- `**.example.com` - Matches any depth (`sub.deep.example.com`)
- `api.*.example.com` - Matches `api.v1.example.com`, `api.v2.example.com`
## Security Architecture
### Authentication Flow
1. User sends credentials to `/api/auth/login`
2. Credentials validated against stored hash (bcrypt)
3. Token generated and stored in Redis with TTL
4. Token returned to client
5. Subsequent requests include token in `auth-token` header
6. Middleware validates token before processing request
### SSL Certificate Security
- **Private keys** stored only in Redis (memory/disk based on config)
- **Fallback certificates** used when SNI unavailable
- **Let's Encrypt** rate limiting respected
- **DNS provider credentials** marked as `isPrivate` (not returned in API)
### Unix Socket Communication
- Socket file: `/var/run/proxy_lookup.socket`
- Permissions: `777` (container-safe, single-use deployment)
- Protocol: JSON over Unix stream socket
- Buffer handling: Accumulates partial messages until complete JSON
## Performance Optimizations
### Caching Strategy
The system uses a multi-tier caching approach:
1. **Redis (L1 Cache)** - OpenResty checks Redis FIRST for every request
- Primary host configuration storage
- Survives Node.js restarts/failures
- Shared across all OpenResty workers
2. **Node.js Lookup Tree (L2 Cache)** - In-memory host lookup with wildcard matching
- Only queried when Redis has no entry
- Rebuilt automatically when hosts change
- Supports complex wildcard resolution
3. **Wildcard Parent Caching** - Resolved wildcard matches stored back to Redis
- Subsequent requests to `api.example.com` hit Redis directly
- No repeated wildcard resolution needed
### Unix Socket vs HTTP API
Unix socket chosen over HTTP for host lookups:
- **Lower latency** - No TCP overhead
- **Higher throughput** - No HTTP parsing
- **Simpler** - Direct JSON communication
- **Secure** - Filesystem permissions, no network exposure
## Scalability Considerations
### Current Architecture
- **Single instance** - OpenResty + Node.js + Redis on one server
- **Vertical scaling** - Add CPU/RAM as needed
- **Limitations** - Unix socket ties OpenResty to Node.js on same host
### Future Scaling Options
- **Redis cluster** - Distribute data storage
- **Multiple OpenResty instances** - Load balance incoming requests
- **Stateless Node.js** - Run multiple API instances
- **Replace Unix socket** - Use TCP/HTTP for cross-host communication
- **Separate cert management** - Dedicated service for wildcard SSL
## Monitoring and Observability
### Logs
- **OpenResty**: `/var/log/nginx/access.log`, `/var/log/nginx/error.log`
- **Node.js**: `journalctl -u proxy.service`
- **Redis**: `redis-cli MONITOR`
### Health Checks
- Node.js API: `curl http://localhost:3000/api/host`
- Redis: `redis-cli PING`
- OpenResty: `systemctl status openresty`
- Unix socket: `ls -la /var/run/proxy_lookup.socket`
### Metrics to Monitor
- Request rate and response times
- SSL certificate expiration dates
- Redis memory usage
- Host lookup cache hit rate
- Background service execution times
[← Back to Home](index.html)
-116
View File
@@ -1,116 +0,0 @@
/* theta42 docs site — shares the in-app dark navbar/footer + card look
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
generic Jekyll theme. */
body {
background-color: #f4f5f6;
}
.navbar-brand img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
}
.navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Markdown content typography, scoped to the card body so it doesn't leak
into the nav/footer. */
.site-content h1:first-child {
margin-top: 0;
}
.site-content h1,
.site-content h2,
.site-content h3 {
font-weight: 700;
}
.site-content h2 {
margin-top: 2.5rem;
padding-bottom: .4rem;
border-bottom: 1px solid #e9ecef;
}
.site-content h3 {
margin-top: 1.75rem;
}
.site-content a {
color: #a3671f;
text-decoration-color: rgba(163, 103, 31, .35);
}
.site-content a:hover {
color: #8a5a16;
}
.site-content pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem 1.25rem;
border-radius: .375rem;
overflow-x: auto;
}
.site-content code {
color: #a3671f;
background-color: #f4f0e8;
padding: .15em .4em;
border-radius: .25rem;
font-size: .875em;
}
.site-content pre code {
color: inherit;
background: none;
padding: 0;
}
.site-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
margin: 1.25rem 0;
}
.site-content table th,
.site-content table td {
border: 1px solid #dee2e6;
padding: .5rem .75rem;
text-align: left;
}
.site-content table th {
background-color: #f8f9fa;
}
.site-content blockquote {
border-left: 4px solid #C59341;
padding: .5rem 1rem;
margin: 1.25rem 0;
background-color: #f8f6f1;
color: #495057;
}
.site-content img {
max-width: 100%;
height: auto;
}
/* Screenshot grids in the markdown use width="49%" inline attrs for a
two-up desktop layout -- stack them on narrow screens instead of
squeezing to illegibility. */
@media (max-width: 576px) {
.site-content img[width] {
width: 100% !important;
margin-bottom: .75rem;
}
}
.site-content hr {
margin: 2rem 0;
border-top: 1px solid #e9ecef;
}
-17
View File
@@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
<!-- Network nodes -->
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
<!-- Connection lines -->
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 788 B

-51
View File
@@ -1,51 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-344
View File
@@ -1,344 +0,0 @@
---
layout: default
title: Contributing
description: How to contribute to the proxy — dev setup, tests, and code conventions.
---
# Contributing Guide
[← Back to Home](index.html)
Thank you for considering contributing to the Proxy project! This guide will help you get started.
## Development Setup
### Prerequisites
- Node.js 18+ (18.x, 20.x, or 22.x recommended)
- Redis server
- Git
### Local Development
1. **Clone the repository**
```bash
git clone https://github.com/theta42/proxy.git
cd proxy/nodejs
```
2. **Install dependencies**
```bash
npm install
```
3. **Start Redis** (if not already running)
```bash
redis-server
```
4. **Run in development mode**
```bash
npm run dev
```
This starts the Node.js API with nodemon for auto-reload on file changes.
5. **Access the API**
- API: `http://localhost:3000/api`
- Web UI: `http://localhost:3000`
## Testing
The project uses Node.js built-in test runner (requires Node 18+).
### Running Tests
```bash
# Run all tests
npm test
# Run only unit tests
npm run test:unit
# Run only integration tests
npm run test:integration
# Watch mode for development
npm run test:watch
```
### Test Structure
```
test/
├── unit/ # Unit tests for isolated components
│ ├── basicauth.test.js
│ ├── callback_queue.test.js
│ ├── dynamic_record.test.js
│ ├── host_features.test.js
│ ├── host_lookup.test.js
│ ├── hostname_validate.test.js
│ ├── host_sso.test.js
│ ├── oidc.test.js
│ ├── password_policy.test.js
│ ├── roles.test.js
│ ├── safe_redirect.test.js
│ ├── unix_socket.test.js
│ └── wildcard_matchany.test.js
├── integration/ # Integration tests
│ └── dns_provider.test.js
└── helpers/ # Test utilities
└── dns_provider_contract.js
```
### Writing Tests
We test **custom logic**, not third-party libraries:
**DO test:**
- Host lookup algorithm
- Socket buffering logic
- DNS provider contracts
- Custom utility functions
**DON'T test:**
- Express.js routing
- Redis ORM
- External DNS APIs (use mocks instead)
### Adding DNS Provider Tests
When adding a new DNS provider, you **must** add contract tests:
```javascript
describe('NewProvider Provider', () => {
const NewProvider = require('../../models/dns_provider/newprovider');
test('should meet DNS provider contract', () => {
const mockCredentials = {api_key: 'mock-key'};
const instance = validateDnsProviderContract(NewProvider, mockCredentials);
assert.ok(instance);
});
test('should have valid method signatures', () => {
const instance = new NewProvider({api_key: 'mock'});
validateMethodSignatures(instance);
});
test('should validate key mapping', () => {
const instance = new NewProvider({api_key: 'mock'});
validateKeyMapping(instance);
});
test('should validate type checking', () => {
const instance = new NewProvider({api_key: 'mock'});
validateTypeChecking(instance);
});
});
```
See `test/integration/dns_provider.test.js` for examples.
## Code Style
### General Guidelines
- Use strict mode: `'use strict';`
- Use tabs for indentation
- Clear, descriptive variable names
- Comment complex logic
- No trailing whitespace
### File Organization
```javascript
'use strict';
// 1. Node.js built-ins
const fs = require('fs');
const path = require('path');
// 2. Third-party modules
const express = require('express');
const redis = require('redis');
// 3. Local modules
const {Host} = require('./models');
const middleware = require('./middleware/auth');
// 4. Code...
```
### Naming Conventions
- Classes: `PascalCase`
- Functions: `camelCase`
- Constants: `UPPER_SNAKE_CASE`
- Private methods: `__privateMethod` (double underscore prefix)
## Project Structure
Understanding the codebase:
```
nodejs/
├── conf/ # Configuration (base.js, environment overlays, secrets.js)
├── controller/ # App-level wiring (pubsub, startup)
├── migrations/ # One-off Redis data migration scripts
├── models/ # Data models (Host, User, DNS providers)
├── routes/ # API route handlers
├── services/ # Background services (lookup, scheduler)
├── middleware/ # Express middleware
├── utils/ # Utility functions
├── public/ # Static web assets
├── views/ # EJS templates
└── test/ # Test suite
```
## Pull Request Process
### Before Submitting
1. **Run tests** - Ensure all tests pass
```bash
npm test
```
2. **Test locally** - Verify your changes work
```bash
npm run dev
```
3. **Update documentation** - Keep docs in sync with code changes
4. **Commit messages** - Use clear, descriptive messages
```
Add DNS provider for Route53
- Implement Route53 DNS API client
- Add contract tests for Route53
- Update documentation with Route53 setup
```
### Submitting a PR
1. **Fork the repository**
2. **Create a feature branch**
```bash
git checkout -b feature/my-new-feature
```
3. **Make your changes**
4. **Commit your changes**
```bash
git add .
git commit -m "Description of changes"
```
5. **Push to your fork**
```bash
git push origin feature/my-new-feature
```
6. **Open a Pull Request** on GitHub
### PR Requirements
- All tests must pass (CI/CD runs automatically)
- Tests run on Node.js 18.x, 20.x, and 22.x
- No merge conflicts with `master`
- Code follows project conventions
- New features include tests
- Documentation updated if needed
### CI/CD Process
When you open a PR:
1. GitHub Actions automatically runs tests
2. Tests execute on multiple Node.js versions
3. PR cannot be merged until all checks pass
4. Review from maintainers
5. Merge to master
## Data Models
The project uses [model-redis](https://www.npmjs.com/package/model-redis) as the ORM for Redis data storage. All models extend the `Table` class and use a declarative schema via `_keyMap`.
**Example Model:**
```javascript
const Table = require('../utils/redis_model');
class Host extends Table {
static _key = 'host'; // Primary key field
static _keyMap = {
'host': {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},
'forcessl': {default: true, type: 'boolean'},
'created_on': {default: () => Date.now(), type: 'number'}
};
}
```
**Learn more:** [model-redis documentation](https://www.npmjs.com/package/model-redis)
## Adding Features
### Adding a DNS Provider
1. **Create provider file** in `models/dns_provider/yourprovider.js`
2. **Extend DnsApi base class**
```javascript
const {DnsApi} = require('./common');
class YourProvider extends DnsApi {
static _keyMap = {
api_key: {isRequired: true, type: 'string', isPrivate: true}
};
// Implement required methods
async listDomains() { }
async getRecords(domain, options) { }
async createRecord(domain, options) { }
async deleteRecords(domain, options) { }
}
```
3. **Add to provider list** in `models/dns_provider.js`
4. **Add contract tests** in `test/integration/dns_provider.test.js`
5. **Test your provider**
```bash
npm run test:integration
```
### Adding API Endpoints
1. **Add route** in appropriate file (`routes/`)
2. **Update API documentation** (`nodejs/api.md` and `docs/api.md` — keep them in sync)
3. **Test the endpoint** manually and add integration tests if needed
## Getting Help
- **Questions?** Open a [GitHub Discussion](https://github.com/theta42/proxy/discussions)
- **Bug reports** Use [GitHub Issues](https://github.com/theta42/proxy/issues)
- **Security issues** Email maintainers directly (see package.json)
## Code of Conduct
- Be respectful and inclusive
- Focus on constructive feedback
- Help others learn and grow
- Follow the project's technical direction
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
---
[← Back to Home](index.html) | [View on GitHub](https://github.com/theta42/proxy)
-141
View File
@@ -1,141 +0,0 @@
---
layout: default
title: Docker
description: Running the proxy's all-in-one Docker image — OpenResty, the management app, and Redis in one container.
---
# Docker Deployment
[← Back to Home](index.html)
The proxy ships as a single all-in-one Docker image bundling **OpenResty + the
Node management app + Redis** in one container, mirroring the bare-metal
[`ops/install.sh`](https://github.com/theta42/proxy/blob/master/ops/install.sh)
layout. This is the easiest way to run the proxy standalone, or as part of the
unified [theta-env](https://github.com/theta42/theta-env) stack.
## Quick start (standalone)
```bash
git clone https://github.com/theta42/proxy.git
cd proxy
mkdir -p config && chmod 700 config
cp secrets.js.example config/proxy-secrets.js # set OIDC/LDAP wiring
$EDITOR config/proxy-secrets.js
docker compose up -d --build
```
- Proxy (public, auto-SSL): `https://<host>/`
- Management UI / API: `http://127.0.0.1:3000/` (bound to localhost)
- Health: `http://127.0.0.1:3000/health``{"status":"ok"}`
## How configuration works
The app loads config via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf),
which deep-merges, in order:
1. `conf/base.js` (committed defaults)
2. `conf/<NODE_ENV>.js` (optional)
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.
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
`JSON.parse`-coerced when possible, kept as strings otherwise. `app_*` env is
still supported for advanced/standalone use — add the vars to the compose
`environment:` block yourself (the bundled compose no longer sets them).
> **Requires `@simpleworkjs/conf` >= 1.1.0.** The `app_*` env layer is not
> honored on 1.0.0. The lock is already on `^1.1.0`.
### Key `app_*` variables
| Env var | Sets |
|---------|------|
| `app_oidc__issuer` | `conf.oidc.issuer` (browser-facing SSO URL) |
| `app_oidc__authorizationEndpoint` | `conf.oidc.authorizationEndpoint` |
| `app_oidc__tokenEndpoint` | `conf.oidc.tokenEndpoint` (server-to-server; can be internal) |
| `app_oidc__userinfoEndpoint` | `conf.oidc.userinfoEndpoint` (server-to-server) |
| `app_oidc__endSessionEndpoint` | `conf.oidc.endSessionEndpoint` |
| `app_oidc__clientId` / `app_oidc__clientSecret` | OIDC client creds |
| `app_oidc__redirectUri` | `conf.oidc.redirectUri` (must match the SSO client) |
| `app_oidc__enabled` | `conf.oidc.enabled` (boolean) |
| `app_ldap__url` | `conf.ldap.url` (`ldaps://…:636` or `ldap://…:389`) |
| `app_ldap__bindDN` / `app_ldap__bindPassword` | LDAP service account |
| `app_ldap__searchBase` / `app_ldap__userFilter` | user search |
| `app_ldap__tlsOptions__rejectUnauthorized` | `false` for self-signed LDAPS |
| `app_ldap__tlsOptions__ca` | path to a CA cert for strict trust |
| `app_auth__adminUsers` | local anti-lockout admin (uid) |
| `app_auth__adminGroups` | SSO/LDAP groups that are global admin (JSON array) |
| `app_redis__prefix` | `conf.redis.prefix` (default `proxy_`) |
See [`DEPLOYMENT.md`](https://github.com/theta42/proxy/blob/master/DEPLOYMENT.md)
for the complete reference.
## OpenResty runtime env
| Variable | Default | Description |
|----------|---------|-------------|
| `RESOLVER` | `127.0.0.11` | DNS for upstream names in Host records (Docker's embedded DNS) |
| `REAL_IP_FROM` | _empty_ | Trusted CIDR for `X-Real-IP`. Empty = the proxy is the front (removes the real_ip block). Set to an upstream proxy's CIDR if one sits in front. |
## Auto-SSL / Let's Encrypt
`lua-resty-auto-ssl` stores certs in the bundled Redis. Redis is now AOF+RDB
persisted to the `proxy-data` volume (not in-memory), so **Let's Encrypt certs
survive container recreation** — no re-issue / rate-limit on rebuild. Port 80 is
required for HTTP-01 challenges (mapped in the compose). Back up + restore Redis
to back up + restore cert state (see *Backups and restore* in `DEPLOYMENT.md`).
## Fronting an SSO Manager
The proxy is a natural front for
[`theta42/sso-manager-node`](https://github.com/theta42/sso-manager-node): it
terminates TLS for the SSO's UI and protects it with OIDC login, while also
binding to the SSO's LDAP directly for user lookups. To run both together:
1. **One Docker network** so the proxy reaches the SSO internally at
`http://sso-manager:3001` (token/userinfo) and `ldaps://sso-manager:636`.
2. **Set the SSO's `app_oauth__issuer`** to the browser-facing HTTPS URL the
proxy serves the SSO at (e.g. `https://sso.example.com`).
3. **Register the proxy as an OIDC client** in the SSO, with `redirectUri`
matching `https://proxy.example.com/api/auth/oidc/callback`.
4. **LDAP**: point `app_ldap__url` at `ldaps://sso-manager:636`, create a
dedicated service account (`cn=ldapclient,ou=people,…`), and for the SSO's
self-signed LDAPS cert set `app_ldap__tlsOptions__rejectUnauthorized=false`
(or mount the cert and use `app_ldap__tlsOptions__ca=<path>`).
The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo
automates all four steps with `./setup.sh` — see
[theta-env docs](https://theta42.github.io/theta-env/).
## API tokens (personal access tokens)
Any logged-in user can mint a long-lived bearer token to call the management API
from scripts/CI without an OIDC browser session. Self-service; authenticates as
the creator (groups snapshotted at mint; authz layer unchanged).
Create one under **API Tokens** in the UI (shown once), then:
```bash
curl -H "Authorization: Bearer prx_<id>_<secret>" https://proxy.example.com/api/host
```
Rotate/revoke from the same page (immediate effect). Optional expiry at
creation. The token carries the creator's rights (admin → full mgmt API;
domain manager → those domains; `requireAdmin` routes 403). To tighten after
group changes, revoke + re-mint. Tokens persist in Redis (AOF) and survive
rebuilds.
## Bare metal
Prefer a systemd install? See the [Installation Guide](installation.html) for
the `ops/install.sh` automated installer on Debian/Ubuntu.
[← Back to Home](index.html)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

-78
View File
@@ -1,78 +0,0 @@
---
layout: default
title: Home
description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with automatic Let's Encrypt certs, OIDC login, and direct LDAP access control per host.
---
# Proxy
A reverse proxy and HTTPS termination service built on OpenResty/nginx, with a
management API and web GUI. It puts any of your apps behind single sign-on
(OIDC) and can also look users up directly in LDAP — so the same people who
log in to your SSO are the people allowed to reach your proxied apps.
Automatic HTTPS from Let's Encrypt (including wildcards), routing by hostname,
and per-host access control tied to your identity provider — managed from a
web UI or a REST API, with no downtime on config changes.
Part of the theta42 self-hosted identity stack, alongside
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
[theta-env](https://theta42.github.io/theta-env/) (the two composed with one
command).
## Screenshots
<a href="images/hosts.png" target="_blank"><img src="images/hosts.png" alt="Host list" width="49%"></a>
<a href="images/host-auth-sso.png" target="_blank"><img src="images/host-auth-sso.png" alt="Per-host SSO auth" width="49%"></a>
Basic auth and SSO are mutually exclusive per host, with per-user password
management once basic auth is enabled:
<a href="images/host-auth-basic.png" target="_blank"><img src="images/host-auth-basic.png" alt="Per-host basic auth" width="60%"></a>
*(click any screenshot to view full size)*
## Why this over the alternatives
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
auto-HTTPS. This one is built around identity: it is both an **OIDC client**
of an SSO provider (for browser login) **and** a direct **LDAP client** (for
user lookups and per-host access control), so access decisions come from your
real user directory, not a static allow-list or a separate auth proxy bolted
on top. The trade-off is that it expects an OIDC/LDAP identity source to point
at — it is not a standalone auth server. Pair it with
[SSO Manager](https://theta42.github.io/sso-manager-node/) (bundled OpenLDAP +
OIDC) for a self-hosted SSO + proxy stack, or point it at any OIDC provider +
LDAP directory you already run.
## Features
- 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 (`*`, `**`)
- **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)
- **Role-based access control** — global admins, local groups, and
per-domain permissions (viewer/manager)
- Self-service API tokens for scripting/CI without a browser session
- Web UI and a full REST API
## Get it
```bash
git clone https://github.com/theta42/proxy.git
cd proxy && docker compose up -d --build
```
That's the standalone quick start. For the full set of install options (Docker,
bare-metal, or as part of the combined SSO + proxy stack), configuration
reference, and API docs, see the
**[GitHub repository](https://github.com/theta42/proxy)**.
## Related projects
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC
provider + LDAP directory this proxy is designed to sit in front of.
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this proxy and
SSO Manager together with one command.
-266
View File
@@ -1,266 +0,0 @@
---
layout: default
title: Installation
description: Installing the proxy — Docker, bare metal, or as part of the unified theta-env stack.
---
# Installation Guide
[← Back to Home](index.html)
## Quick Install (Recommended)
For modern Debian-based systems (Ubuntu 20.04+, Debian 11+):
```bash
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
```
This automated installer will:
- Install Node.js 22.x
- Install OpenResty and required dependencies
- Install and configure Redis
- Set up SSL fallback certificates
- Install Lua dependencies
- Clone and install the proxy application
- Configure systemd service
- Start the proxy service
## Manual Installation
> **Recommended path:** `ops/install.sh` (above) is idempotent and safe to
> re-run — it symlinks the OpenResty/systemd config from the repo checkout,
> so future updates stay in sync automatically (`git pull` + re-run). The
> manual steps below *copy* those same files instead of symlinking them, so
> they will **not** auto-track later changes to `ops/nginx_conf/` or
> `ops/proxy.service` — you'd need to re-copy them by hand after every
> update. Prefer the manual path only if `install.sh` doesn't fit your
> distribution.
### System Requirements
- Modern Linux distribution (Ubuntu 20.04+, Debian 11+, or equivalent)
- Root access
- Inbound internet access for Let's Encrypt validation
- Minimum 1GB RAM, 10GB disk space
### Step 1: Install Dependencies
**Ubuntu/Debian:**
```bash
apt install libpam0g-dev build-essential redis-server luarocks -y
```
### Step 2: Install Node.js 22.x
```bash
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | \
sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=22
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | \
sudo tee /etc/apt/sources.list.d/nodesource.list
apt update && apt install nodejs -y
```
Verify installation:
```bash
node --version # Should show v22.x.x
npm --version
```
### Step 3: Install OpenResty
openresty.org ships distinct trees (and components) for Debian and Ubuntu. The
Debian tree is published only up to **bookworm** (there is no trixie block) and
uses the **`openresty`** component; Ubuntu uses the host codename and **`main`**.
So on a Debian 13 (trixie) host, point at the `bookworm` distribution (binary-
compatible, same OpenSSL 3 era).
```bash
. /etc/os-release
CODENAME="$(lsb_release -sc)"
case "$ID" in
debian)
OR_PATH=package/debian
OR_COMPONENT=openresty
# Debian tree only publishes up to bookworm; fall back to it for trixie+.
case "$CODENAME" in jessie|stretch|buster|bullseye|bookworm) OR_DISTRO="$CODENAME";; *) OR_DISTRO=bookworm;; esac
;;
*)
OR_PATH=package/ubuntu
OR_DISTRO="$CODENAME"
OR_COMPONENT=main
;;
esac
wget -O - https://openresty.org/package/pubkey.gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/openresty.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/${OR_PATH} ${OR_DISTRO} ${OR_COMPONENT}" | \
sudo tee /etc/apt/sources.list.d/openresty.list
```
> **Debian 13 (trixie):** apt's sequoia GPG backend rejects SHA-1 signatures by
> default, and the OpenResty signing key is still SHA-1, so `apt update` will
> refuse the repo. Extend the SHA-1 acceptance window before updating:
> ```bash
> sudo mkdir -p /etc/crypto-policies/back-ends
> sudo cp /usr/share/apt/default-sequoia.config /etc/crypto-policies/back-ends/apt-sequoia.config
> sudo sed -i 's/2026-02-01/2028-02-01/' /etc/crypto-policies/back-ends/apt-sequoia.config
> ```
> (The `default-sequoia.config` file only ships on Debian 13+, so this is a no-op
> on older releases. `ops/install.sh` applies this automatically.)
```bash
apt update && apt install openresty -y
```
### Step 4: Install Lua Dependencies
```bash
luarocks install lua-resty-auto-ssl
luarocks install luasocket
```
### Step 5: SSL Configuration
Create fallback SSL certificates:
```bash
mkdir -p /etc/ssl/
openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
-subj '/CN=sni-support-required-for-valid-ssl' \
-keyout /etc/ssl/resty-auto-ssl-fallback.key \
-out /etc/ssl/resty-auto-ssl-fallback.crt
```
### Step 6: Configure OpenResty
Clone the repository and copy configuration files:
```bash
cd /var/www
git clone https://github.com/theta42/proxy.git
cd proxy
# Copy nginx configs
mkdir -p /etc/openresty/sites-enabled/
cp ops/nginx_conf/nginx.conf /etc/openresty/nginx.conf
cp ops/nginx_conf/autossl.conf /etc/openresty/autossl.conf
cp ops/nginx_conf/proxy.conf /etc/openresty/sites-enabled/000-proxy
cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
```
### Step 7: Install Application
```bash
cd /var/www/proxy/nodejs
npm install
```
### Step 8: Configure Systemd Service
```bash
cp /var/www/proxy/ops/proxy.service /etc/systemd/system/proxy.service
systemctl daemon-reload
systemctl enable proxy.service
systemctl start proxy.service
```
Verify service is running:
```bash
systemctl status proxy.service
```
### Step 9: Initial Setup
The proxy API will be available on port 3000 by default. You'll need to:
1. Create your first user account
2. Configure DNS providers (for wildcard SSL)
3. Add your first host
See the [API Reference](api.html) for details.
## Configuration
### Environment Variables
- `NODE_ENV` - Set to `production` for production deployments
- `NODE_PORT` - Override default port (default: 3000)
### Redis Configuration
The proxy uses Redis with the prefix `proxy_`. To change this, edit `nodejs/conf/base.js`:
```javascript
redis: {
prefix: 'proxy_'
}
```
### OpenResty Configuration
Key configuration files in `/etc/openresty/`:
- `nginx.conf` - Main nginx configuration
- `autossl.conf` - Let's Encrypt HTTP-01 challenge handler
- `sites-enabled/000-proxy` - Proxy server configuration
### Unix Socket
The proxy communicates with OpenResty via Unix socket at:
```
/var/run/proxy_lookup.socket
```
This path is configurable in `nodejs/conf/base.js`.
## Troubleshooting
### Service won't start
Check logs:
```bash
journalctl -u proxy.service -f
```
Common issues:
- Port 3000 already in use
- Redis not running: `systemctl status redis-server`
- Permission issues: Service must run as root for user management
### SSL certificates not working
Check OpenResty logs:
```bash
tail -f /var/log/nginx/error.log
```
Common issues:
- Firewall blocking ports 80/443
- DNS not pointing to server
- Let's Encrypt rate limits exceeded
### Host lookup not working
Check Unix socket:
```bash
ls -la /var/run/proxy_lookup.socket
# Should show srwxrwxrwx (socket permissions)
```
Test lookup:
```bash
echo '{"domain":"example.com"}' | nc -U /var/run/proxy_lookup.socket
```
## Next Steps
- [Configure DNS Providers](api.html#dns-providers) for wildcard SSL
- [Add your first host](api.html#hosts)
- [Set up the web interface](index.html)
[← Back to Home](index.html)
-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/proxy/sitemap.xml
+5
View File
@@ -1,5 +1,10 @@
# API Documentation
> Looking for a plainer explanation of what API tokens are and when you'd
> want one, instead of a full endpoint reference? See
> [API Tokens](/docs/api-tokens) (in-app) or
> [concepts-api-tokens.md](../docs/concepts-api-tokens.md) (repo).
All API endpoints require authentication unless otherwise noted. Three
authentication methods are supported:
+16 -5
View File
@@ -69,6 +69,11 @@ app.use(express.json());
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
// Set as an app local so every res.render has it, including routes that don't
// spread the routers' `values` object.
app.locals.ui = require('./utils/ui');
// Per-host SSO endpoints. nginx routes /__proxy_auth/* on every proxied host to
// the app (see ops/nginx_conf/proxy.conf); these run the OIDC flow and set the
// per-host session cookie. Mounted before the page router.
@@ -101,14 +106,20 @@ 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);
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' });
}
}
});
+77 -64
View File
@@ -4,34 +4,91 @@
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('proxy-api:server');
var http = require('http');
const conf = require('@simpleworkjs/conf');
const debug = require('debug')('proxy-api:server');
const http = require('http');
/**
* Get port from environment and store in Express.
*/
// @simpleworkjs/conf loads ./config/proxy-secrets.js synchronously, then
// @simpleworkjs/bao-conf deep-merges secret/proxy/conf from OpenBao over it.
// The OIDC clientSecret is captured at require time inside models (via
// createOidcClient), and require('../app') transitively loads models, so the
// OpenBao fetch MUST resolve before require('../app'). Fail-soft: if OpenBao
// is unreachable, init() leaves conf as the file-loaded fallback and boot
// continues from ./config/proxy-secrets.js.
require('@simpleworkjs/bao-conf').init({ path: 'proxy', conf }).then(() => {
var app = require('../app'); // models + createOidcClient now see merged conf
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
app.set('port', port);
/**
* Get port from environment and store in Express.
*/
/**
* Create HTTP server.
*/
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
app.set('port', port);
var server = http.createServer(app);
/**
* Create HTTP server.
*/
var io = require('socket.io')(server);
app.io = io;
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
var io = require('socket.io')(server);
app.io = io;
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
console.log('Listening on ' + bind);
for(let listener of app.onListen){
listener()
}
}
}).catch(err => {
console.error('boot failed:', err);
process.exit(1);
});
/**
* Normalize a port into a number, string, or false.
@@ -52,47 +109,3 @@ function normalizePort(val) {
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
console.log('Listening on ' + bind);
for(let listener of app.onListen){
listener()
}
}
+10 -9
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
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.
@@ -49,7 +49,8 @@ module.exports = {
// Per-user overrides are Grant records managed in the app.
auth: {
// Members of these SSO/LDAP groups are always global admins.
adminGroups: [],
// app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
adminGroups: ['app_super_admin'],
// Optional default role mapping for groups, e.g.
// { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } }
// { 'proxy-viewers': { role: 'viewer', scope: 'global' } }
+3 -3
View File
@@ -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'
},
+1 -1
View File
@@ -1,6 +1,6 @@
'use strict';
const {Auth} = require('../models/auth');
const {Auth} = require('../models');
async function auth(req, res, next){
try{
-136
View File
@@ -1,136 +0,0 @@
'use strict';
const Table = require('../models');
const {User, AuthToken} = Table.models;
const {ApiToken} = require('./api_token');
/**
* Auth Model
*
* Handles authentication operations for the application.
* Manages user login, token validation, and logout processes.
*
* Dependencies:
* - User model: Validates user credentials
* - AuthToken model: Creates and manages authentication tokens
*
* All methods throw standardized login errors on failure to avoid
* leaking information about whether usernames exist or tokens are valid.
*/
class Auth{
/**
* Standardized error responses for authentication failures.
* Returns generic "Invalid Credentials" message for security.
*/
static errors = {
login: function(){
let error = new Error('LoginFailed');
error.name = 'LoginFailed';
error.message = `Invalid Credentials, login failed.`;
error.status = 401;
return error;
}
}
/**
* Authenticate user and create session token.
*
* @param {Object} data - Login credentials {username, password}
* @returns {Object} {user, token} - User object and auth token
* @throws {Error} Generic login error on any failure
*
* Flow:
* 1. Validate credentials via User.login()
* 2. Create new AuthToken for the user
* 3. Return both user data and token
*/
static async login(data){
try{
let user = await User.login(data);
// Backends may attach group membership to the user (LDAP); default
// to none for local/redis users.
let groups = Array.isArray(user.groups) ? user.groups : [];
let token = await AuthToken.create({username: user.username, groups});
return {user, token}
}catch(error){
console.log('login error', error);
throw this.errors.login();
}
}
/**
* Establish a session for an OIDC-authenticated identity: JIT-provision the
* local user (redis-backed) and mint an AuthToken carrying the SSO groups.
*
* @param {Object} identity - {username, groups} from utils/oidc claims
* @returns {Object} {user, token}
*/
static async oidcSession(identity){
let user = typeof User.upsertOidc === 'function'
? await User.upsertOidc(identity)
: await User.get(identity.username);
let token = await AuthToken.create({
username: user.username,
groups: identity.groups || [],
});
return {user, token};
}
/**
* Validate an authentication token.
*
* @param {string} token - Token string to validate
* @returns {Object} Token object if valid
* @throws {Error} Generic login error if token invalid or expired
*
* Checks:
* 1. Token exists in database
* 2. Token has not expired (via token.check())
*/
static async checkToken(token){
try{
token = await AuthToken.get(token);
if(token && token.check()) return token;
throw this.errors.login();
}catch(error){
console.log('check error', error);
throw this.errors.login();
}
}
/**
* Validate an `Authorization: Bearer prx_<id>_<secret>` API token.
*
* Returns the authenticated ApiToken record (with created_by + the
* mint-time groups snapshot); middleware/auth.js wraps it into the
* req.token shape the authz layer expects. Every failure collapses to the
* same generic login 401 — no leak of existence / wrong secret / expired.
*/
static async checkApiToken(raw){
try{
return await ApiToken.authenticate(raw);
}catch(error){
console.log('api-token check error', error);
throw this.errors.login();
}
}
/**
* Destroy an authentication token (logout).
*
* @param {string} data - Token string to destroy
* @returns {void}
*
* Removes token from database, invalidating the session.
*/
static async logout(data){
let token = await AuthToken.get(data);
await token.destroy();
}
}
module.exports = {Auth};
+73 -1
View File
@@ -3,6 +3,7 @@
const crypto = require("crypto");
const conf = require('@simpleworkjs/conf');
const baoConf = require('@simpleworkjs/bao-conf');
const Table = require('.');
const ModelPs = require('../utils/model_pubsub');
@@ -139,11 +140,26 @@ class DnsProvider extends Table{
let __intraModel = this.__intraModel(data.dnsProvider);
Provider = __intraModel.Provider;
if (!data.id) data.id = crypto.randomBytes(8).toString("hex");
let secrets = {};
for (let key in Provider._keyMap) {
if (Provider._keyMap[key].isPrivate && data[key] !== undefined) {
secrets[key] = data[key];
}
}
// This is here test if the given API key is valid
let provider = new __intraModel.Provider(data, ...args);
let domains = await provider.listDomains();
for (let key in secrets) data[key] = '********';
let instance = await super.create.call(__intraModel, data, ...args);
if (Object.keys(secrets).length > 0) {
await baoConf.set(`proxy/dns-providers/${instance.id}`, secrets);
}
try{
await instance.updateDomains(domains);
}catch(updateError){
@@ -189,7 +205,63 @@ class DnsProvider extends Table{
let instance = await super.get(data, ...args);
let __intraModel = this.__intraModel(instance.dnsProvider);
return await super.get.call(__intraModel, data, ...args);
let resolved = await super.get.call(__intraModel, data, ...args);
try {
let secrets = await baoConf.get(`proxy/dns-providers/${resolved.id}`);
if (secrets) Object.assign(resolved, secrets);
} catch(e) {}
return resolved;
}
static async findall(...args){
let instances = await super.findall(...args);
for (let inst of instances) {
try {
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
if (secrets) Object.assign(inst, secrets);
} catch(e) {}
}
return instances;
}
static async find(...args){
let instances = await super.find(...args);
for (let inst of instances) {
try {
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
if (secrets) Object.assign(inst, secrets);
} catch(e) {}
}
return instances;
}
async update(data){
let Provider = this.constructor.Provider || providers[this.dnsProvider];
let secrets = {};
if (Provider) {
for (let key in Provider._keyMap) {
if (Provider._keyMap[key].isPrivate && data[key] !== undefined && data[key] !== '********') {
secrets[key] = data[key];
data[key] = '********';
} else if (Provider._keyMap[key].isPrivate && data[key] === '********') {
delete data[key]; // Do not update the masked value if it's sent back
}
}
}
let res = await super.update(data);
if (Object.keys(secrets).length > 0) {
let existing = await baoConf.get(`proxy/dns-providers/${this.id}`) || {};
await baoConf.set(`proxy/dns-providers/${this.id}`, { ...existing, ...secrets });
Object.assign(this, secrets);
}
return res;
}
async remove(...args){
await baoConf.request('DELETE', `proxy/dns-providers/${this.id}`).catch(()=>{});
return await super.remove(...args);
}
static listProviders(){
+41 -13
View File
@@ -9,10 +9,23 @@ const tldExtract = require('tld-extract').parse_host;
const LetsEncrypt = require('../utils/letsencrypt');
const conf = require('@simpleworkjs/conf');
const fs = require('fs');
const path = require('path');
// Defaults to the same persisted volume Redis uses (/data, see
// docker-entrypoint.sh's REDIS_DATA_DIR) instead of the old CWD-relative
// default (./le_key.cert -> /app/le_key.cert), which lives in the
// container's writable layer and was lost on every rebuild. Falls back to
// the old relative path when /data isn't present (e.g. local dev outside
// docker), so it stays writable there too.
const dataDir = process.env.REDIS_DATA_DIR || '/data';
const accountKeyPath = fs.existsSync(dataDir) ? path.join(dataDir, 'le_key.cert') : './le_key.cert';
const letsEncrypt = new LetsEncrypt({
directoryUrl: conf.environment === "production" ?
LetsEncrypt.AcmeClient.directory.letsencrypt.production :
LetsEncrypt.AcmeClient.directory.letsencrypt.staging,
accountKeyPath,
});
class Host extends Table{
@@ -28,6 +41,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 +532,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(){
+23 -4
View File
@@ -1,18 +1,37 @@
'use strict';
const conf = require('@simpleworkjs/conf');
const {setUpTable} = require('model-redis');
const {createOidcClient, bootstrapLocalAdmin} = require('@simpleworkjs/oidc-client');
const Table = setUpTable(conf.redis);
module.exports = Table;
// App-local models. User + ApiToken register before the OIDC client factory
// below: Auth binds User, and checkApiToken wraps ApiToken.authenticate.
require('./user'); // User (redis-backed local + OIDC JIT)
const {ApiToken} = require('./api_token'); // ApiToken (Bearer PATs)
require('./dns_provider');
require('./dynamic_record');
require('./host');
require('./token');
require('./user');
require('./local_group');
require('./permission');
require('./oidc_state');
require('./sso_session');
require('./api_token');
// Shared OIDC client (authorization-code + PKCE): session models (Token,
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
// /oidc/callback router — all created on this app's Table/redis. PAT validation
// is wired in (proxy accepts Bearer prx_<id>_<secret>); the package collapses
// every checkApiToken failure to a generic 401.
const oidcClient = createOidcClient({
Table,
checkApiToken: (raw) => ApiToken.authenticate(raw),
});
module.exports.Token = oidcClient.Token;
module.exports.AuthToken = oidcClient.AuthToken;
module.exports.OidcState = oidcClient.OidcState;
module.exports.Auth = oidcClient.Auth;
module.exports.authRouter = oidcClient.router;
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
bootstrapLocalAdmin(Table.models.User, {defaultName: 'proxyadmin2'});
-31
View File
@@ -1,31 +0,0 @@
'use strict';
const Table = require('.');
/**
* OidcState
*
* Short-lived store for an in-flight OpenID Connect authorization request.
* Keyed by the random `state` value; holds the PKCE `code_verifier` and the
* post-login redirect target until the SSO calls us back.
*
* The record auto-expires via model-redis per-key TTL (static _ttl), so an
* abandoned login attempt leaves nothing behind and there is no cleanup job.
*/
class OidcState extends Table{
static _key = 'state';
// Auth round-trips are quick; 5 minutes is plenty and bounds replay.
static _ttl = 300;
static _keyMap = {
'created_on': {default: function(){return (new Date).getTime()}},
'state': {isRequired: true, type: 'string', min: 8, max: 500},
'codeVerifier': {isRequired: true, type: 'string', min: 8, max: 500},
'redirect': {default: '/', isRequired: false, type: 'string'},
}
}
OidcState.register();
module.exports = {OidcState};
-64
View File
@@ -1,64 +0,0 @@
'use strict';
const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
class Token extends Table{
static _key = 'token';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
'is_valid': {default: true, type: 'boolean'},
}
constructor(...args){
super(...args);
}
async check(){
try{
return this.is_valid;
}catch(error){
return false
}
}
}
Token.register();
class AuthToken extends Token{
static _keyMap = {
...super._keyMap,
user: {model: 'User', rel: 'one', localKey: 'created_by'},
// Group memberships captured at login (OIDC `groups` claim or LDAP
// group membership), stored as a JSON string. Drives authorization for
// the life of the session without re-querying the IdP on every request.
groups: {default: '[]', isRequired: false, type: 'string'},
}
static async create(data){
data.created_by = data.username;
if(Array.isArray(data.groups)){
data.groups = JSON.stringify(data.groups);
}
return super.create(data)
}
// Parse the stored groups JSON back into an array, tolerating bad/missing
// data so authorization never crashes on a malformed token.
groupsArray(){
try{
let parsed = JSON.parse(this.groups);
return Array.isArray(parsed) ? parsed : [];
}catch(error){
return [];
}
}
}
AuthToken.register();
module.exports = {Token, AuthToken};
+4 -2
View File
@@ -1,8 +1,8 @@
'use strict';
const { Client, Attribute, Change } = require('ldapts');
const {Token} = require('./token');
const conf = require('@simpleworkjs/conf').ldap;
const { escapeFilter } = require('@simpleworkjs/ldap');
// tlsOptions is optional and forwarded to ldapts so the proxy can bind to
// ldaps:// with a self-signed or internal-CA cert. Set via conf/secrets.js or
@@ -107,7 +107,9 @@ User.get = async function(data){
await client.bind(conf.bindDN, conf.bindPassword);
let filter = `(&${conf.userFilter}(${conf.userNameAttribute}=${data.username}))`;
// Escape the interpolated username (RFC 4515) — previously raw, which
// let `*`/`(`/`)`/`\`/NUL in a username break or broaden the filter.
let filter = `(&${conf.userFilter}(${conf.userNameAttribute}=${escapeFilter(data.username)}))`;
const res = await client.search(conf.searchBase, {
scope: 'sub',
-1
View File
@@ -2,7 +2,6 @@
const linuxUser = require('linux-sys-user').promise();
const objValidate = require('../utils/object_validate');
const {Token} = require('./token');
const {promisify} = require('util');
const pam = require('authenticate-pam');
const authenticate = promisify(pam.authenticate);
+3 -24
View File
@@ -3,7 +3,6 @@
const Table = require('.');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const saltRounds = 10;
class User extends Table{
@@ -86,26 +85,6 @@ class User extends Table{
User.register();
(async function(){
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;
try{
let user = await User.get(defaultUser);
}catch(error){
try{
let user = await User.create({
username:defaultUser,
password: defaultPass,
created_by: defaultUser
});
console.log(defaultUser, 'created', user);
}catch(error){
console.error(error)
}
}
})();
// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
// (bootstrapLocalAdmin); invoked once from models/index.js after User is
// registered. See the package lib/bootstrap.js for the original logic.
+156 -33
View File
@@ -1,17 +1,22 @@
{
"name": "proxy-api",
"version": "1.1.9",
"version": "1.13.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxy-api",
"version": "1.1.9",
"version": "1.13.3",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.1.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
@@ -21,18 +26,19 @@
"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",
"marked": "^9.1.6",
"model-redis": "^1.5.0",
"model-redis": "^1.6.0",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"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"
@@ -280,10 +286,34 @@
"@redis/client": "^6.1.0"
}
},
"node_modules/@simpleworkjs/app-stack": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
"license": "MIT",
"dependencies": {
"express": "^5.2.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/bao-conf": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/conf": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.1.0.tgz",
"integrity": "sha512-MKRQQ4JAH2tbEm87NdkmfikTT58Tyk/SFbvCC7zKja0bK6j8zYyBXTQUJ0rnvFOVEalDWd/au4AEiptOCEqgvA==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
"integrity": "sha512-X4u1oRb0A0x7wzmyiIH5hPYYIFJYUXhYVe9CPX6G6INouRIeZuHlx0pthHlihiAAIc3+KqZBx18qirFN8RoJwA==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
@@ -292,6 +322,41 @@
"node": ">=16.0.0"
}
},
"node_modules/@simpleworkjs/frontend": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/ldap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
"license": "MIT",
"dependencies": {
"ldapts": "^8.1.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/oidc-client": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/oidc-client/-/oidc-client-1.0.0.tgz",
"integrity": "sha512-AzxIaE32p4yKDlp0mWvZp1wmXi8tMFckcPwMiQyZrDgEC0IybGhbjppPa+vNGx1AoVLp64vRL/zR3yXb/19NPg==",
"license": "MIT",
"dependencies": {
"@simpleworkjs/conf": "^1.2.0",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -459,20 +524,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -482,6 +547,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -502,16 +580,16 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/braces": {
@@ -611,6 +689,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 +806,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",
@@ -1375,9 +1465,9 @@
"license": "MIT"
},
"node_modules/jq-repeat": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.1.0.tgz",
"integrity": "sha512-e1OmSWeBEHEtyOhNVysx0bnT5wd6HlZ37JZgPcGPmACJ0K9bXDPq0xOwrM1slQMSTw7FOSNDX+MD6VwvPeeZyQ==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
"integrity": "sha512-OdKAQJ8SOTZzoNL/76o5+WJehXnMCoP8aXbDtZCmDh3vuGGdXfN14FkPTqLpZC5xmlv+QVfTXu/UaIRsDjVuhA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -1495,9 +1585,9 @@
}
},
"node_modules/model-redis": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.5.0.tgz",
"integrity": "sha512-eVXQQN+k3cR5aJBvPQurgr8WXYpAvVoLu6ydMWenSOJZBDVcHqeNqUCp8n5lYrxv6iZ8PlF0WQzZFmbJ1zP6/A==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.6.0.tgz",
"integrity": "sha512-QinykZ0H9vhyj0qY9NnNd1m1YcBXBXuc0viENJuEyTwplhOWT0cpjpGxObqoD1ysb8AJkrBkJB9tAf4EZbISWA==",
"license": "MIT",
"dependencies": {
"redis": "^6.1.0"
@@ -2181,17 +2271,34 @@
"license": "0BSD"
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/undefsafe": {
@@ -2251,6 +2358,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"
}
}
}
}
+15 -10
View File
@@ -1,7 +1,6 @@
{
"name": "proxy-api",
"version": "1.1.9",
"private": true,
"version": "1.13.3",
"author": [
{
"name": "William Mantly",
@@ -11,10 +10,10 @@
"scripts": {
"start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www",
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js",
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/unit/no_native_dialogs.test.js test/integration/dns_provider.test.js",
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/unit/no_native_dialogs.test.js",
"test:integration": "node --test test/integration/dns_provider.test.js",
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/host_sso.test.js test/unit/unix_socket.test.js test/unit/no_native_dialogs.test.js test/integration/dns_provider.test.js"
},
"engines": {
"node": ">=18.0.0"
@@ -22,7 +21,12 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.1.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
@@ -32,23 +36,24 @@
"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",
"marked": "^9.1.6",
"model-redis": "^1.5.0",
"model-redis": "^1.6.0",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"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"
+10
View File
@@ -7,6 +7,12 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
/* Height of the fixed navbar (plus the update banner, while shown --
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
sticky element offset itself below both fixed elements via
`top: var(--sw-content-offset)` instead of colliding with them at the
viewport's true top:0. */
--sw-content-offset: 4.5rem;
}
#spa-shell {
@@ -18,3 +24,7 @@ body {
.card-title{
font-weight: bold;
}
.group-required{
display: none;
}
+69
View File
@@ -91,3 +91,72 @@ app.apiToken = (function(app){
return {list, get, add, update, remove, rotate};
})(app);
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
// incoming host may be a wildcard ("*.example.com"); the target may not.
// Proxy-specific, so it's registered here (via @simpleworkjs/frontend's
// $.validateSettings) rather than in the shared package's generic rule set.
(function(){
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
// Either one bare label (Docker service names, /etc/hosts entries) or a
// dotted hostname with an alphabetic TLD.
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
var FORBIDDEN = /[\s/:]/;
function isIPv4( value ) {
var parts = value.split( '.' );
if ( parts.length !== 4 ) return false;
return parts.every( function( p ) {
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
});
}
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
// (any number of fragments, incl. a bare "**" global catch-all).
function isHostPattern( value ) {
if ( value.length > 253 ) return false;
return value.split( '.' ).every( function( l ) {
return l === '*' || l === '**' || LABEL.test( l );
});
}
function forbidden( value ) {
return FORBIDDEN.test( value ) || value.includes( '://' );
}
// Incoming host: IPv4 or a wildcard host pattern.
function checkHost( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || isHostPattern( value ) ) return;
return "Enter a valid host or wildcard (*, **)";
}
// Downstream target: IPv4 or a strict hostname, no wildcard.
function checkTarget( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
return "Enter a valid hostname or IP";
}
$.validateSettings({
rule:{
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
host: function( value ) {
return checkHost( value );
},
// Downstream target — hostname or IPv4, no wildcard.
target: function( value ) {
return checkTarget( value );
},
// Back-compat alias (no wildcard).
hostname: function( value ) {
return checkTarget( value );
},
}
});
})();
+280 -105
View File
@@ -1,3 +1,12 @@
// Shared client framework for the theta42 apps.
//
// This file is byte-identical across sso-manager-node, proxy and jump-host —
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
// the /api/user/me response), never from edits to this file. Edit all three
// copies together.
//
// jQuery 4 safe: no $.isFunction, no $.holdReady.
var app = {};
app.pubsub = (function(){
@@ -75,10 +84,26 @@ app.socket = (function(app){
app.api = (function(app){
var baseURL = '/api/'
function post(url, data, callback){
if(typeof callback !== 'function') callback = callback2;
// post/put/delete are dual-mode: pass a callback for the node-style
// (error, data, status) form, or omit it to get a Promise that resolves
// with the parsed body and rejects with the error body. get/options return
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
function body(method, url, data, callback){
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: method,
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({
type: 'POST',
type: method,
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
@@ -87,40 +112,44 @@ app.api = (function(app){
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback ? callback(
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
);
}
});
}
function post(url, data, callback){
return body('POST', url, data, callback);
}
function put(url, data, callback){
if(typeof callback !== 'function') callback = callback2;
return $.ajax({
type: 'PUT',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
},
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback ? callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
}
});
return body('PUT', url, data, callback);
}
function remove(url, callback, callback2){
if(typeof callback !== 'function') callback = callback2;
// Called both as (url, callback) and — from formAJAX, which always passes
// the serialized form as the second argument — as (url, data, callback).
// No request body is sent either way.
function remove(url, data, callback){
if(typeof data === 'function'){
callback = data;
data = undefined;
}
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({
type: 'DELETE',
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({
type: 'delete',
type: 'DELETE',
url: baseURL+url,
headers:{
'auth-token': app.auth.getToken()
@@ -128,11 +157,11 @@ app.api = (function(app){
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback ? callback(
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
) : function(){}
);
}
});
}
@@ -179,7 +208,11 @@ app.api = (function(app){
})(app)
app.auth = (function(app){
var user = {}
// One in-flight/cached GET /api/user/me per page load. Every gating
// decision (nav items, per-view forceLogin, group-required elements) reads
// this same promise instead of re-fetching.
var userPromise = null;
function setToken(token){
localStorage.setItem('APIToken', token);
}
@@ -188,18 +221,95 @@ app.auth = (function(app){
return localStorage.getItem('APIToken');
}
function isLoggedIn(callback){
if(getToken()){
return app.api.get('user/me', function(error, data){
// data now carries effective rights (isAdmin, global, domains).
if(!error) app.auth.user = app.auth.perms = data;
return callback(error, data);
});
}else{
callback(null, false);
async function getUser(){
try{
return await app.api.get('user/me');
}catch(error){
if(error && error.status === 401) return null;
throw error;
}
}
// Cached current user, or false when there's no token at all. Callers that
// need a fresh copy (after a login or a profile change) pass force.
function loadUser(force){
if(force || !userPromise){
userPromise = getToken() ? getUser() : Promise.resolve(null);
userPromise = userPromise.then(function(user){
app.auth.user = app.auth.perms = user || null;
return user;
});
}
return userPromise;
}
// The apps report group membership two ways: sso-manager-node returns LDAP
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
// is exposed as a synthetic `admin` group so one gating model covers both.
function groupCNs(user){
var raw = (user && (user.memberOf || user.groups)) || [];
if(!Array.isArray(raw)) raw = [raw];
var names = raw.map(function(group){
return String(group).split(',')[0].replace(/^cn=/i, '');
});
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
return names;
}
async function memberOf(groupNameToFind, user){
user = user || await loadUser();
if(!user) return false;
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
return groupCNs(user).some(function(group){
return groupNameToFind.includes(group);
});
}
// True when the logged-in user is a global admin (per user/me). Sync — only
// meaningful once isLoggedIn/forceLogin has resolved.
function isAdmin(){
return !!(app.auth.perms && app.auth.perms.isAdmin);
}
// Dual-mode: returns a Promise resolving to the user (or false), and calls
// an optional node-style callback with the same result.
function isLoggedIn(callback){
var promise = loadUser().then(function(user){
return user || false;
});
if(typeof callback === 'function'){
promise.then(function(user){
callback(null, user);
}, function(error){
callback(error, false);
});
}
return promise;
}
function logIn(args, callback){
app.api.post('auth/login', args, function(error, data){
if(data.login){
setToken(data.token);
}
loadUser(true);
callback(error, !!data.token);
});
}
// Clears the session only — the caller decides where to go next (the nav's
// Log Out button uses ui.logoutRedirect).
function logOut(callback){
localStorage.removeItem('APIToken');
userPromise = null;
app.auth.user = app.auth.perms = null;
if(typeof callback === 'function') callback();
}
// Constrain a redirect target to a same-origin absolute path. Rejects
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
@@ -230,46 +340,68 @@ app.auth = (function(app){
return true;
}
// True when the logged-in user is a global admin (per user/me).
function isAdmin(){
return !!(app.auth.perms && app.auth.perms.isAdmin);
}
function logIn(args, callback){
app.api.post('auth/login', args, function(error, data){
if(data.login){
setToken(data.token);
}
callback(error, !!data.token);
});
}
function logOut(callback){
localStorage.removeItem('APIToken');
callback();
}
function forceLogin(){
// jQuery 4 removed $.holdReady; rely on the redirect below to keep an
// unauthenticated user off the page instead of pausing document ready.
app.auth.isLoggedIn(function(error, isLoggedIn){
if(error || !isLoggedIn){
app.auth.logOut(function(){})
location.replace(`/login${location.href.replace(location.origin, '')}`);
}
});
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
// unauthorised user is kept off the page by a redirect / an error panel
// rather than by pausing document ready.
//
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
// `admin` group covers the OIDC clients' isAdmin flag.
async function forceLogin(requiredGroups){
var user = await loadUser();
if(!user){
logOut(function(){});
location.replace('/login?redirect=' + encodeURIComponent(
location.pathname + location.search
));
return false;
}
if(user.onboardingRequired && location.pathname !== '/onboarding'){
location.replace('/onboarding');
return false;
}
if(requiredGroups && !await memberOf(requiredGroups, user)){
app.messages.action(
`<h1>
<i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b>
<i class="fa-solid fa-triangle-exclamation"></i>
</h1>`,
$('#spa-shell'),
'danger',
);
throw new Error("User does not have permission");
}
return user;
}
// Where to go after a successful login: the ?redirect= query param, or the
// legacy /login/<path> suffix form, constrained to a same-origin path. The
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
// is how the OIDC provider sends an unauthenticated user through login.
function logInRedirect(){
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/')
var params = new URLSearchParams(location.search);
var target = params.get('redirect')
|| location.href.replace(location.origin + '/login', '')
|| '/';
window.location.href = safeInternalPath(target);
}
return {
getToken: getToken,
setToken: setToken,
isLoggedIn: isLoggedIn,
consumeTokenFragment: consumeTokenFragment,
getUser: getUser,
loadUser: loadUser,
groupCNs: groupCNs,
memberOf: memberOf,
isAdmin: isAdmin,
isLoggedIn: isLoggedIn,
safeInternalPath: safeInternalPath,
consumeTokenFragment: consumeTokenFragment,
user: null,
perms: null,
logIn: logIn,
logOut: logOut,
@@ -279,6 +411,11 @@ app.auth = (function(app){
})(app);
// Back-compat alias for views that awaited the cached user directly.
Object.defineProperty(app.auth, 'asyncUser', {
get: function(){ return app.auth.loadUser(); },
});
app.user = (function(app){
function list(callback){
app.api.get('user/?detail=true', function(error, data){
@@ -308,6 +445,8 @@ app.user = (function(app){
})(app);
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
// these endpoints; the calls are inert elsewhere.
app.permission = (function(app){
function list(callback){
app.api.get('permission/', function(error, data){
@@ -381,29 +520,15 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
};
function actionMessage(message, $target, type, callback){
message = message || '';
$target = $target.closest('div.card').find('.actionMessage');
type = type || 'info';
callback = callback || function(){};
if($target.html() === message) return;
if($target.html()){
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function(index, className){
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('bg-' + type);
message = '<span class="align-middle">' + message + '</span><button class="action-close btn btn-sm btn-outline-dark float-end"><i class="fa-solid fa-xmark"></i></button>'
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
// app.util.escapeHtml and app.messages.action/confirm.
function escapeHtml(s){
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
$.fn.serializeObject = function() {
@@ -459,14 +584,65 @@ app.util = (function(app){
document.body.removeChild(element);
}
// Scroll a just-added/-edited element into view and flash its
// background, so the user's eye lands on the row that changed instead of
// it silently appearing/updating somewhere off-screen. Takes a jQuery
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
function revealItem(el){
var node = el && el.jquery ? el[0] : el;
if (!node) return;
if (typeof node.scrollIntoView === 'function') {
node.scrollIntoView({behavior: 'smooth', block: 'center'});
}
var prevTransition = node.style.transition;
var prevBg = node.style.backgroundColor;
node.style.transition = 'background-color 1.5s ease';
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
setTimeout(function(){
node.style.backgroundColor = prevBg;
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
}, 300);
}
return {
downloadFile: downloadFile,
getUrlParameter: getUrlParameter,
actionMessage: actionMessage
escapeHtml: escapeHtml,
revealItem: revealItem,
}
})(app);
$( document ).ready(function(){
// Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a
// user who is in no groups — or who isn't logged in — simply never sees them.
app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user);
if(!groups.length) return;
var style = document.getElementById('group-required-rules');
if(!style){
style = document.createElement('style');
style.id = 'group-required-rules';
document.head.appendChild(style);
}
for(var group of groups){
try{
style.sheet.insertRule(
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
style.sheet.cssRules.length
);
}catch(error){
// A group whose CN isn't a usable CSS identifier just gates nothing.
}
}
};
$( document ).ready(async function(){
// Show content the user's groups entitle them to.
app.auth.applyGroupVisibility(await app.auth.loadUser());
$('div.row').fadeIn('slow'); //show the page
//panel button's
@@ -487,9 +663,9 @@ $( document ).ready(function(){
$(this).closest('.card').slideUp('fast');
});
$('.actionMessage').on('click', 'button.action-close', function(event){
app.util.actionMessage(null, $(this));
});
// action-close click handling is wired by @simpleworkjs/frontend's
// app.messages.js (delegated on document, so it also covers messages
// rendered after this ready handler runs).
setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{
@@ -520,18 +696,17 @@ function formAJAX(btn){
var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger');
app.messages.action('Please fix the form errors.', $form, 'danger')
return false;
}
app.util.actionMessage(
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>',
$form,
'info'
);
// Plain text: app.messages.action HTML-escapes its message (by design,
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
// render literally instead of as an element.
app.messages.action('Saving…', $form, 'info');
app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
$form.validateClear();
if(!error){
$form.trigger("reset");
@@ -539,7 +714,7 @@ function formAJAX(btn){
}else{
console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
}
if(data && data.keys){
console.log('form key errors', data.keys)
-201
View File
@@ -1,201 +0,0 @@
( function( $ ) {
var settings = {
rule: {
eq: function(value, options){
var compare = $('[name=' + options + ']').val();
if ( value != compare ) {
return "Miss-match";
}
}
},
};
$.fn.validate = function(event) {
// let thisSettings = $.extend(true, settings, settingsObj);
let hasErrors = false;
if(this.is('[validate]')) return this.validateField(event);
if(!this.attr('isValid')){
console.log('adding reset event')
this.on('reset', function(){
$(this).attr('isValid', false);
$(this).validateClear();
})
}
this.find('[validate]').each(function(){
if(!$(this).validateField()) hasErrors = true;
});
this.attr('isValid', !hasErrors);
if(hasErrors && event) event.preventDefault();
return !hasErrors;
};
$.fn.validateClear = function(){
$(this).find('input').each(function(){
$(this).removeClass('is-invalid');
$(this).removeClass('is-valid');
})
}
$.fn.validateField = function(){
var attr = this.attr('validate').split(':'); //array of params
var rule = attr[0];
var options = attr[1];
var value = this.val(); //link to input value
var message;
if(this.prop('disabled')) return true;
//checks if field is required, and length
if(!isNaN(options) && value.length < options){
message = `Must be ${options} characters`;
}
//checks if empty to stop processing
if(!isNaN(options) && value.length === 0) {
}else if(rule in settings.rule){
message = settings.rule[rule].apply(this, [value, options]);
}
this.validateMessage(message)
return !message;
}
$.fn.validateMessage = function(message){
if(message && message !== true){
this.closest('.form-group').find('b.invalid-feedback').html(message);
this.addClass('is-invalid');
}else{
this.removeClass('is-invalid');
this.addClass('is-valid');
}
return this;
};
jQuery.extend({
validateSettings: function( settingsObj ) {
$.extend( true, settings, settingsObj );
},
validateInit: function( ettingsObj ) {
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
$( this ).validate( settingsObj, event );
});
}
});
}( jQuery ));
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
// incoming host may be a wildcard ("*.example.com"); the target may not.
(function(){
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
// Either one bare label (Docker service names, /etc/hosts entries) or a
// dotted hostname with an alphabetic TLD.
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
var FORBIDDEN = /[\s/:]/;
function isIPv4( value ) {
var parts = value.split( '.' );
if ( parts.length !== 4 ) return false;
return parts.every( function( p ) {
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
});
}
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
// (any number of fragments, incl. a bare "**" global catch-all).
function isHostPattern( value ) {
if ( value.length > 253 ) return false;
return value.split( '.' ).every( function( l ) {
return l === '*' || l === '**' || LABEL.test( l );
});
}
function forbidden( value ) {
return FORBIDDEN.test( value ) || value.includes( '://' );
}
// Incoming host: IPv4 or a wildcard host pattern.
function checkHost( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || isHostPattern( value ) ) return;
return "Enter a valid host or wildcard (*, **)";
}
// Downstream target: IPv4 or a strict hostname, no wildcard.
function checkTarget( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
return "Enter a valid hostname or IP";
}
$.validateSettings({
rule:{
ip: function( value ) {
value = value.split( '.' );
if ( value.length != 4 ) {
return "Malformed IP";
}
$.each( value, function( key, value ) {
if( value > 255 || value < 0 ) {
return "Malformed IP";
}
});
},
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
host: function( value ) {
return checkHost( value );
},
// Downstream target — hostname or IPv4, no wildcard.
target: function( value ) {
return checkTarget( value );
},
// Back-compat alias (no wildcard).
hostname: function( value ) {
return checkTarget( value );
},
user: function( value ) {
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
// or at least 3 of {lowercase, uppercase, number, symbol}.
password: function( value ) {
if ( typeof value !== 'string' || value.length < 8 ) {
return "Password must be at least 8 characters";
}
if ( value.length >= 12 ) return;
var classes = 0;
if ( /[a-z]/.test( value ) ) classes++;
if ( /[A-Z]/.test( value ) ) classes++;
if ( /[0-9]/.test( value ) ) classes++;
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
if ( classes < 3 ) {
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
}
}
}
});
})();
+1 -1
View File
@@ -6,7 +6,7 @@ const middleware = require('../middleware/auth');
const authz = require('../middleware/authz');
// API routes for authentication.
router.use('/auth', require('./auth'));
router.use('/auth', require('../models').authRouter);
// API routes for working with users. All endpoints need to be have valid user.
// User management is admin-only; the router allows self-service exceptions
-111
View File
@@ -1,111 +0,0 @@
'use strict';
const router = require('express').Router();
const { rateLimit } = require('express-rate-limit');
const conf = require('@simpleworkjs/conf');
const { Auth } = require('../models/auth');
const { OidcState } = require('../models/oidc_state');
const oidc = require('../utils/oidc');
const { safeInternalPath } = require('../utils/safe_redirect');
// Throttle unauthenticated auth endpoints (credential login + the OIDC
// handshake) to blunt brute-force / callback abuse. Keyed per IP.
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 60, // 60 attempts per IP per window
standardHeaders: true,
legacyHeaders: false,
message: {name: 'TooManyRequests', message: 'Too many attempts, please try again later.'},
});
router.post('/login', authLimiter, async function(req, res, next){
try{
let auth = await Auth.login(req.body);
return res.json({
login: true,
token: auth.token.token,
message:`${req.body.username} logged in!`,
});
}catch(error){
next(error);
}
});
router.all('/logout', async function(req, res, next){
try{
if(req.user){
await req.user.logout();
}
res.json({message: 'Bye'})
}catch(error){
next(error);
}
});
/**
* OIDC login start: create a PKCE + state challenge, persist it (auto-expiring
* via OidcState TTL), and redirect the browser to the SSO authorize endpoint.
*/
router.get('/oidc/start', authLimiter, async function(req, res, next){
try{
if(!conf.oidc || !conf.oidc.enabled){
let error = new Error('OidcDisabled');
error.status = 404;
error.message = 'OIDC login is not enabled.';
throw error;
}
let {state, codeVerifier, codeChallenge} = oidc.createAuthRequest();
await OidcState.create({
state,
codeVerifier,
// Sanitize now so a hostile ?redirect= can't be stored and later
// reflected into the login page's navigation.
redirect: safeInternalPath(req.query.redirect || '/'),
});
return res.redirect(oidc.buildAuthUrl(state, codeChallenge));
}catch(error){
next(error);
}
});
/**
* OIDC callback: validate state (consuming the one-time record), exchange the
* code for tokens, read identity from userinfo, establish a session, and hand
* the app token back to the browser via a URL fragment for the login page to
* store in localStorage.
*/
router.get('/oidc/callback', authLimiter, async function(req, res, next){
try{
let {code, state} = req.query;
if(!code || !state){
let error = new Error('OidcCallbackInvalid');
error.status = 400;
error.message = 'Missing code or state.';
throw error;
}
// get() throws if the state is unknown or has expired — this both binds
// the callback to our request and bounds replay.
let saved = await OidcState.get(state);
await saved.remove();
let tokens = await oidc.exchangeCode(code, saved.codeVerifier);
let claims = await oidc.fetchUserInfo(tokens.access_token);
let identity = oidc.claimsToIdentity(claims);
let {token} = await Auth.oidcSession(identity);
let redirect = safeInternalPath(saved.redirect || '/');
return res.redirect(
`/login#token=${encodeURIComponent(token.token)}&redirect=${encodeURIComponent(redirect)}`
);
}catch(error){
next(error);
}
});
module.exports = router;
+72 -2
View File
@@ -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');
@@ -33,6 +34,15 @@ const values = {
// An explicit slug -> file allowlist, never a user-suppliable path, so
// there's no way to make this read outside the doc set below.
const DOCS = {
// Plain-language "what is this and why would I use it" guides -- linked
// directly from the relevant card in the UI (see the help icon on each
// card). Each links onward to the deeper technical doc below for readers
// who want the system-design/protocol-level detail.
hosts: {title: 'Hosts & HTTPS', file: path.join(__dirname, '../../docs/concepts-hosts.md')},
dns: {title: 'DNS Providers', file: path.join(__dirname, '../../docs/concepts-dns.md')},
access: {title: 'Users, Groups & Permissions', file: path.join(__dirname, '../../docs/concepts-access.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
@@ -54,24 +64,84 @@ function fixImagePaths(html) {
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
}
// docs/*.md files (not the repo-root README/CHANGELOG/api.md) carry Jekyll
// front matter for the GitHub Pages build and a "← Back to Home" link back
// to that site's index -- both meaningless here (this viewer has its own
// doc-list sidebar, docs_page.ejs) and, worse, marked() doesn't know front
// matter isn't regular markdown: it rendered as a garbled heading + stray
// <hr> at the top of every page. Strip both before rendering.
function stripJekyllCruft(content) {
return content
.replace(/^---\n[\s\S]*?\n---\n/, '')
.replace(/^\s*\[← Back to Home\]\([^)]*\)\s*\n/m, '');
}
// Docs cross-link each other as "<slug>.html" (correct for the Jekyll/GitHub
// Pages build, which is what these same .md files also feed) and
// "index.html" for the docs home -- neither resolves here, where a doc lives
// at /docs/<slug> with no .html suffix. Rewrite known doc links to the
// in-app route, same idea as fixImagePaths() above. Only touches slugs that
// actually exist, so an unrelated "foo.html" link is left alone.
// Docs are also linked by their real filename stem (e.g. "concepts-hosts.html"
// for docs/concepts-hosts.md) -- the correct, working link on the Jekyll/
// GitHub Pages build, where the URL IS the filename stem. That doesn't match
// this viewer's own short slugs (DOCS keys, e.g. "hosts"), so also resolve by
// filename as a fallback -- one link written in a doc works correctly on
// both targets, rather than needing two different link forms.
const slugByFilename = Object.fromEntries(
Object.entries(DOCS).map(([slug, d]) => [path.basename(d.file, '.md'), slug])
);
function fixDocLinks(html) {
return html
.replace(/href="index\.html"/g, 'href="/docs"')
.replace(/href="([a-z0-9-]+)\.html"/g, (match, name) => {
const slug = DOCS[name] ? name : slugByFilename[name];
return slug ? `href="/docs/${slug}"` : match;
});
}
router.use(docsLimiter);
router.get('/', function(req, res) {
res.render('docs_index', {...values, docs: docList});
});
// Plain, dependency-free line-substring search over the same allowlisted
// doc set -- no separate index to build/maintain, no new dependency, and it
// keeps working with no internet access (same reasoning as the rest of this
// route). Must be registered before the /:slug catch-all below, or "search"
// would be treated as a (nonexistent) doc slug and 404.
router.get('/search', function(req, res) {
const q = (req.query.q || '').trim();
if (!q) return res.json({results: []});
const qLower = q.toLowerCase();
const results = [];
for (const [slug, doc] of Object.entries(DOCS)) {
try {
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
const matchLine = content.split('\n').find(line => line.toLowerCase().includes(qLower));
if (matchLine) {
results.push({slug, title: doc.title, snippet: matchLine.trim().slice(0, 200)});
}
} catch (error) { /* unreadable doc file -- skip it */ }
}
res.json({results});
});
router.get('/:slug', function(req, res, next) {
const doc = DOCS[req.params.slug];
if (!doc) return next({status: 404, message: 'Doc not found'});
try {
const content = fs.readFileSync(doc.file, 'utf8');
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
res.render('docs_page', {
...values,
docs: docList,
currentSlug: req.params.slug,
docTitle: doc.title,
docHtml: fixImagePaths(marked(content)),
docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
});
} catch (error) {
next(error);
+23
View File
@@ -128,6 +128,29 @@ router.get('/lookup/:item', authz.requireDomainRole('viewer', authz.resolve.host
}
});
// Is there a wildcard host that could serve as :item's parent (i.e. an
// already-issued cert :item could reuse instead of getting its own)? Two
// cases, covered by two different lookups: a brand-new subdomain that has
// never been created (lookUp()'s normal wildcard fallback finds it, since
// the name has no leaf of its own yet), and an ALREADY-EXISTING host or the
// wildcard's own base domain (lookUp() would just resolve to that host's
// own leaf -- lookUpWildcardParent() checks the sibling "*" slot instead;
// see its comment in models/host.js). Used by the host create/edit form to
// decide whether to offer "Parent Wildcard" as a challenge type.
router.get('/wildcard-parent/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam), async function(req, res, next){
try{
let match = Model.lookUp(req.params.item);
if(!match || !match.is_wildcard){
match = Model.lookUpWildcardParent(req.params.item);
}
return res.json({
results: (match && match.is_wildcard) ? match : null,
});
}catch(error){
return next(error);
}
});
// The full lookup tree exposes every host, so restrict it to admins.
router.get('/lookupobj', authz.requireAdmin, async function(req, res, next){
try{
+3 -2
View File
@@ -3,7 +3,8 @@
/**
* Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host
* (nginx routes that path here; see ops/nginx_conf/proxy.conf). These run the
* OIDC authorization-code flow (reusing utils/oidc.js and conf.oidc) and, on a
* OIDC authorization-code flow (reusing @simpleworkjs/oidc-client's pure oidc
* utils and conf.oidc) and, on a
* successful + authorized login, mint a Redis-backed SsoSession and set the
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that
* session (ops/nginx_conf/hostfeatures.lua).
@@ -15,7 +16,7 @@
const router = require('express').Router();
const conf = require('@simpleworkjs/conf');
const oidc = require('../utils/oidc');
const {oidc} = require('@simpleworkjs/oidc-client');
const {Host} = require('../models').models;
const {HostSsoState, SsoSession} = require('../models/sso_session');
const {identityAllowed} = require('../utils/host_sso');
+16 -14
View File
@@ -5,6 +5,7 @@ const express = require('express');
const router = require('express').Router();
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
const { mountStaticModules } = require('@simpleworkjs/app-stack');
const values ={
title: conf.environment !== 'production' ? `dev` : '',
@@ -15,24 +16,16 @@ const values ={
}
// List of front end node modules to be served
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
'moment', '@popper', 'jq-repeat',
];
// Server front end modules
// https://stackoverflow.com/a/55700773/3140931
// Vendor libraries only change when package versions are bumped (a rebuild),
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
// still cover that rare case with a cheap 304 instead of a stale asset.
frontEndModules.forEach(dep => {
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
// still cover that rare case with a cheap 304 instead of a stale asset. The
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
// every deploy and isn't cache-busted/fingerprinted.
mountStaticModules(router, {
root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
});
// Have express server static content( images, CSS, browser JS) from the public
// local folder. Shorter maxAge than /static-modules since this is the app's
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
router.get('/', (req, res) => {
res.redirect(301, '/hosts');
});
@@ -47,6 +40,15 @@ router.get('/hosts', async function(req, res, next) {
res.render('hosts', {...values});
});
// Linkable deep-link to a single host's modal, e.g. from the host modal's
// app.modal `url` option. No server-side use of :host -- the client reads
// location.pathname itself and opens the matching host's modal once the
// page's own data has loaded (same idiom sso-manager-node uses for
// /directory/:slug).
router.get('/hosts/:host', async function(req, res, next) {
res.render('hosts', {...values});
});
router.get('/dns', async function(req, res, next) {
res.render('dns', {...values});
});
+10 -1
View File
@@ -81,11 +81,20 @@ router.put('/password', async function(req, res, next){
}
});
// Admin: reset another user's password.
// Admin: reset another user's password. Blocked for SSO/OIDC-provisioned
// accounts (backing === 'oidc') -- they authenticate through the IdP, not a
// local password, so resetting one here would be a no-op at best and a
// false sense of control at worst. Only applies to the redis user backend;
// LDAP/PAM-backed deployments have no per-record marker for this.
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
try{
validatePassword(req.body.password);
let user = await User.get(req.params.username);
if(user.backing === 'oidc'){
let e = new Error('Cannot set a password for an SSO-authenticated user.');
e.status = 403;
throw e;
}
return res.json({results: await user.setPassword(req.body)});
}catch(error){
next(error);
@@ -0,0 +1,67 @@
const { describe, test, beforeEach, afterEach, after, mock } = require('node:test');
const assert = require('node:assert');
const crypto = require('crypto');
const baoConf = require('@simpleworkjs/bao-conf');
const Table = require('../../models/index');
const DnsProvider = Table.models.DnsProvider;
const DuckDns = require('../../models/dns_provider/duckdns');
describe('DnsProvider Vault Integration', () => {
let originalSet, originalGet, originalRequest;
after(async () => {
if (Table._redis && Table._redis.quit) {
await Table._redis.quit();
}
});
beforeEach(() => {
// Mock baoConf
originalSet = baoConf.set;
originalGet = baoConf.get;
originalRequest = baoConf.request;
const vaultStore = {};
baoConf.set = mock.fn(async (path, data) => { vaultStore[path] = data; return true; });
baoConf.get = mock.fn(async (path) => vaultStore[path] || {});
baoConf.request = mock.fn(async () => ({}));
mock.method(DuckDns.prototype, 'listDomains', async () => []);
mock.method(DnsProvider.prototype, 'updateDomains', async () => {});
});
afterEach(() => {
baoConf.set = originalSet;
baoConf.get = originalGet;
baoConf.request = originalRequest;
mock.restoreAll();
});
test('create() writes isPrivate keys to OpenBao and get() retrieves them', async () => {
const payload = {
name: 'My Duck',
dnsProvider: 'DuckDns',
token: 'super-secret-vault-token',
subdomains: 'myduck',
created_by: 'admin'
};
const instance = await DnsProvider.create(payload);
// 1. Should have called OpenBao set
assert.strictEqual(baoConf.set.mock.callCount(), 1);
const [path, secrets] = baoConf.set.mock.calls[0].arguments;
assert.strictEqual(path, `proxy/dns-providers/${instance.id}`);
assert.deepStrictEqual(secrets, { token: 'super-secret-vault-token' });
// 2. The returned instance should have the secret injected back
assert.strictEqual(instance.token, 'super-secret-vault-token');
// 3. get() should fetch public data from Redis and merge secrets from OpenBao
// (baoConf.get is already mocked to return from vaultStore)
const fetched = await DnsProvider.get(instance.id);
assert.strictEqual(fetched.token, 'super-secret-vault-token');
});
});
+106 -3
View File
@@ -185,6 +185,99 @@ 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);
});
});
/**
* Tests for the exact fallback combination used by
* routes/host.js's GET /wildcard-parent/:item (and, via hostMatchWildcard(),
* the host create/edit form's "Parent Wildcard" option) -- lookUp() first
* (handles a brand-new subdomain that has no leaf of its own yet), falling
* back to lookUpWildcardParent() only when lookUp() didn't resolve to a
* wildcard (handles an ALREADY-EXISTING host, which lookUp() would resolve
* to its own record). Regression coverage for the edit-form bug where the
* "Parent Wildcard" option stayed permanently greyed out for an existing
* host, because the route only ever tried lookUp().
*/
describe('Host wildcard-parent route fallback (lookUp then lookUpWildcardParent)', () => {
let Host;
before(async () => {
Host = createMockHostClassWithWildcardParentFix();
});
function findWildcardParent(host){
let match = Host.lookUp(host);
if(!match || !match.is_wildcard) match = Host.lookUpWildcardParent(host);
return (match && match.is_wildcard) ? match : null;
}
test('finds the wildcard for a brand-new subdomain that was never created', async () => {
await populateTree(Host, ['*.cool.mysite.com']);
const result = findWildcardParent('newthing.cool.mysite.com');
assert.ok(result);
assert.strictEqual(result.host, '*.cool.mysite.com');
});
test('finds the wildcard for the wildcard\'s own base domain, whether or not it is already a plain host', async () => {
await populateTree(Host, ['*.cool.mysite.com']);
assert.strictEqual(findWildcardParent('cool.mysite.com').host, '*.cool.mysite.com');
await populateTree(Host, ['*.cool.mysite.com', 'cool.mysite.com']);
assert.strictEqual(findWildcardParent('cool.mysite.com').host, '*.cool.mysite.com');
});
test('returns null when the host has no wildcard sibling at all', async () => {
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');
});
});
/**
@@ -219,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'];
}
};
}
@@ -243,7 +342,11 @@ async function populateTree(Host, hosts) {
}
if(fragments.length === 0){
pointer[fragment]['#record'] = {host};
// is_wildcard mirrors the real Host model's own field (set
// whenever a host is DNS-01 wildcard-issued, i.e. starts with
// "*."), needed by tests that check it the same way the real
// /wildcard-parent/:item route does.
pointer[fragment]['#record'] = {host, is_wildcard: host.startsWith('*.')};
if(fragment === '*' && !pointer['#record']){
pointer['#record'] = pointer[fragment]['#record'];
@@ -0,0 +1,44 @@
'use strict';
// Regression guard: native alert()/confirm()/prompt() calls block all further
// browser events on the page (found live, mid browser-automation testing, on
// sso-manager-node's equivalent secret-rotate flow) and are visually
// inconsistent with the rest of the UI. Every call site was removed in favor
// of app.messages.action/confirm/toast and app.modal.open; this test keeps
// it that way.
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', '..', d));
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
function walk(dir) {
let files = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files = files.concat(walk(full));
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
}
return files;
}
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
const offenders = [];
for (const root of ROOTS) {
for (const file of walk(root)) {
const src = fs.readFileSync(file, 'utf8');
let m;
NATIVE_DIALOG_RE.lastIndex = 0;
while ((m = NATIVE_DIALOG_RE.exec(src))) {
const line = src.slice(0, m.index).split('\n').length;
offenders.push(`${path.relative(path.join(__dirname, '..', '..'), file)}:${line}${m[2]}(`);
}
}
}
assert.deepStrictEqual(offenders, []);
});
+1 -1
View File
@@ -4,7 +4,7 @@ const {describe, test} = require('node:test');
const assert = require('node:assert');
const crypto = require('crypto');
const oidc = require('../../utils/oidc');
const oidc = require('@simpleworkjs/oidc-client').oidc;
const conf = require('@simpleworkjs/conf');
/**
+1 -1
View File
@@ -3,7 +3,7 @@
const {describe, test} = require('node:test');
const assert = require('node:assert');
const {safeInternalPath} = require('../../utils/safe_redirect');
const {safeInternalPath} = require('@simpleworkjs/oidc-client');
/**
* safeInternalPath guards the OIDC post-login redirect against open-redirect
+12 -25
View File
@@ -1,29 +1,16 @@
'use strict';
const fs = require('fs');
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit
// (../ from here in utils/), matching the Dockerfile gitinfo stage; cwd is
// utils/ for the bare-metal git fallback.
const path = require('path');
const { execSync } = require('child_process');
const { version: buildVersion } = require('../package.json');
const { createBuildInfo } = require('@simpleworkjs/app-stack');
const { version } = require('../package.json');
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo
// stage in Dockerfile) -- the final image has no git binary and no .git
// directory, so `git rev-parse` below always fails there. Bare-metal/dev
// runs have no baked file, so they fall back to asking git directly.
function readBuildHash() {
try {
const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim();
if (baked) return baked;
} catch (_) {}
try {
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
} catch (_) {
return 'unknown';
}
}
module.exports = {
buildVersion,
buildHash: readBuildHash(),
buildYear: new Date().getFullYear(),
};
module.exports = createBuildInfo({
version,
buildCommitPath: path.join(__dirname, '../.build_commit'),
cwd: __dirname,
});
+1
View File
@@ -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);
-127
View File
@@ -1,127 +0,0 @@
'use strict';
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
/**
* Minimal OpenID Connect authorization-code + PKCE client.
*
* The SSO publishes no jwks_uri, so we do not verify ID-token signatures;
* instead we treat the flow as opaque and read identity from the userinfo
* endpoint (the access token is exchanged server-side over TLS). Uses Node's
* global fetch (Node 18+) and crypto — no external dependency.
*
* All endpoints and client config come from conf.oidc (+ clientSecret from
* secrets.js, deep-merged by @simpleworkjs/conf).
*/
const base64url = buf => buf.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// A high-entropy random string for `state` / PKCE verifier.
function randomToken(bytes = 32){
return base64url(crypto.randomBytes(bytes));
}
// PKCE S256 challenge derived from the verifier.
function codeChallengeS256(verifier){
return base64url(crypto.createHash('sha256').update(verifier).digest());
}
// Generate the {state, codeVerifier, codeChallenge} triple for a new login.
function createAuthRequest(){
let state = randomToken(32);
let codeVerifier = randomToken(32);
let codeChallenge = codeChallengeS256(codeVerifier);
return {state, codeVerifier, codeChallenge};
}
// Build the SSO authorize URL the browser is redirected to. `redirectUri`
// overrides conf.oidc.redirectUri (per-host SSO uses a per-host callback).
function buildAuthUrl(state, codeChallenge, redirectUri){
let o = conf.oidc;
let params = new URLSearchParams({
response_type: 'code',
client_id: o.clientId,
redirect_uri: redirectUri || o.redirectUri,
scope: (o.scopes || ['openid', 'profile', 'email', 'groups']).join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${o.authorizationEndpoint}?${params.toString()}`;
}
// Exchange an authorization code for tokens at the token endpoint. `redirectUri`
// must match the one used in buildAuthUrl (per-host for per-host SSO).
async function exchangeCode(code, codeVerifier, redirectUri){
let o = conf.oidc;
let body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri || o.redirectUri,
client_id: o.clientId,
client_secret: o.clientSecret,
code_verifier: codeVerifier,
});
let res = await fetch(o.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.toString(),
});
if(!res.ok){
let text = await res.text().catch(() => '');
let error = new Error('OidcTokenExchangeFailed');
error.name = 'OidcTokenExchangeFailed';
error.message = `Token exchange failed (${res.status}): ${text}`;
error.status = 502;
throw error;
}
return res.json();
}
// Fetch the userinfo claims for an access token.
async function fetchUserInfo(accessToken){
let o = conf.oidc;
let res = await fetch(o.userinfoEndpoint, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
if(!res.ok){
let error = new Error('OidcUserInfoFailed');
error.name = 'OidcUserInfoFailed';
error.message = `Userinfo request failed (${res.status})`;
error.status = 502;
throw error;
}
return res.json();
}
// Pull the app username and group list out of userinfo claims per conf.
function claimsToIdentity(claims){
let o = conf.oidc;
let username = claims[o.usernameClaim || 'preferred_username'] || claims.sub;
let groups = claims[o.groupsClaim || 'groups'] || [];
if(!Array.isArray(groups)) groups = [groups].filter(Boolean);
return {username, groups, claims};
}
module.exports = {
randomToken,
codeChallengeS256,
createAuthRequest,
buildAuthUrl,
exchangeCode,
fetchUserInfo,
claimsToIdentity,
};
-23
View File
@@ -1,23 +0,0 @@
'use strict';
/**
* Constrain a post-login redirect target to a same-origin path.
*
* Rejects anything that could leave the site or execute script:
* - absolute URLs ("https://evil.com") -> not a "/" path
* - protocol-relative ("//evil.com", "/\\evil.com") -> host takeover
* - scheme targets ("javascript:...", "data:...") -> XSS
* Anything not a plain "/path" falls back to "/".
*
* The browser has its own copy of this in public/lib/js/app-base.js; keep the
* two in sync.
*/
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
module.exports = {safeInternalPath};
+45
View File
@@ -0,0 +1,45 @@
'use strict';
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
//
// Those two partials are byte-identical across sso-manager-node, proxy and
// jump-host — everything that differs between the apps lives here and is
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
// in sync across the three apps; a missing key is a render-time ReferenceError,
// not a silent fallback.
module.exports = {
// --- footer -------------------------------------------------------------
repoUrl: 'https://github.com/theta42/proxy',
licenseUrl: 'https://github.com/theta42/proxy/blob/master/LICENSE',
// In-app docs route (routes/docs.js). Apps without one point at the
// published docs site and set docsExternal.
docsUrl: '/docs',
docsExternal: false,
// Only sso-manager-node serves a Terms of Service page; null hides the link.
tosUrl: null,
// --- header / nav -------------------------------------------------------
faviconUrl: '/static/favicon.svg',
// Where the current-user chip links. null renders it as a plain span (for
// apps with no profile page).
profileUrl: '/profile',
// Where "Log Out" lands.
logoutRedirect: '/',
// Admin-only "a newer release is available" banner, backed by
// GET /api/update-check. Apps without that endpoint set false.
updateCheck: true,
updateLabel: 'the proxy',
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
// item; an empty list means "always visible". Gating is done client-side by
// app-base.js, which reveals .group-required-<cn> for each group the user is
// in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [
{href: '/hosts', icon: 'fa-solid fa-network-wired', label: 'Hosts', groups: []},
{href: '/dns', icon: 'fa-solid fa-record-vinyl', label: 'DNS', groups: []},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['admin']},
{href: '/permissions', icon: 'fa-solid fa-user-shield', label: 'Permissions', groups: ['admin']},
{href: '/groups', icon: 'fa-solid fa-users-gear', label: 'Groups', groups: ['admin']},
],
};
+6 -4
View File
@@ -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
+28 -22
View File
@@ -1,24 +1,30 @@
</div>
</div><!-- end spa-shell -->
<footer class="py-2 bg-dark text-light mt-4">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank">
<img width="64" src="/static/img/theta42.svg"/>
</a>
&copy; <%- buildYear %> theta42 &middot;
<a href="https://github.com/theta42/proxy/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="/docs" class="text-light text-decoration-none">
<i class="fa-solid fa-book"></i> Docs
</a>
<a href="https://github.com/theta42/proxy" target="_blank" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
</span>
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
</div>
</footer>
</body>
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
app.locals in app.js). Edit all three copies together. -->
<footer class="py-2 bg-dark text-light mt-4">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank">
<img width="64" src="/static/img/theta42.svg"/>
</a>
&copy; <%- buildYear %> theta42 &middot;
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
<i class="fa-solid fa-book"></i> Docs
</a>
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<% if(ui.tosUrl){ %>
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
<% } %>
</span>
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
</div>
</footer>
</body>
</html>
+4
View File
@@ -113,6 +113,7 @@
});
</script>
<div class="container mt-4">
<div class="row mb-3" style="display:none">
<div class="col-md-3">
<div class="card shadow-lg mb-3">
@@ -125,6 +126,7 @@
Add DNS Provider
</span>
<span class="float-end">
<a href="/docs/dns" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
@@ -226,6 +228,7 @@
<div class="card-header d-flex align-items-center">
<span class="card-icon me-2"><i class="fa-solid fa-tower-broadcast"></i></span>
<span class="card-title">Dynamic A Records</span>
<a href="/docs/dns" class="text-reset ms-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<span class="ms-auto text-muted small">
This server's public IP:
<span class="badge text-bg-primary fs-6"><i class="fa-solid fa-globe me-1"></i><span id="ddns-current-ip">…</span></span>
@@ -297,4 +300,5 @@
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+42 -1
View File
@@ -11,7 +11,12 @@
A local copy of this project's documentation, readable from the
running app -- no internet access required.
</p>
<ul class="list-group">
<div class="input-group mb-3">
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
<input type="search" id="docs-search-input" class="form-control" placeholder="Search the docs…" oninput="docsSearch(this.value)">
</div>
<div id="docs-search-results" style="display:none"></div>
<ul id="docs-list" class="list-group">
<% docs.forEach(function(doc){ %>
<li class="list-group-item">
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
@@ -22,5 +27,41 @@
</div>
</div>
</div>
<script type="text/javascript">
var docsSearchTimer;
function docsSearch(q){
clearTimeout(docsSearchTimer);
docsSearchTimer = setTimeout(function(){ docsSearchRun(q); }, 200);
}
function docsSearchRun(q){
q = (q || '').trim();
var $results = $('#docs-search-results');
var $list = $('#docs-list');
if(!q){
$results.hide().empty();
$list.show();
return;
}
// Not app.api.get() -- routes/docs.js is mounted at /docs directly,
// not under /api, unlike the rest of this app's endpoints.
$.getJSON('/docs/search', {q: q}, function(data){
$list.hide();
$results.empty().show();
var hits = (data && data.results) || [];
if(!hits.length){
$results.append($('<p class="text-muted"></p>').text('No results for "' + q + '".'));
return;
}
var $ul = $('<ul class="list-group"></ul>');
hits.forEach(function(hit){
var $li = $('<li class="list-group-item"></li>');
$('<a></a>').attr('href', '/docs/' + hit.slug).text(hit.title).appendTo($li);
$('<div class="text-muted small"></div>').text(hit.snippet).appendTo($li);
$ul.append($li);
});
$results.append($ul);
});
}
</script>
<%- include('bottom') %>
+7 -4
View File
@@ -28,14 +28,14 @@
function removeGroup(name){
app.group.remove(name, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
$.scope.LocalGroup.remove(name);
});
}
function removeMember(group, username){
app.group.removeMember(group, username, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
// websocket update echoes the new member list.
});
}
@@ -47,14 +47,14 @@
let username = ($input.val() || '').trim();
if(!username) return;
app.group.addMember(group, username, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
$input.val('');
});
}
$(document).ready(function(){
app.group.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.LocalGroup.$this, 'danger');
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
for(let g of data.results) $.scope.LocalGroup.push(g);
});
@@ -78,6 +78,7 @@
});
</script>
<div class="container mt-4">
<datalist id="groupUsers"></datalist>
<div class="row" style="display:none">
@@ -86,6 +87,7 @@
<div class="card-header text-center">
<span class="card-icon float-start"><i class="fa-solid fa-users-gear"></i></span>
<span class="card-title">Add Group</span>
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
@@ -142,4 +144,5 @@
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+427 -379
View File
@@ -61,9 +61,9 @@
return host;
}
function hostPopulate(){
function hostPopulate(onLoaded){
app.api.get('host?detail=1&provider=1', function(error, res){
if(error) return app.util.actionMessage(error, $.scope.hosts.$this, 'danger');
if(error) return app.messages.action(error, $.scope.hosts.$this, 'danger');
for(let host of res.results){
$.scope.hosts.push(hostParseRow(host));
@@ -75,6 +75,8 @@
$el.removeClass('table-success');
});
};
if (onLoaded) onLoaded();
});
}
@@ -90,10 +92,341 @@
// ----- Add / Edit modal --------------------------------------------------
function hostModal(){
return bootstrap.Modal.getOrCreateInstance(document.getElementById('hostModal'));
function hostModalClose(){ app.modal.close(); }
// Built once; injected fresh into app.modal's body on every open() call
// (unlike the old always-in-DOM static modal). .actionMessage lives
// INSIDE the form (not a sibling, as it was before) because formAJAX's
// error/success target resolves via $form.closest('div.card') (which
// app.modal's plain, card-less .modal-content never matches) falling
// back to $form.find('.actionMessage') -- a descendant search.
var hostModalBodyHtml = `
<form id="hostForm" method="POST" action="host" onsubmit="formAJAX(this)" evalAJAX="hostModalClose()">
<div class="actionMessage m-0" style="display:none"></div>
<ul class="nav nav-tabs flex-nowrap overflow-x-auto" role="tablist">
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS &amp; Wildcard</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-headers-btn" data-bs-toggle="tab" data-bs-target="#hostTab-headers" type="button" role="tab">Headers</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-access-btn" data-bs-toggle="tab" data-bs-target="#hostTab-access" type="button" role="tab">Access</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-auth-btn" data-bs-toggle="tab" data-bs-target="#hostTab-auth" type="button" role="tab">Authentication</button></li>
</ul>
<div class="tab-content pt-3">
<!-- General -->
<div class="tab-pane fade show active" id="hostTab-general" role="tabpanel">
<div class="form-group">
<label for="host" class="form-label">Incoming host name</label>
<input type="text" name="host" class="form-control" placeholder="ex: app.example.com, *.example.com, **.example.com, or **" validate="host">
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">
The public hostname clients request. Use <code>*.example.com</code>
for one subdomain level, <code>**.example.com</code> for any depth,
or <code>**</code> as a catch-all.
</small>
<small id="host-rename-help" class="field-help text-muted d-block" style="display:none">
Wildcard hosts, their children, and auto-created subdomain cache
entries can't be renamed here — the name is referenced elsewhere
(the wildcard's own children, or the cache entry's parent). Delete
and recreate instead.
</small>
</div>
<div class="form-group">
<label class="form-label">Incoming SSL</label>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
Force HTTPS <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-false" value="false">
Allow both HTTP and HTTPS
</label></div>
<small class="field-help text-muted d-block">Redirect plain HTTP requests to HTTPS.</small>
</div>
<hr>
<div class="form-group">
<label for="ip" class="form-label">Target IP or host name</label>
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10, app.internal.net, or sso-manager" validate="target:3" />
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">Where matching requests are proxied. Hostname or IP only &mdash; no protocol, port, or path.</small>
</div>
<div class="row">
<div class="col form-group">
<label for="targetPort" class="form-label">Target TCP port</label>
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
<b class="invalid-feedback"></b>
</div>
<div class="col form-group">
<label class="form-label">Target SSL</label>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
Proxy to HTTP <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label></div>
<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;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 -->
<div class="tab-pane fade" id="hostTab-tls" role="tabpanel">
<div class="form-group autoSll">
<label class="form-label">
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">validation type</a>
</label>
<div class="radio" id="challengeType-HTTP-01-container"><label>
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
HTTP-01
</label></div>
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container"><label>
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
DNS-01 Wildcard
</label></div>
<div class="radio challengeType-container" id="challengeType-child-container"><label>
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
</label></div>
<small class="field-help text-muted d-block">
Options light up based on the host name: wildcard certs need a DNS
provider for the domain; child hosts reuse a parent wildcard.
</small>
</div>
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
<label class="form-label">Wildcard matching</label>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
Match only subdomains defined here <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
Match any subdomain and proxy to this host
</label></div>
<small class="field-help text-muted d-block">
"Recommended" only routes subdomains you've explicitly registered
as their own host (optionally as a "Parent Wildcard" child of this
one, to reuse this cert). "Match any" auto-creates a temporary
route to this host's target for <i>any</i> undefined subdomain the
first time it's requested — convenient, but it means every subdomain
typo or scan attempt also gets routed here.
</small>
</div>
</div>
<!-- Traffic -->
<div class="tab-pane fade" id="hostTab-traffic" role="tabpanel">
<div class="form-group">
<label class="form-label">Rate limiting</label>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
Limit requests per client IP
</label></div>
</div>
<div class="row">
<div class="col form-group">
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
</div>
<div class="col form-group">
<label for="ratelimit_burst" class="form-label">Burst</label>
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
</div>
</div>
<small class="field-help text-muted d-block mb-3">Token bucket per client IP; bursts above the rate are queued, then rejected with 429.</small>
<hr>
<div class="form-group">
<label class="form-label">Response caching</label>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
Cache cacheable responses
</label></div>
<small class="field-help text-muted d-block">Cache upstream responses that declare themselves cacheable.</small>
</div>
<div class="form-group">
<label class="form-label">HSTS</label>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
Off
</label></div>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
Send Strict-Transport-Security
</label></div>
<small class="field-help text-muted d-block">Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working.</small>
</div>
</div>
<!-- Headers -->
<div class="tab-pane fade" id="hostTab-headers" role="tabpanel">
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each request sent to the target. One <code>Name: value</code> per line.</small>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each response returned to the client.</small>
</div>
</div>
<!-- Access -->
<div class="tab-pane fade" id="hostTab-access" role="tabpanel">
<h6 class="text-muted">IP access</h6>
<div class="form-group">
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
<small class="field-help text-muted d-block">If non-empty, only these sources may connect (default-deny).</small>
</div>
<div class="form-group">
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
<small class="field-help text-muted d-block">These sources are always blocked (deny wins over allow).</small>
</div>
</div>
<!-- Authentication -->
<div class="tab-pane fade" id="hostTab-auth" role="tabpanel">
<p class="field-help text-muted">
Pick one authentication method for this host &mdash; basic auth and
SSO can't both be enabled, to avoid ambiguity about which one
actually protected a request. Choose "Off" for a public host.
</p>
<div class="form-group">
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-none" value="none" checked onchange="hostAuthModeChanged('none')">
Off (public)
</label></div>
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-basic" value="basic" onchange="hostAuthModeChanged('basic')">
Basic authentication
</label></div>
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-sso" value="sso" onchange="hostAuthModeChanged('sso')">
Single sign-on (SSO)
</label></div>
</div>
<!-- Actually-submitted fields; kept in sync with the radios above by
hostAuthModeChanged() so only one can be true at a time. -->
<input type="hidden" name="basicauth_enabled" id="basicauth_enabled-hidden" value="false">
<input type="hidden" name="sso_enabled" id="sso_enabled-hidden" value="false">
<div id="hostTab-auth-basicFields" style="display:none">
<hr>
<h6 class="text-muted">Basic authentication</h6>
<div class="form-group">
<label for="basicauth_realm" class="form-label">Realm</label>
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
</div>
<div class="form-group">
<label for="basicauth_users" class="form-label">Users</label>
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
<small class="field-help text-muted d-block">
Current: <span class="basicauth-current">none</span>.
Passwords are stored hashed and never shown here. Leave blank to keep
the current users; entering any lines replaces the whole list. To
manage individual users (delete / change password), use the table
below once the host has been saved.
</small>
</div>
</div>
<div id="hostTab-auth-ssoFields" style="display:none">
<hr>
<h6 class="text-muted">Single sign-on (SSO)</h6>
<p class="field-help text-muted">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</p>
<div class="form-group">
<label for="sso_allow_users" class="form-label">Allowed users</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoUsers" placeholder="type to search users…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_users');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_users')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_users" class="form-control" rows="2" placeholder="one email/username per line; blank = any authenticated user"></textarea>
</div>
<div class="form-group">
<label for="sso_allow_groups" class="form-label">Allowed groups</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoGroups" placeholder="type to search groups…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_groups');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_groups')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_groups" class="form-control" rows="2" placeholder="one group per line; blank = any authenticated user"></textarea>
</div>
</div>
<div id="hostTab-auth-basicUsersMgmt" style="display:none">
<hr>
<h6 class="text-muted">Manage basic-auth users</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead><tr><th>Username</th><th>New password</th><th></th></tr></thead>
<tbody id="basicAuthUserRows"></tbody>
</table>
</div>
</div>
</div>
</div>
<datalist id="hostSsoUsers"></datalist>
<datalist id="hostSsoGroups"></datalist>
</form>
`;
function openHostModal(title, submitLabel, host){
app.modal.open({
title: title,
size: 'lg',
bodyHtml: hostModalBodyHtml,
footer: {
// created_on/updated_on come back as redis-hash strings, not
// numbers -- moment's bare fallback parser mishandles that
// (silently "Invalid date"), same reason hostParseRow above
// uses the explicit "x" (unix ms) format token.
metaHtml: host ? app.modal.formatAudit(host, {formatDate: function(ms){ return moment(ms, 'x').format('YYYY-MM-DD HH:mm'); }}) : '',
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><i class="fa-solid fa-ban"></i> Cancel</button>'
+ '<button type="submit" form="hostForm" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> ' + $('<div>').text(submitLabel).html() + '</button>',
},
url: host ? {path: '/hosts/' + encodeURIComponent(host.host)} : null,
});
// The datalists were just rebuilt empty -- refill them (cheap re-fetch,
// no cache bug here since this function has no cache guard to skip it).
hostLoadAuthSuggestions();
}
function hostModalClose(){ hostModal().hide(); }
function hostShowTab(id){
bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show();
@@ -152,9 +485,9 @@
let password = $pass.val();
if(!password) return;
app.api.put('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), {password}, function(error, data){
if(error) return app.util.actionMessage((data && data.message) || 'Failed to update password', $rows, 'danger');
if(error) return app.messages.action((data && data.message) || 'Failed to update password', $rows, 'danger');
$pass.val('');
app.util.actionMessage('Password updated for "' + username + '".', $rows, 'success');
app.messages.action('Password updated for "' + username + '".', $rows, 'success');
});
});
// No confirm step, matching this form's existing "Delete" button
@@ -163,7 +496,7 @@
let $del = $('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button>');
$del.on('click', function(){
app.api.delete('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), function(error, data){
if(error) return app.util.actionMessage((data && data.message) || 'Failed to delete user', $rows, 'danger');
if(error) return app.messages.action((data && data.message) || 'Failed to delete user', $rows, 'danger');
$tr.remove();
$('.basicauth-current').text(Object.keys((data && data.basicauth_users) || {}).join(', ') || 'none');
});
@@ -206,20 +539,17 @@
}
function hostAddOpen(){
openHostModal('Add host', 'Add host', null);
hostFormReset();
$('#hostModalTitle').text('Add host');
$('#hostModalSubmitText').text('Add host');
hostModal().show();
}
function hostEditOpen(host){
hostFormReset();
async function hostEditOpen(host){
let h = $.scope.hosts.getByKey(host);
openHostModal('Edit ' + host, 'Save changes', h);
hostFormReset();
let $f = $('#hostForm');
$f.attr('method', 'PUT').attr('action', 'host/' + encodeURIComponent(host));
$('#hostModalTitle').text('Edit ' + host);
$('#hostModalSubmitText').text('Save changes');
// Scalar fields: booleans drive the matching radio, everything else the
// input with that name. Object/array fields are handled as text below.
@@ -232,6 +562,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));
@@ -258,16 +589,37 @@
let hostRenameable = !h.is_wildcard && !h.wildcard_parent && !h.is_cache;
$f.find('[name=host]').prop('disabled', !hostRenameable);
$('#host-rename-help').toggle(!hostRenameable);
if(h.is_wildcard){
$('#wildcard_matchAny-container').removeClass('challengeType-container');
}
hostModal().show();
// Reflect + enable the challenge-type options actually available for
// this host. Setting the host field's .val() above does not fire a
// 'keyup' event, so without this the "Parent Wildcard" option stayed
// permanently greyed out on edit even when a valid parent wildcard
// existed -- it only ever got un-greyed by the user re-typing the
// hostname (the keyup handler further down).
$('#challengeType-child-container, #challengeType-DNS-01-wildcard-container, #wildcard_matchAny-container')
.addClass('challengeType-container');
if(h.is_wildcard){
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
$('#challengeType-DNS-01-wildcard').prop('checked', true);
$('#wildcard_matchAny-container').removeClass('challengeType-container');
}else{
let wildcardParent = await hostMatchWildcard(h.host);
if(wildcardParent){
$('#challengeType-child-container').removeClass('challengeType-container');
$('#challengeType-child-relatedHost').text(wildcardParent.host);
}
if(h.wildcard_parent){
$('#challengeType-wildcardChild').prop('checked', true);
}else{
$('#challengeType-HTTP-01').prop('checked', true);
}
}
}
function hostDownloadCert(host, type){
app.host.getCert({host}, function(error, data){
if(error) app.util.actionMessage(error.message, $.scope.hosts.$this, 'danger');
if(error) app.messages.action(error.message, $.scope.hosts.$this, 'danger');
app.util.downloadFile(`${host}-${type}.crt`, data[type])
});
}
@@ -298,58 +650,71 @@
app.host.clearCache(function(error, data){
$btn.prop('disabled', false);
if(error){
return app.util.actionMessage(error.message || error, $.scope.hosts.$this, 'danger');
return app.messages.action(error.message || error, $.scope.hosts.$this, 'danger');
}
app.util.actionMessage(data.message, $.scope.hosts.$this, 'success');
app.messages.action(data.message, $.scope.hosts.$this, 'success');
});
}
async function hostMatchWildcard(host){
try{
let res = await app.api.get(`host/lookup/${host}`);
if(res.results && res.results.is_wildcard){
return res.results;
}
// Not /host/lookup/ -- that resolves an ALREADY-EXISTING host to its
// own record, not a sibling wildcard (see the route's comment). This
// dedicated endpoint correctly finds a usable wildcard parent whether
// @host is brand new or already exists as its own host.
let res = await app.api.get(`host/wildcard-parent/${host}`);
return res.results || false;
}catch(error){
return false;
}
}
// Determine what Let's Encrypt challenge type the given host name can use.
// Delegated (not a direct bind against a captured selector) since
// app.modal.open() rebuilds #hostForm from scratch on every call -- a
// direct .on('keyup', ...) bind here would silently stop firing after
// the first Add/Edit.
app.modal.on('keyup', '#hostForm [name=host]', async function(){
let $hostField = $(this);
// Reset the allowed types on start
$('#challengeType-child-container').addClass('challengeType-container');
$('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container');
$('#wildcard_matchAny-container').addClass('challengeType-container');
let host = $hostField.val();
// If it's a wildcard, we must check the domain has a registered provider.
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
$('#wildcard_matchAny-container').removeClass('challengeType-container');
return;
}
// Check if a wildcard cert is available for the given host. When it is,
// make "Parent Wildcard" the default choice (it reuses an existing cert).
let wildcardParent = await hostMatchWildcard(host);
if(wildcardParent){
$('#challengeType-child-container').removeClass('challengeType-container');
$('#challengeType-child-relatedHost').text(wildcardParent.host);
$('#challengeType-wildcardChild').prop('checked', true);
return;
}
// Revert the form to a valid state.
$('#challengeType-child-relatedHost').text('');
$('#challengeType-HTTP-01').prop('checked', true);
});
$(document).ready(function(){
// Populate the host UI table
hostPopulate();
hostLoadAuthSuggestions();
// Determine what Let's Encrypt challenge type the given host name can use.
let $hostField = $('#hostForm [name=host]');
$hostField.on('keyup', async function(){
// Reset the allowed types on start
$('#challengeType-child-container').addClass('challengeType-container');
$('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container');
$('#wildcard_matchAny-container').addClass('challengeType-container');
let host = $hostField.val();
// If it's a wildcard, we must check the domain has a registered provider.
if(host.startsWith("*.") && await verifyWildcardRequirements(host)){
$('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container');
$('#wildcard_matchAny-container').removeClass('challengeType-container');
return;
// Populate the host UI table, then check for a deep-linked host
// (e.g. a direct load of /hosts/some-host, or app.modal's url option
// having pushed that path) once the table's data is actually loaded.
hostPopulate(function(){
const slug = app.modal.deepLinkSlug('/hosts');
if (slug) {
const h = $.scope.hosts.getByKey(decodeURIComponent(slug));
if (h) hostEditOpen(h.host);
}
// Check if a wildcard cert is available for the given host. When it is,
// make "Parent Wildcard" the default choice (it reuses an existing cert).
let wildcardParent = await hostMatchWildcard(host);
if(wildcardParent){
$('#challengeType-child-container').removeClass('challengeType-container');
$('#challengeType-child-relatedHost').text(wildcardParent.host);
$('#challengeType-wildcardChild').prop('checked', true);
return;
}
// Revert the form to a valid state.
$('#challengeType-child-relatedHost').text('');
$('#challengeType-HTTP-01').prop('checked', true);
});
$.scope.hosts.take = function($el, item, list){
@@ -387,6 +752,7 @@
});
</script>
<div class="container mt-4">
<div class="row" style="display:none">
<div class="col-12">
<div class="card shadow-lg hostListPanel">
@@ -394,6 +760,7 @@
<span class="card-icon me-2"><i class="fa-solid fa-network-wired"></i></span>
<span class="card-title fw-bold">Proxy List</span>
<span class="ms-auto">
<a href="/docs/hosts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="hostClearCache(this)" title="Clear cached wildcard subdomain lookups">
<i class="fa-solid fa-broom"></i>
Clear cache
@@ -520,325 +887,6 @@
</div>
</div>
</div>
<!-- Add / Edit host modal ------------------------------------------------- -->
<div class="modal fade" id="hostModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content card border-0">
<div class="modal-header">
<h5 class="modal-title" id="hostModalTitle">Add host</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="card-header actionMessage m-0" style="display:none"></div>
<div class="modal-body">
<ul class="nav nav-tabs flex-nowrap overflow-x-auto" role="tablist">
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS &amp; Wildcard</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-headers-btn" data-bs-toggle="tab" data-bs-target="#hostTab-headers" type="button" role="tab">Headers</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-access-btn" data-bs-toggle="tab" data-bs-target="#hostTab-access" type="button" role="tab">Access</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-auth-btn" data-bs-toggle="tab" data-bs-target="#hostTab-auth" type="button" role="tab">Authentication</button></li>
</ul>
<form class="addHost" id="hostForm" method="POST" action="host" onsubmit="formAJAX(this)" evalAJAX="hostModalClose()">
<div class="tab-content pt-3">
<!-- General -->
<div class="tab-pane fade show active" id="hostTab-general" role="tabpanel">
<div class="form-group">
<label for="host" class="form-label">Incoming host name</label>
<input type="text" name="host" class="form-control" placeholder="ex: app.example.com, *.example.com, **.example.com, or **" validate="host">
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">
The public hostname clients request. Use <code>*.example.com</code>
for one subdomain level, <code>**.example.com</code> for any depth,
or <code>**</code> as a catch-all.
</small>
<small id="host-rename-help" class="field-help text-muted d-block" style="display:none">
Wildcard hosts, their children, and auto-created subdomain cache
entries can't be renamed here — the name is referenced elsewhere
(the wildcard's own children, or the cache entry's parent). Delete
and recreate instead.
</small>
</div>
<div class="form-group">
<label class="form-label">Incoming SSL</label>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-true" value="true" checked>
Force HTTPS <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="forcessl" id="forcessl-false" value="false">
Allow both HTTP and HTTPS
</label></div>
<small class="field-help text-muted d-block">Redirect plain HTTP requests to HTTPS.</small>
</div>
<hr>
<div class="form-group">
<label for="ip" class="form-label">Target IP or host name</label>
<input type="text" name="ip" class="form-control" placeholder="ex: 10.10.10.10, app.internal.net, or sso-manager" validate="target:3" />
<b class="invalid-feedback"></b>
<small class="field-help text-muted d-block">Where matching requests are proxied. Hostname or IP only &mdash; no protocol, port, or path.</small>
</div>
<div class="row">
<div class="col form-group">
<label for="targetPort" class="form-label">Target TCP port</label>
<input type="number" name="targetPort" class="form-control" value="80" min="0" max="65535" />
<b class="invalid-feedback"></b>
</div>
<div class="col form-group">
<label class="form-label">Target SSL</label>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-false" value="false" checked>
Proxy to HTTP <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label></div>
<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>
<!-- TLS & Wildcard -->
<div class="tab-pane fade" id="hostTab-tls" role="tabpanel">
<div class="form-group autoSll">
<label class="form-label">
SSL <a href="https://letsencrypt.org/docs/challenge-types/" target="_blank">validation type</a>
</label>
<div class="radio" id="challengeType-HTTP-01-container"><label>
<input type="radio" name="challengeType" id="challengeType-HTTP-01" value="HTTP-01" checked>
HTTP-01
</label></div>
<div class="radio challengeType-container" id="challengeType-DNS-01-wildcard-container"><label>
<input type="radio" name="challengeType" id="challengeType-DNS-01-wildcard" value="DNS-01-wildcard">
DNS-01 Wildcard
</label></div>
<div class="radio challengeType-container" id="challengeType-child-container"><label>
<input type="radio" name="challengeType" id="challengeType-wildcardChild" value="wildcardChild">
Parent Wildcard from <i id="challengeType-child-relatedHost"></i>
</label></div>
<small class="field-help text-muted d-block">
Options light up based on the host name: wildcard certs need a DNS
provider for the domain; child hosts reuse a parent wildcard.
</small>
</div>
<div class="form-group challengeType-container" id="wildcard_matchAny-container">
<label class="form-label">Wildcard matching</label>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-false" value="false" checked>
Match only subdomains defined here <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
Match any subdomain and proxy to this host
</label></div>
<small class="field-help text-muted d-block">
"Recommended" only routes subdomains you've explicitly registered
as their own host (optionally as a "Parent Wildcard" child of this
one, to reuse this cert). "Match any" auto-creates a temporary
route to this host's target for <i>any</i> undefined subdomain the
first time it's requested — convenient, but it means every subdomain
typo or scan attempt also gets routed here.
</small>
</div>
</div>
<!-- Traffic -->
<div class="tab-pane fade" id="hostTab-traffic" role="tabpanel">
<div class="form-group">
<label class="form-label">Rate limiting</label>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
Limit requests per client IP
</label></div>
</div>
<div class="row">
<div class="col form-group">
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
</div>
<div class="col form-group">
<label for="ratelimit_burst" class="form-label">Burst</label>
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
</div>
</div>
<small class="field-help text-muted d-block mb-3">Token bucket per client IP; bursts above the rate are queued, then rejected with 429.</small>
<hr>
<div class="form-group">
<label class="form-label">Response caching</label>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
Off <b>(recommended)</b>
</label></div>
<div class="radio"><label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
Cache cacheable responses
</label></div>
<small class="field-help text-muted d-block">Cache upstream responses that declare themselves cacheable.</small>
</div>
<div class="form-group">
<label class="form-label">HSTS</label>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
Off
</label></div>
<div class="radio"><label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
Send Strict-Transport-Security
</label></div>
<small class="field-help text-muted d-block">Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working.</small>
</div>
</div>
<!-- Headers -->
<div class="tab-pane fade" id="hostTab-headers" role="tabpanel">
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each request sent to the target. One <code>Name: value</code> per line.</small>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="3" placeholder="Name: value, one per line"></textarea>
<small class="field-help text-muted d-block">Added to each response returned to the client.</small>
</div>
</div>
<!-- Access -->
<div class="tab-pane fade" id="hostTab-access" role="tabpanel">
<h6 class="text-muted">IP access</h6>
<div class="form-group">
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
<small class="field-help text-muted d-block">If non-empty, only these sources may connect (default-deny).</small>
</div>
<div class="form-group">
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
<small class="field-help text-muted d-block">These sources are always blocked (deny wins over allow).</small>
</div>
</div>
<!-- Authentication -->
<div class="tab-pane fade" id="hostTab-auth" role="tabpanel">
<p class="field-help text-muted">
Pick one authentication method for this host &mdash; basic auth and
SSO can't both be enabled, to avoid ambiguity about which one
actually protected a request. Choose "Off" for a public host.
</p>
<div class="form-group">
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-none" value="none" checked onchange="hostAuthModeChanged('none')">
Off (public)
</label></div>
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-basic" value="basic" onchange="hostAuthModeChanged('basic')">
Basic authentication
</label></div>
<div class="radio"><label>
<input type="radio" name="auth_mode" id="auth_mode-sso" value="sso" onchange="hostAuthModeChanged('sso')">
Single sign-on (SSO)
</label></div>
</div>
<!-- Actually-submitted fields; kept in sync with the radios above by
hostAuthModeChanged() so only one can be true at a time. -->
<input type="hidden" name="basicauth_enabled" id="basicauth_enabled-hidden" value="false">
<input type="hidden" name="sso_enabled" id="sso_enabled-hidden" value="false">
<div id="hostTab-auth-basicFields" style="display:none">
<hr>
<h6 class="text-muted">Basic authentication</h6>
<div class="form-group">
<label for="basicauth_realm" class="form-label">Realm</label>
<input type="text" name="basicauth_realm" class="form-control" value="Restricted" placeholder="Restricted" />
</div>
<div class="form-group">
<label for="basicauth_users" class="form-label">Users</label>
<textarea name="basicauth_users" class="form-control" rows="2" placeholder="username:password, one per line"></textarea>
<small class="field-help text-muted d-block">
Current: <span class="basicauth-current">none</span>.
Passwords are stored hashed and never shown here. Leave blank to keep
the current users; entering any lines replaces the whole list. To
manage individual users (delete / change password), use the table
below once the host has been saved.
</small>
</div>
</div>
<div id="hostTab-auth-ssoFields" style="display:none">
<hr>
<h6 class="text-muted">Single sign-on (SSO)</h6>
<p class="field-help text-muted">Gates the site behind the same identity provider the admin app uses. Empty allow-lists below mean any authenticated user is allowed.</p>
<div class="form-group">
<label for="sso_allow_users" class="form-label">Allowed users</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoUsers" placeholder="type to search users…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_users');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_users')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_users" class="form-control" rows="2" placeholder="one email/username per line; blank = any authenticated user"></textarea>
</div>
<div class="form-group">
<label for="sso_allow_groups" class="form-label">Allowed groups</label>
<div class="input-group mb-1">
<input type="text" class="form-control" list="hostSsoGroups" placeholder="type to search groups…"
onkeydown="if(event.key==='Enter'){event.preventDefault();allowListAdd(this,'sso_allow_groups');}">
<button type="button" class="btn btn-outline-secondary" onclick="allowListAdd(this.previousElementSibling,'sso_allow_groups')">
<i class="fa-solid fa-plus"></i> Add
</button>
</div>
<textarea name="sso_allow_groups" class="form-control" rows="2" placeholder="one group per line; blank = any authenticated user"></textarea>
</div>
</div>
<div id="hostTab-auth-basicUsersMgmt" style="display:none">
<hr>
<h6 class="text-muted">Manage basic-auth users</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead><tr><th>Username</th><th>New password</th><th></th></tr></thead>
<tbody id="basicAuthUserRows"></tbody>
</table>
</div>
</div>
</div>
</div>
<datalist id="hostSsoUsers"></datalist>
<datalist id="hostSsoGroups"></datalist>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fa-solid fa-ban"></i> Cancel
</button>
<button type="submit" form="hostForm" class="btn btn-success">
<i class="fa-solid fa-floppy-disk"></i>
<span id="hostModalSubmitText">Add host</span>
</button>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+12 -8
View File
@@ -4,14 +4,18 @@
// If we arrived from the OIDC callback with a token in the URL fragment,
// store it and forward on before doing anything else.
if(!app.auth.consumeTokenFragment()){
app.auth.isLoggedIn(function(error, isLoggedIn){
if(isLoggedIn){
app.auth.logInRedirect();
}else{
// Reveal the login card once we know the user is not logged in.
document.getElementById('login-card-row').style.display = '';
}
})
// The reveal below touches an element further down this page, so wait
// for the DOM — isLoggedIn can answer before the parser gets there.
$(document).ready(function(){
app.auth.isLoggedIn(function(error, isLoggedIn){
if(isLoggedIn){
app.auth.logInRedirect();
}else{
// Reveal the login card once we know the user is not logged in.
document.getElementById('login-card-row').style.display = '';
}
});
});
}
</script>
+76 -81
View File
@@ -40,9 +40,53 @@
}
}
function permissionAddOpen(){
app.modal.open({title: 'Add Permission', bodyHtml:
'<form action="permission/" onsubmit="formAJAX(this)" evalAJAX="app.modal.close();">'
+ '<div class="form-group">'
+ '<label class="control-label">Subject type</label>'
+ '<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">'
+ '<option value="user">User</option>'
+ '<option value="group">Group</option>'
+ '</select>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Subject (username or group)</label>'
+ '<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Scope</label>'
+ '<select class="form-control" name="scope">'
+ '<option value="domain">Domain</option>'
+ '<option value="global">Global</option>'
+ '</select>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Domain (for domain scope)</label>'
+ '<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />'
+ '<div class="field-hint text-muted">'
+ 'Wildcards: <code>*.example.com</code> matches one label, '
+ '<code>**.example.com</code> matches any depth (incl. the apex), '
+ '<code>**</code> matches every domain.'
+ '</div>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Role</label>'
+ '<select class="form-control" name="role">'
+ '<option value="viewer">Viewer (read)</option>'
+ '<option value="manager">Manager (full over domain)</option>'
+ '<option value="admin">Admin (global only)</option>'
+ '</select>'
+ '</div>'
+ '<hr />'
+ '<button type="submit" class="btn btn-info">Add Permission</button>'
+ '</form>',
});
}
function removePermission(id){
app.permission.remove(id, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger');
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
// The websocket echo removes the row; drop it locally too for snappiness.
$.scope.Permission.remove(id);
});
@@ -51,7 +95,7 @@
$(document).ready(function(){
// Existing permissions.
app.permission.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.Permission.$this, 'danger');
if(error) return app.messages.action(error, $.scope.Permission.$this, 'danger');
for(let p of data.results) $.scope.Permission.push(p);
});
@@ -66,6 +110,7 @@
app.subscribe(/^model:Permission:create/, function(data){
$.scope.Permission.remove(data.id);
$.scope.Permission.unshift(data);
setTimeout(function(){ app.util.revealItem($('#permission-row-' + data.id)); }, 100);
});
app.subscribe(/^model:Permission:remove/, function(data, topic){
$.scope.Permission.remove(topic.split(':')[3]);
@@ -73,66 +118,12 @@
});
</script>
<div class="container mt-4">
<datalist id="subjectUsers"></datalist>
<datalist id="subjectGroups"></datalist>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-user-shield"></i>
</span>
<span class="card-title">Add Permission</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="permission/" onsubmit="formAJAX(this)">
<div class="form-group">
<label class="control-label">Subject type</label>
<select class="form-control" name="subjectType" onchange="subjectTypeChanged(this)">
<option value="user">User</option>
<option value="group">Group</option>
</select>
</div>
<div class="form-group">
<label class="control-label">Subject (username or group)</label>
<input type="text" class="form-control" name="subject" list="subjectUsers" placeholder="alice" autocomplete="off" />
</div>
<div class="form-group">
<label class="control-label">Scope</label>
<select class="form-control" name="scope">
<option value="domain">Domain</option>
<option value="global">Global</option>
</select>
</div>
<div class="form-group">
<label class="control-label">Domain (for domain scope)</label>
<input type="text" class="form-control" name="domain" placeholder="example.com" autocomplete="off" />
<div class="field-hint text-muted">
Wildcards: <code>*.example.com</code> matches one label,
<code>**.example.com</code> matches any depth (incl. the apex),
<code>**</code> matches every domain.
</div>
</div>
<div class="form-group">
<label class="control-label">Role</label>
<select class="form-control" name="role">
<option value="viewer">Viewer (read)</option>
<option value="manager">Manager (full over domain)</option>
<option value="admin">Admin (global only)</option>
</select>
</div>
<hr />
<button type="submit" class="btn btn-info">Add Permission</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header text-center">
@@ -140,37 +131,41 @@
<i class="fa-solid fa-list-check"></i>
</span>
<span class="card-title">Permissions</span>
<span class="float-end">
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button type="button" class="btn btn-sm btn-success" onclick="permissionAddOpen()">
<i class="fa-solid fa-user-shield"></i>
Add Permission
</button>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Type</th>
<th>Subject</th>
<th>Scope</th>
<th>Domain</th>
<th>Role</th>
<th>Delete</th>
</thead>
<tbody>
<tr jq-repeat="Permission" jq-repeat-index="id" style="display:none">
<td class="align-middle">{{ subjectType }}</td>
<td class="align-middle">{{ subject }}</td>
<td class="align-middle">{{ scope }}</td>
<td class="align-middle">{{ domain }}</td>
<td class="align-middle">{{ role }}</td>
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removePermission('{{id}}')">
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2 g-3" id="permission-cards">
<div class="col" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
<div class="card shadow-sm h-100">
<div class="card-body">
<h6 class="mb-2">
<span class="badge text-bg-secondary">{{ subjectType }}</span>
{{ subject }}
</h6>
<dl class="row mb-2 small">
<dt class="col-4">Scope</dt><dd class="col-8">{{ scope }}</dd>
<dt class="col-4">Domain</dt><dd class="col-8">{{ domain }}</dd>
<dt class="col-4">Role</dt><dd class="col-8">{{ role }}</dd>
</dl>
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
<i class="fa-solid fa-trash"></i>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+153 -92
View File
@@ -63,12 +63,13 @@
$(document).ready(function(){
app.api.get('user/me', function(error, data){
if(error) return app.util.actionMessage(error, $('#profile-card'), 'danger');
if(error) return app.messages.action(error, $('#profile-card'), 'danger');
renderProfile(data);
});
});
</script>
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-lg" id="profile-card">
@@ -102,43 +103,33 @@
</div>
</div>
<!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-key"></i> API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>
<div class="input-group">
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" onclick="copyToken()" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer &lt;token&gt;</code></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var tokensById = {};
function showSecret(secret){
document.getElementById('secretValue').value = secret;
secretModal.show();
// Shared "reveal secret once" display -- same pattern as jump-host's
// showToken(), which sso-manager-node also uses.
function showToken(title, token){
app.modal.open({title: title, bodyHtml:
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.util.escapeHtml(token) + '">'
+ '<button class="btn btn-outline-secondary" onclick="copyFieldValue(\'#revealed-token\')" title="Copy"><i class="fa-solid fa-copy"></i></button></div>'
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>'
});
}
function copyToken(){
var el = document.getElementById('secretValue');
el.select(); el.setSelectionRange(0, 99999);
try { document.execCommand('copy'); } catch(_){}
// Not the checkmark-flash technique some of this codebase's other copy
// buttons use -- FontAwesome replaces <i> icons with inline <svg>, so
// swapping the <i>'s class silently no-ops. A toast doesn't have that
// problem.
function copyFieldValue(sel){
var $el = $(sel);
var text = $el.val();
if(!text) return;
navigator.clipboard.writeText(text).then(function(){
app.messages.toast('Copied to clipboard', 'success');
}, function(){
app.messages.toast('Could not copy — select and copy manually', 'danger');
});
}
function fmtTime(ms){
@@ -170,92 +161,162 @@
function tableAJAX(){
app.apiToken.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.apiTokenCard.$this, 'danger');
if(error) return app.messages.action(error, $.scope.apiTokenCard.$this, 'danger');
var tokens = data.results || [];
$.scope.apiTokenCard.empty();
(data.results || []).forEach(function(token){
tokens.forEach(function(token){
$.scope.apiTokenCard.push(processToken(token));
});
$('#api-tokens-empty').toggle(tokens.length === 0);
});
}
function revokeToken(id, name, btn){
if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return;
async function revokeToken(id, name, btn){
const ok = await app.messages.confirm('Revoke API token "' + name + '"? It stops working immediately.', $(btn).closest('.card'), 'danger');
if(!ok) return;
app.apiToken.remove({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger');
if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
$.scope.apiTokenCard.remove('id', id);
});
}
function rotateToken(id, name, btn){
if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return;
async function rotateToken(id, name, btn){
const ok = await app.messages.confirm('Rotate API token "' + name + '"? The old token stops working immediately.', $(btn).closest('.card'), 'warning');
if(!ok) return;
app.apiToken.rotate({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger');
showSecret(data.token);
if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
showToken('API Token Rotated', data.token);
tableAJAX();
});
}
// Create is a native <form>+formAJAX submission (matching this app's own
// hostModal convention) rather than a JS-built payload -- the form now
// lives inside app.modal's body, rebuilt fresh on every open(), so
// .actionMessage must be a descendant of the form (not a sibling, as the
// old static create-form card had it) for formAJAX's error/success
// targeting to resolve correctly (it falls back to searching descendants
// of the form once app.modal's card-less .modal-content fails the
// closest('div.card') check).
function createApiToken(){
var $body = app.modal.open({
title: 'New API Token',
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>'
// Deliberately does NOT call app.modal.close() before showToken() --
// app.modal is a singleton, and close() immediately followed by
// open() in the same synchronous tick collides with Bootstrap's
// hide-transition guard (show() silently no-ops while _isTransitioning
// is still true from the just-started hide()). open() alone already
// overwrites the (already-visible) modal's content in place.
+ '<form id="newTokenForm" action="api-token/" method="post" onsubmit="formAJAX(this)" evalAJAX="showToken(\'API Token Created\', data.token); tableAJAX();">'
+ '<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control" name="name" placeholder="CI host sync" required>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control" name="description" placeholder="Used by the nightly sync job">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>'
+ '<input type="number" class="form-control" name="expires_in_days" value="0" min="0">'
+ '</div>'
+ '</form>',
footer: {
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
+ '<button type="submit" form="newTokenForm" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>',
},
});
$body.find('[name=name]').focus();
}
function editToken(id){
var t = tokensById[id]; if(!t) return;
app.modal.open({
title: 'Edit Token',
bodyHtml:
'<input type="hidden" id="edit-token-id" value="' + app.util.escapeHtml(id) + '">'
+ '<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control" id="edit-token-name" value="' + app.util.escapeHtml(t.name || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control" id="edit-token-description" value="' + app.util.escapeHtml(t.description || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days, blank = keep as-is, 0 = never)</label>'
+ '<input type="number" class="form-control" id="edit-token-days" min="0">'
+ '</div>',
footer: {
metaHtml: 'Created by ' + app.util.escapeHtml(t.created_by || '—') + ' on ' + fmtTime(t.created_on),
buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
},
});
}
function saveEditToken(){
var payload = {
id: $('#edit-token-id').val(),
name: $('#edit-token-name').val(),
description: $('#edit-token-description').val(),
expires_in_days: $('#edit-token-days').val(),
};
app.apiToken.update(payload, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to update token', app.modal.body(), 'danger');
app.modal.close();
tableAJAX();
});
}
$(document).ready(function(){
tableAJAX();
// After a successful create, show the raw token once + refresh the list.
$('form[action="api-token/"]').attr('evalAJAX',
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
);
});
</script>
<div class="row mt-3">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<p class="text-muted small">A personal access token lets scripts and services call the proxy management API as you, with your permissions. Treat it like a password.</p>
<form action="api-token/" method="post" onsubmit="formAJAX(this)">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" placeholder="CI host sync" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" name="description" placeholder="Used by the nightly sync job">
</div>
<div class="mb-3">
<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>
<input type="number" class="form-control" name="expires_in_days" value="0" min="0">
</div>
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>
</form>
</div>
</div>
</div>
<div class="row mt-3 justify-content-center">
<div class="col-md-8">
<div class="card-header actionMessage" style="display:none"></div>
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
<div class="card-header">
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
<small class="text-muted font-monospace">{{ id_short }}</small>
<div class="card shadow-lg">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
<span>
<a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<p class="text-muted small px-3 pt-3 mb-0">A personal access token lets scripts and services call the proxy management API as you, with your permissions. Treat it like a password.</p>
<div class="card-body">
{{ #description }}<p>{{ description }}</p>{{ /description }}
<dl class="row mb-0">
<dt class="col-sm-3">Token ID</dt>
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">{{{ created_display }}}</dd>
<dt class="col-sm-3">Last used</dt>
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{ expires_display }}}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
<div class="card-header">
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
<small class="text-muted font-monospace">{{ id_short }}</small>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
{{ #description }}<p>{{ description }}</p>{{ /description }}
<dl class="row mb-0">
<dt class="col-sm-3">Token ID</dt>
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">{{{ created_display }}}</dd>
<dt class="col-sm-3">Last used</dt>
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{ expires_display }}}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+63 -55
View File
@@ -4,8 +4,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title><%- name %> <%- title %></title>
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
via app.locals in app.js). Edit all three copies together. -->
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
<!-- CSS are placed here -->
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
@@ -14,63 +17,52 @@
<!-- Scripts are placed here -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> -->
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> -->
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
<script type="text/javascript" src='/static/lib/js/val.js'></script>
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script>
</head>
<body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav top-nav">
<li class="nav-item">
<a class="nav-link active" href="/hosts">
<i class="fa-solid fa-network-wired"></i>
Hosts
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/dns"><i class="fa-solid fa-record-vinyl"></i>
DNS
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
Users
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/permissions"><i class="fa-solid fa-user-shield"></i>
Permissions
</a>
</li>
<li class="nav-item nav-admin" style="display: none;">
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-gear"></i>
Groups
<%# Items gated on a group start hidden (.group-required) and are
revealed by app-base.js for the groups the user is in. %>
<% for(const item of ui.nav){ %>
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
<%- item.label %>
</a>
</li>
<% } %>
</ul>
<div class="form-inline mt-2 mt-md-0">
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
<% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<% } else { %>
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</span>
<% } %>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-out"></i>
<i class="fas fa-sign-in"></i>
Login
</a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
<i class="fas fa-sign-out"></i>
Log Out
</button>
@@ -78,6 +70,7 @@
</div>
</nav>
<% if(ui.updateCheck){ %>
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
@@ -89,19 +82,43 @@
</div>
<script type="text/javascript">
// --sw-content-offset tracks the same height as #spa-shell's margin-top
// (fixed navbar, plus the update banner while it's shown), so any
// in-page sticky element (e.g. a sticky search/sort bar) can offset
// itself below both fixed elements via `top: var(--sw-content-offset)`
// instead of colliding with them at the viewport's true top:0.
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
let offset = $nav.outerHeight() + $banner.outerHeight();
$('#spa-shell').css('margin-top', offset + 'px');
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
sessionStorage.setItem('update-banner-dismissed', '1');
}
function checkForUpdate(){
if(sessionStorage.getItem('update-banner-dismissed')) return;
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
</script>
<% } %>
<script type="text/javascript">
$(document).ready(function(){
// Set the correct link to active in the top nav bar
@@ -113,34 +130,24 @@
}
})
// Set the correct login/logout button, and reveal admin-only nav
// items for global admins.
app.auth.isLoggedIn(function(error, data){
if(data){
// Set the correct login/logout button, and reveal the current user's
// name once we know who they are. Group-gated nav items are revealed
// by app-base.js off the same cached user/me.
app.auth.isLoggedIn(function(error, me){
if(me){
$('#cl-logout-button').show();
if(data.username){
$('#cl-username-text').text(data.username);
let username = me.uid || me.username;
if(username){
$('#cl-username-text').text(username);
$('#cl-username').css('display', '');
}
<% if(ui.updateCheck){ %>
if(me.isAdmin) checkForUpdate();
<% } %>
}else{
$('#cl-login-button').show();
}
if(data && data.isAdmin){
$('.nav-admin').css('display', '');
if(!sessionStorage.getItem('update-banner-dismissed')){
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of the proxy is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
}
});
});
@@ -149,3 +156,4 @@
<!-- Container -->
<div id="spa-shell" class="container-fluid">
<div class="actionMessage" style="display:none;"></div>
+68 -68
View File
@@ -20,11 +20,16 @@
<script type="text/javascript">
function processUser(user){
user.isExternal = user.backing === 'oidc';
return user;
}
function populateUsers(actionMessage){
app.user.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.users.$this, 'danger');
if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
for(let user of data.results){
$.scope.users.push(user);
$.scope.users.push(processUser(user));
}
$.scope.users.__put = function($el, item, list){
$el.addClass('bg-success');
@@ -37,11 +42,37 @@
function removeUser(username){
app.user.remove({username: username}, function(error, data){
if(error) return app.util.actionMessage(error, $.scope.users.$this, 'danger');
if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
$.scope.users.remove(username);
});
}
function userAddOpen(){
app.modal.open({title: 'Add New User', bodyHtml:
'<form action="user/" onsubmit="formAJAX(this)" evalAJAX="'
+ '$.scope.users.splice(0, 0, processUser(data));'
+ 'setTimeout(function(){ app.util.revealItem($(\'#user-row-\' + data.username)); }, 100);'
+ 'app.modal.close();'
+ '">'
+ '<input type="hidden" class="form-control" name="delete" value="false" />'
+ '<div class="form-group">'
+ '<label class="control-label">User-name</label>'
+ '<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Password</label>'
+ '<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Again</label>'
+ '<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>'
+ '</div>'
+ '<hr />'
+ '<button type="submit" class="btn btn-info">Add</button>'
+ '</form>',
});
}
$(document).ready(function(){
populateUsers(); //populate the table
@@ -54,49 +85,9 @@
});
</script>
<div class="container mt-4">
<div class="row" style="display:none">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header text-center">
<span class="card-icon float-start">
<i class="fa-solid fa-user-plus"></i>
</span>
<span class="card-title">
Add New User
</span>
<span class="float-end">
<i class="fa-solid fa-circle-minus"></i>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="user/" onsubmit="formAJAX(this)" evalAJAX="
$.scope.users.splice(0, 0, data);
">
<input type="hidden" class="form-control" name="delete" value="false" />
<div class="form-group">
<label class="control-label">User-name</label>
<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />
</div>
<div class="form-group">
<label class="control-label">Password</label>
<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>
</div>
<div class="form-group">
<label class="control-label">Again</label>
<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>
</div>
<hr />
<button type="submit" class="btn btn-info">
Add
</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header text-center">
@@ -107,42 +98,51 @@
User List
</span>
<span class="float-end">
<i class="fa-solid fa-circle-minus"></i>
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button type="button" class="btn btn-sm btn-success" onclick="userAddOpen()">
<i class="fa-solid fa-user-plus"></i>
Add User
</button>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Name</th>
<th>Password</th>
<th>Delete</th>
</thead>
<tbody>
<tr jq-repeat="users" jq-repeat-index="username" style="display:none" >
<td class="align-middle">
{{ username }}
</td>
<td>
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2 g-3" id="user-cards">
<div class="col" jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
<div class="card shadow-sm h-100">
<div class="card-body">
<h6 class="d-flex align-items-center mb-2">
<i class="fa-solid fa-user me-2"></i>
{{ username }}
{{#isExternal}}
<span class="badge text-bg-secondary ms-2" title="Provisioned via SSO login; no local password to manage here.">
<i class="fa-solid fa-cloud"></i> External (SSO)
</span>
{{/isExternal}}
</h6>
<form class="input-group" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
<input type="password" name="password" class="form-control" placeholder="Change {{ username }} password" aria-label="Update password">
{{^isExternal}}
<form class="input-group input-group-sm mb-2" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
<input type="password" name="password" class="form-control" placeholder="Change password" aria-label="Update password">
<button class="btn btn-warning" type="submit">Change</button>
</form>
{{/isExternal}}
{{#isExternal}}
<p class="text-muted small mb-2">Authenticates via SSO -- cannot be edited here.</p>
{{/isExternal}}
</td>
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removeUser('{{username}}')">
<button type="button" class="btn btn-sm btn-danger" onclick="removeUser('{{username}}')">
<i class="fa-solid fa-user-slash"></i>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+49 -3
View File
@@ -9,19 +9,29 @@
# update is just "sync the repo + reload" -- the files under /etc always track
# 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
# hard-reset to origin/$BRANCH on every run, so the box deterministically mirrors
# 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
# Never block on an interactive git credential prompt in CI.
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_DIR="${REPO_DIR:-/var/www/proxy}"
REPO_DIR="${REPO_DIR:-/opt/theta42/proxy}"
BRANCH="${BRANCH:-master}"
NODE_MAJOR=22
SECRETS_FILE="${SECRETS_FILE:-/etc/proxy/secrets.js}"
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (try: sudo $0)" >&2
@@ -34,6 +44,19 @@ link(){
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"
apt-get update
apt-get install -y --no-install-recommends \
@@ -100,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
@@ -134,6 +158,20 @@ else
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
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"
install -d /etc/openresty/sites-enabled /var/log/nginx
link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf
@@ -162,4 +200,12 @@ else
exit 1
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"
+5 -1
View File
@@ -1,5 +1,9 @@
listen 443 ssl http2;
listen 443 ssl;
listen 4443 ssl;
# The "http2" listen parameter is deprecated since nginx 1.25.1 in favor of
# this standalone directive, which applies to every "listen ... ssl" in the
# server block (both 443 and 4443 here).
http2 on;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
+39
View File
@@ -62,6 +62,7 @@ function M.get(ngx, domain, targetInfo)
local json = require "cjson"
local redis = require "resty.redis"
local roundrobin = require "resty.roundrobin"
if not domain then
return nil, 499
@@ -95,6 +96,44 @@ function M.get(ngx, domain, targetInfo)
return nil, 406
end
-- Load balancing
local target_list = {}
table.insert(target_list, res["ip"] .. ":" .. tostring(res["targetPort"]))
if res["targets"] and res["targets"] ~= "" and res["targets"] ~= "[]" then
local decodeOk, decodedTargets = pcall(json.decode, res["targets"])
if decodeOk and type(decodedTargets) == "table" then
for _, t in ipairs(decodedTargets) do
table.insert(target_list, t)
end
end
end
if #target_list > 1 then
if not M.host_balancers then M.host_balancers = {} end
local cache_key = domain .. "_" .. (res["updated_on"] or "0")
if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then
local nodes = {}
for _, t in ipairs(target_list) do
nodes[t] = 1
end
local b = roundrobin:new(nodes)
M.host_balancers[domain] = { b = b, key = cache_key }
end
local peer = M.host_balancers[domain].b:find()
if peer then
local colon = peer:find(":")
if colon then
res["ip"] = peer:sub(1, colon - 1)
res["targetPort"] = peer:sub(colon + 1)
else
res["ip"] = peer
end
end
end
ngx.ctx.targetInfo = res
-- Remember which host this target was resolved for, so the reuse guard at
-- the top can tell a genuine cache hit from a coalesced request for a
+3 -2
View File
@@ -8,9 +8,10 @@ Type=simple
Restart=always
RestartSec=1
User=root
WorkingDirectory=/var/www/proxy/nodejs
WorkingDirectory=/opt/theta42/proxy/nodejs
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]
WantedBy=multi-user.target
+15 -10
View File
@@ -6,13 +6,16 @@
// direct LDAP client for user lookups. This file supplies that wiring.
//
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. No app_* env
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points the
// 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
// 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
// here override conf/base.js and win over <environment>.js.
// Bare-metal: ops/install.sh seeds this file at /etc/proxy/secrets.js on first
// 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
// theta-env orchestrator (setup.sh) and ignored by the app.
@@ -64,12 +67,14 @@ module.exports = {
adminUsers: ['proxyadmin'],
groupRoleMap: {},
// Optional: the local anti-lockout admin's initial password, used
// ONLY the first time that account is created. Leave unset and it
// defaults to the username itself ("proxyadmin2") — fine for a quick
// local test, but change it (or set this) before exposing the proxy
// publicly. Once the account exists, this key is never read again;
// change the password via the app itself (or delete the Redis user
// to force it to be re-bootstrapped with a new value here).
// ONLY the first time that account is created. Leave unset and a
// random password is generated and printed to the container log on
// first boot — fine for a quick local test if you copy it from the
// log right away, but set this (or change the password afterward)
// before exposing the proxy publicly. Once the account exists, this
// key is never read again; change the password via the app itself
// (or delete the Redis user to force it to be re-bootstrapped with a
// new value here).
// localAdminPass: 'change-me',
},