Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60552c6246 | |||
| 25419382bd | |||
| 229f5b9760 | |||
| 3a078e3918 | |||
| 9029de825c | |||
| b50a1de76f | |||
| c419249e98 | |||
| 4aa994121a | |||
| 2e92f58750 | |||
| aeccbcbbe9 | |||
| 15b154fc8d | |||
| a44d7ef7ab | |||
| 144efdb5dd | |||
| b54a738524 | |||
| 8bf963f48b | |||
| 30835baaeb | |||
| 2319ac3a0e | |||
| 8c0eaf0d07 | |||
| 8c81128235 | |||
| 4f23d301bf | |||
| be666f5b2f | |||
| 2bfba93e00 | |||
| 8cee583da3 | |||
| b5fab6c91a | |||
| c7ec65e0d9 | |||
| 3f2ef57c78 | |||
| c0e04d1a56 | |||
| 130d4a2c9b | |||
| 3b729295b0 | |||
| 44c2ec3fdd | |||
| e9032ec4fb | |||
| a6e745cfc1 | |||
| 8350ab9b57 | |||
| 001fe84b99 | |||
| e05ec1d2ad | |||
| 652df4f3cf | |||
| 9370734084 | |||
| 58204da3cb | |||
| 2d7eadec60 | |||
| 0b9a1aabfd | |||
| 36c3f7a881 | |||
| 3686a5ddb8 | |||
| ce013d7e31 | |||
| f6bc38eef2 | |||
| 0a659428dd | |||
| e770bbb41f | |||
| 90619dd4ff | |||
| 75045b4b33 | |||
| 2e959f7c48 | |||
| fcc09d1e4b | |||
| a83a5fd39a | |||
| 3a307c1563 | |||
| f055d4f567 | |||
| f4efdfb957 | |||
| 60dbfe5b9b | |||
| ecdced16fc | |||
| aecf275031 | |||
| 3f46a807e5 | |||
| bb1b84b56d | |||
| 93cf034e61 | |||
| 2102b309de | |||
| c3fe25335f | |||
| 4321826dc8 | |||
| 21e295615b | |||
| 7452ccd655 | |||
| 5acea6fcc2 | |||
| fcd97b12aa | |||
| 289a9587d6 | |||
| 6ede072213 | |||
| bdaba513a7 | |||
| d1dd40d60a | |||
| c0e1aa666e | |||
| f8d620f4d3 | |||
| 899c4d91d6 | |||
| 0f268fdcae | |||
| da0ed0e2ad | |||
| f0eadbc2d7 | |||
| a02ca4d3e7 | |||
| 2ff2bf9ea7 | |||
| 355a9d68e5 | |||
| 11f6c4df36 | |||
| a567bf6c51 |
@@ -1,3 +1,6 @@
|
|||||||
|
# v1.13.2
|
||||||
|
- chore: Update CI pipeline integration
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project are documented here. Format loosely
|
All notable changes to this project are documented here. Format loosely
|
||||||
@@ -6,6 +9,193 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.1.13] - 2026-07-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -14,24 +204,24 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
### Fixed
|
### Fixed
|
||||||
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL (by registered slug, falling back to the doc's real filename), the same way image paths already were.
|
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL (by registered slug, falling back to the doc's real filename), the same way image paths already were.
|
||||||
|
|
||||||
Bumps to v1.1.13.
|
## [1.1.12] - 2026-07-17
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- The host edit form's "Parent Wildcard" option stayed greyed out even when a valid wildcard actually existed for that host, so an already-created host could never be switched onto one from the edit modal (only brand-new hosts, via the field's `keyup` handler, ever saw it become available). The underlying `/host/lookup/:item` check also had the same self-match issue as the recently-fixed backend bug: it resolved an already-existing host to its own record instead of a sibling wildcard. Added a dedicated `/host/wildcard-parent/:item` endpoint that checks both directions, and the edit form now actually runs the check when it opens.
|
- The host edit form's "Parent Wildcard" option stayed greyed out even when a valid wildcard actually existed for that host, so an already-created host could never be switched onto one from the edit modal (only brand-new hosts, via the field's `keyup` handler, ever saw it become available). The underlying `/host/lookup/:item` check also had the same self-match issue as the recently-fixed backend bug: it resolved an already-existing host to its own record instead of a sibling wildcard. Added a dedicated `/host/wildcard-parent/:item` endpoint that checks both directions, and the edit form now actually runs the check when it opens.
|
||||||
- Fixed an nginx startup warning: `the "listen ... http2" directive is deprecated, use the "http2" directive instead`. Migrated to the standalone `http2 on;` directive (nginx 1.25.1+).
|
- Fixed an nginx startup warning: `the "listen ... http2" directive is deprecated, use the "http2" directive instead`. Migrated to the standalone `http2 on;` directive (nginx 1.25.1+).
|
||||||
|
|
||||||
Bumps to v1.1.12.
|
## [1.1.11] - 2026-07-17
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Moved the help (❓) link out of the global header and onto each relevant card individually (Proxy List, Add/Edit host, Add DNS Provider, Dynamic A Records, Add New User, User List, Add Permission, Permissions, Add Group) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
|
- Moved the help (❓) link out of the global header and onto each relevant card individually (Proxy List, Add/Edit host, Add DNS Provider, Dynamic A Records, Add New User, User List, Add Permission, Permissions, Add Group) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
|
||||||
|
|
||||||
Bumps to v1.1.11.
|
## [1.1.10] - 2026-07-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
|
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
|
||||||
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
|
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
|
||||||
|
|
||||||
Bumps to v1.1.10.
|
## [1.1.9] - 2026-07-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- The host list now shows who created each host, and when.
|
- The host list now shows who created each host, and when.
|
||||||
@@ -42,7 +232,7 @@ Bumps to v1.1.10.
|
|||||||
- The host create/edit modal's tabs could overflow awkwardly on narrow (mobile) screens — they now scroll horizontally instead.
|
- The host create/edit modal's tabs could overflow awkwardly on narrow (mobile) screens — they now scroll horizontally instead.
|
||||||
- Fixed a bug in the vendored `model-redis` library's record-rename path: renaming a record's primary key while another `always`-type field (e.g. `updated_on`) is defined earlier in the schema left a stray, incomplete hash behind under the old key, making that name permanently unavailable for reuse. Worked around in `Host.prototype.update()`.
|
- Fixed a bug in the vendored `model-redis` library's record-rename path: renaming a record's primary key while another `always`-type field (e.g. `updated_on`) is defined earlier in the schema left a stray, incomplete hash behind under the old key, making that name permanently unavailable for reuse. Worked around in `Host.prototype.update()`.
|
||||||
|
|
||||||
Bumps to v1.1.9.
|
## [1.1.8] - 2026-07-17
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Couldn't attach an existing host to a parent wildcard.** The host edit form's "Parent Wildcard" option submitted correctly, but `Host.prototype.update()` had no `challengeType` handling at all (only `Host.create()` did) — selecting it and saving silently did nothing. Added the same wildcard-parent lookup to `update()`.
|
- **Couldn't attach an existing host to a parent wildcard.** The host edit form's "Parent Wildcard" option submitted correctly, but `Host.prototype.update()` had no `challengeType` handling at all (only `Host.create()` did) — selecting it and saving silently did nothing. Added the same wildcard-parent lookup to `update()`.
|
||||||
@@ -50,6 +240,8 @@ Bumps to v1.1.9.
|
|||||||
|
|
||||||
Both required a corrected lookup: attaching an *existing* host (which already has its own tree leaf) needed a new `Host.lookUpWildcardParent()` that checks the sibling wildcard slot instead of resolving to the host's own record.
|
Both required a corrected lookup: attaching an *existing* host (which already has its own tree leaf) needed a new `Host.lookUpWildcardParent()` that checks the sibling wildcard slot instead of resolving to the host's own record.
|
||||||
|
|
||||||
|
## [1.1.7] - 2026-07-16
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout.
|
- Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout.
|
||||||
|
|
||||||
@@ -96,7 +288,11 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
|||||||
- Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention.
|
- Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention.
|
||||||
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
|
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.13...HEAD
|
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.17...HEAD
|
||||||
|
[1.1.17]: https://github.com/theta42/proxy/compare/v1.1.16...v1.1.17
|
||||||
|
[1.1.16]: https://github.com/theta42/proxy/compare/v1.1.15...v1.1.16
|
||||||
|
[1.1.15]: https://github.com/theta42/proxy/compare/v1.1.14...v1.1.15
|
||||||
|
[1.1.14]: https://github.com/theta42/proxy/compare/v1.1.13...v1.1.14
|
||||||
[1.1.13]: https://github.com/theta42/proxy/compare/v1.1.12...v1.1.13
|
[1.1.13]: https://github.com/theta42/proxy/compare/v1.1.12...v1.1.13
|
||||||
[1.1.12]: https://github.com/theta42/proxy/compare/v1.1.11...v1.1.12
|
[1.1.12]: https://github.com/theta42/proxy/compare/v1.1.11...v1.1.12
|
||||||
[1.1.11]: https://github.com/theta42/proxy/compare/v1.1.10...v1.1.11
|
[1.1.11]: https://github.com/theta42/proxy/compare/v1.1.10...v1.1.11
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ $EDITOR config/proxy-secrets.js # set oidc.clientId/clientSecret, ldap.bindP
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-entrypoint.sh` symlinks `/config/proxy-secrets.js` → `/app/conf/secrets.js`
|
`docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so
|
||||||
so `@simpleworkjs/conf` reads it. No `app_*` env is passed — `app_*` env would
|
`@simpleworkjs/conf` reads it directly. No `app_*` env is passed — `app_*` env
|
||||||
override the file (env beats secrets.js in `@simpleworkjs/conf`), so the file is
|
would override the file (env beats secrets.js in `@simpleworkjs/conf`), so the
|
||||||
kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` / `NODE_PORT` are
|
file is kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` /
|
||||||
OpenResty-runtime / process env, not `app_*` config, so they stay in the compose.
|
`NODE_PORT` are OpenResty-runtime / process env, not `app_*` config, so they
|
||||||
|
stay in the compose.
|
||||||
|
|
||||||
> Running the unified `theta-env` stack? Its `setup.sh` generates
|
> Running the unified `theta-env` stack? Its `setup.sh` generates
|
||||||
> `./config/proxy-secrets.js` (+ `./config/sso-secrets.js`) for you and
|
> `./config/proxy-secrets.js` (+ `./config/sso-secrets.js`) for you and
|
||||||
@@ -227,16 +228,25 @@ docker compose logs --tail=200 --since=10m proxy # recent context
|
|||||||
|
|
||||||
`ops/install.sh` is an idempotent installer: it installs Node.js 22.x, OpenResty
|
`ops/install.sh` is an idempotent installer: it installs Node.js 22.x, OpenResty
|
||||||
(from openresty.org), Lua modules (luarocks), Redis, force-syncs the repo to
|
(from openresty.org), Lua modules (luarocks), Redis, force-syncs the repo to
|
||||||
`/var/www/proxy`, symlinks the OpenResty + systemd config from the repo, and
|
`/opt/theta42/proxy`, symlinks the OpenResty + systemd config from the repo, and
|
||||||
starts `proxy.service`. Re-run it to update.
|
starts `proxy.service`. Re-run it to update — it prints the version you're
|
||||||
|
updating from and to (or "Already up to date" if there's nothing new).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
or, if you already have the repo checked out:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./ops/install.sh
|
sudo ./ops/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Configuration is file-based: write `nodejs/conf/secrets.js` with the OIDC +
|
Configuration is file-based: on first run the installer seeds
|
||||||
LDAP values (see `nodejs/conf/base.js` for the shape), then
|
`/etc/proxy/secrets.js` from `secrets.js.example` (placeholders you must fill
|
||||||
`sudo systemctl restart proxy`.
|
in — OIDC + LDAP values, see `nodejs/conf/base.js` for the shape). Edit it,
|
||||||
|
then `sudo systemctl restart proxy`. Later runs never touch an existing
|
||||||
|
secrets file.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ RUN apt-get update \
|
|||||||
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
||||||
RUN luarocks install lua-resty-auto-ssl \
|
RUN luarocks install lua-resty-auto-ssl \
|
||||||
&& luarocks install luasocket \
|
&& luarocks install luasocket \
|
||||||
|
&& luarocks install lua-resty-balancer \
|
||||||
&& luarocks install lua-resty-ipmatcher
|
&& luarocks install lua-resty-ipmatcher
|
||||||
|
|
||||||
# ── Node app ─────────────────────────────────────────────────────────────────
|
# ── Node app ─────────────────────────────────────────────────────────────────
|
||||||
@@ -114,8 +115,8 @@ COPY nodejs/api.md ./api.md
|
|||||||
# docs/ mirrors the repo's own top-level docs/ folder.
|
# docs/ mirrors the repo's own top-level docs/ folder.
|
||||||
COPY README.md /README.md
|
COPY README.md /README.md
|
||||||
COPY CHANGELOG.md /CHANGELOG.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).
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) <year> <copyright holders>
|
Copyright (c) 2026 theta42
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ management once basic auth is enabled:
|
|||||||
|
|
||||||
[](docs/images/host-auth-basic.png)
|
[](docs/images/host-auth-basic.png)
|
||||||
|
|
||||||
|
Multiple backend targets per host, load balanced round-robin:
|
||||||
|
|
||||||
|
[](docs/images/load-balancing.png)
|
||||||
|
|
||||||
## Why this over the alternatives
|
## Why this over the alternatives
|
||||||
|
|
||||||
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
|
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
|
||||||
@@ -51,6 +55,7 @@ provider + LDAP directory you already run.
|
|||||||
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
|
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
|
||||||
- Wildcard SSL certificate support with automatic renewal
|
- Wildcard SSL certificate support with automatic renewal
|
||||||
- Dynamic host routing with wildcard domain matching (*, **)
|
- Dynamic host routing with wildcard domain matching (*, **)
|
||||||
|
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||||
- Web-based management interface
|
- Web-based management interface
|
||||||
- RESTful API for automation
|
- RESTful API for automation
|
||||||
- **OIDC login** — the proxy is an OpenID Connect client of an external SSO
|
- **OIDC login** — the proxy is an OpenID Connect client of an external SSO
|
||||||
@@ -127,10 +132,15 @@ This installer will:
|
|||||||
- Install and configure Redis
|
- Install and configure Redis
|
||||||
- Set up SSL fallback certificates
|
- Set up SSL fallback certificates
|
||||||
- Install Lua dependencies (lua-resty-auto-ssl, luasocket)
|
- Install Lua dependencies (lua-resty-auto-ssl, luasocket)
|
||||||
- Clone and install the proxy application
|
- Clone/update the proxy application at `/opt/theta42/proxy`
|
||||||
|
- Seed `/etc/proxy/secrets.js` on first run (edit it, then re-run or `systemctl restart proxy`)
|
||||||
- Configure systemd service
|
- Configure systemd service
|
||||||
- Start the proxy service
|
- Start the proxy service
|
||||||
|
|
||||||
|
It's idempotent and safe to re-run — re-running it updates the app in place and
|
||||||
|
prints the version you're updating from and to (e.g. `Updated v1.1.13 ->
|
||||||
|
v1.1.14`), or `Already up to date` if there's nothing new.
|
||||||
|
|
||||||
## Logs (Docker)
|
## Logs (Docker)
|
||||||
|
|
||||||
The all-in-one image runs OpenResty in the foreground and the Node app in the
|
The all-in-one image runs OpenResty in the foreground and the Node app in the
|
||||||
@@ -152,6 +162,23 @@ docker compose exec proxy tail -f /var/log/nginx/error.log
|
|||||||
docker compose logs --tail=200 --since=10m proxy
|
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
|
## Manual Installation
|
||||||
|
|
||||||
For manual installation or other distributions, see the detailed steps below.
|
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:
|
Clone and install:
|
||||||
```bash
|
```bash
|
||||||
cd /var/www
|
mkdir -p /opt/theta42
|
||||||
|
cd /opt/theta42
|
||||||
git clone https://github.com/theta42/proxy.git
|
git clone https://github.com/theta42/proxy.git
|
||||||
cd proxy/nodejs
|
cd proxy/nodejs
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Configure secrets:
|
||||||
|
```bash
|
||||||
|
mkdir -p /etc/proxy
|
||||||
|
cp ../secrets.js.example /etc/proxy/secrets.js
|
||||||
|
chmod 600 /etc/proxy/secrets.js
|
||||||
|
$EDITOR /etc/proxy/secrets.js
|
||||||
|
```
|
||||||
|
|
||||||
Create systemd service:
|
Create systemd service:
|
||||||
```bash
|
```bash
|
||||||
cp ops/proxy.service /etc/systemd/system/proxy.service
|
cp ../ops/proxy.service /etc/systemd/system/proxy.service
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable proxy.service
|
systemctl enable proxy.service
|
||||||
systemctl start proxy.service
|
systemctl start proxy.service
|
||||||
|
|||||||
@@ -9,13 +9,14 @@
|
|||||||
# 3. OpenResty (80/443/4443) — exec'd in the foreground as PID 2 (under
|
# 3. OpenResty (80/443/4443) — exec'd in the foreground as PID 2 (under
|
||||||
# dumb-init, PID 1) so it receives SIGTERM from `docker stop`.
|
# dumb-init, PID 1) so it receives SIGTERM from `docker stop`.
|
||||||
#
|
#
|
||||||
# The app reads its config from conf/base.js deep-merged with conf/secrets.js
|
# The app reads its config from conf/base.js deep-merged with a secrets file
|
||||||
# and `app_*` env vars (requires @simpleworkjs/conf >= 1.1.0, pinned in
|
# and `app_*` env vars (requires @simpleworkjs/conf >= 1.2.0, pinned in
|
||||||
# nodejs/package-lock.json). No secrets.js is baked into the image. The unified
|
# nodejs/package-lock.json). No secrets.js is baked into the image. The unified
|
||||||
# theta-env stack mounts ./config/proxy-secrets.js at /config; this entrypoint
|
# theta-env stack mounts ./config/proxy-secrets.js at /config; this entrypoint
|
||||||
# symlinks it into /app/conf/secrets.js so the app reads oidc/ldap/auth config
|
# points CONF_SECRETS at it so the app reads oidc/ldap/auth config straight
|
||||||
# from the file (no app_* env needed). Without the mount, supply the same config
|
# from the mounted file (no app_* env needed, no write access to /app/conf
|
||||||
# via `app_*` env (compose `environment:` / `env_file:`).
|
# required). Without the mount, supply the same config via `app_*` env
|
||||||
|
# (compose `environment:` / `env_file:`).
|
||||||
#
|
#
|
||||||
# OpenResty config: the committed ops/nginx_conf/*.conf carry the bare-metal
|
# OpenResty config: the committed ops/nginx_conf/*.conf carry the bare-metal
|
||||||
# home-LAN values (set_real_ip_from 192.168.1.0/24; resolver 192.168.1.1). They
|
# home-LAN values (set_real_ip_from 192.168.1.0/24; resolver 192.168.1.1). They
|
||||||
@@ -30,14 +31,14 @@ error() { echo "[ERROR] $*" >&2; }
|
|||||||
|
|
||||||
# ── Optional: mount proxy secrets.js ─────────────────────────────────────────
|
# ── Optional: mount proxy secrets.js ─────────────────────────────────────────
|
||||||
# When /config/proxy-secrets.js is present (unified theta-env stack, or any
|
# When /config/proxy-secrets.js is present (unified theta-env stack, or any
|
||||||
# deployment that bind-mounts ./config), symlink it into /app/conf/secrets.js so
|
# deployment that bind-mounts ./config), point CONF_SECRETS at it so
|
||||||
# @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_* env
|
# @simpleworkjs/conf reads the oidc/ldap/auth config from the file. No app_*
|
||||||
# should then be passed — app_* env beats secrets.js in @simpleworkjs/conf
|
# env should then be passed — app_* env beats secrets.js in @simpleworkjs/conf
|
||||||
# (precedence: base.js < <env>.js < secrets.js < app_* env), so the file is
|
# (precedence: base.js < <env>.js < secrets.js < app_* env), so the file is
|
||||||
# authoritative only if the matching app_* env is absent. When the file is
|
# authoritative only if the matching app_* env is absent. When the file is
|
||||||
# absent the app falls back to app_* env (compose environment / env_file).
|
# absent the app falls back to app_* env (compose environment / env_file).
|
||||||
if [[ -f /config/proxy-secrets.js ]]; then
|
if [[ -f /config/proxy-secrets.js ]]; then
|
||||||
ln -sf /config/proxy-secrets.js /app/conf/secrets.js
|
export CONF_SECRETS=/config/proxy-secrets.js
|
||||||
info "Loaded config from /config/proxy-secrets.js (secrets.js authoritative)"
|
info "Loaded config from /config/proxy-secrets.js (secrets.js authoritative)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ host form whenever the name you're entering already has a matching
|
|||||||
wildcard available to reuse — including the wildcard's own bare base
|
wildcard available to reuse — including the wildcard's own bare base
|
||||||
domain (e.g. `example.com` itself, not just `something.example.com`).
|
domain (e.g. `example.com` itself, not just `something.example.com`).
|
||||||
|
|
||||||
|
## Load Balancing
|
||||||
|
|
||||||
|
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
|
||||||
|
|
||||||
## Want more detail?
|
## Want more detail?
|
||||||
|
|
||||||
This page skips the system-internals (Redis, OpenResty, the lookup service)
|
This page skips the system-internals (Redis, OpenResty, the lookup service)
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ which deep-merges, in order:
|
|||||||
3. `conf/secrets.js` (gitignored)
|
3. `conf/secrets.js` (gitignored)
|
||||||
4. **`app_*` environment variables** — the highest-precedence layer
|
4. **`app_*` environment variables** — the highest-precedence layer
|
||||||
|
|
||||||
The bundled `docker-compose.yml` mount `./config/proxy-secrets.js` at `/config`,
|
The bundled `docker-compose.yml` mounts `./config/proxy-secrets.js` at `/config`,
|
||||||
and `docker-entrypoint.sh` symlinks it into `/app/conf/secrets.js` so the app
|
and `docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so the
|
||||||
reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is passed** —
|
app reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is
|
||||||
`app_*` env beats `secrets.js`, so the file is authoritative only if the matching
|
passed** — `app_*` env beats `secrets.js`, so the file is authoritative only if
|
||||||
`app_*` env is absent. See `secrets.js.example` for the shape.
|
the matching `app_*` env is absent. See `secrets.js.example` for the shape.
|
||||||
|
|
||||||
Any env var starting with `app_` overrides the merged config; the rest of the
|
Any env var starting with `app_` overrides the merged config; the rest of the
|
||||||
name splits on **double-underscore** (`__`) into a nested path. Values are
|
name splits on **double-underscore** (`__`) into a nested path. Values are
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 428 KiB |
@@ -50,6 +50,7 @@ LDAP directory you already run.
|
|||||||
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
|
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
|
||||||
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
|
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
|
||||||
- Dynamic host routing with wildcard domain matching (`*`, `**`)
|
- Dynamic host routing with wildcard domain matching (`*`, `**`)
|
||||||
|
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||||
- **OIDC login** and **direct LDAP lookups**, independently of each other
|
- **OIDC login** and **direct LDAP lookups**, independently of each other
|
||||||
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
|
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
|
||||||
it's never ambiguous which one gated a request)
|
it's never ambiguous which one gated a request)
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
|
|||||||
Clone the repository and copy configuration files:
|
Clone the repository and copy configuration files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www
|
mkdir -p /opt/theta42
|
||||||
|
cd /opt/theta42
|
||||||
git clone https://github.com/theta42/proxy.git
|
git clone https://github.com/theta42/proxy.git
|
||||||
cd proxy
|
cd proxy
|
||||||
|
|
||||||
@@ -161,14 +162,26 @@ cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
|
|||||||
### Step 7: Install Application
|
### Step 7: Install Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/proxy/nodejs
|
cd /opt/theta42/proxy/nodejs
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Step 7b: Configure Secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /etc/proxy
|
||||||
|
cp /opt/theta42/proxy/secrets.js.example /etc/proxy/secrets.js
|
||||||
|
chmod 600 /etc/proxy/secrets.js
|
||||||
|
$EDITOR /etc/proxy/secrets.js # set oidc.clientId/clientSecret, ldap.bindPassword, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`@simpleworkjs/conf` reads this file via the `CONF_SECRETS` env var, which the
|
||||||
|
systemd unit below sets to `/etc/proxy/secrets.js`.
|
||||||
|
|
||||||
### Step 8: Configure Systemd Service
|
### Step 8: Configure Systemd Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp /var/www/proxy/ops/proxy.service /etc/systemd/system/proxy.service
|
cp /opt/theta42/proxy/ops/proxy.service /etc/systemd/system/proxy.service
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable proxy.service
|
systemctl enable proxy.service
|
||||||
systemctl start proxy.service
|
systemctl start proxy.service
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ app.use(express.json());
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
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
|
// 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
|
// 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.
|
// 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
|
// Error handler. This is where `next()` will go on error
|
||||||
app.use(async function(err, req, res, next) {
|
app.use(async function(err, req, res, next) {
|
||||||
try{
|
try{
|
||||||
console.error(err.status || res.status, err.name, req.method, req.url);
|
const status = err.status || 500;
|
||||||
|
console.error(status, err.name, req.method, req.url);
|
||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
console.error(err.stack);
|
if (err.stack) console.error(err.stack);
|
||||||
console.error('=========================================');
|
console.error('=========================================');
|
||||||
|
|
||||||
res.status(err.status || 500);
|
res.status(status);
|
||||||
res.json({name: err.name, message: err.message, keys: err.keys});
|
// Only expose safe, non-internal fields to the client.
|
||||||
|
const body = { name: err.name, message: err.message };
|
||||||
|
res.json(body);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.log('error in the catch all error fn....', error);
|
console.error('error in the catch-all error handler', error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ name: 'Error', message: 'Internal server error' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,34 +4,91 @@
|
|||||||
* Module dependencies.
|
* Module dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var app = require('../app');
|
|
||||||
var debug = require('debug')('proxy-api:server');
|
|
||||||
var http = require('http');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const debug = require('debug')('proxy-api:server');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
/**
|
// @simpleworkjs/conf loads ./config/proxy-secrets.js synchronously, then
|
||||||
* Get port from environment and store in Express.
|
// @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.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
|
||||||
* Create HTTP server.
|
app.set('port', port);
|
||||||
*/
|
|
||||||
|
|
||||||
var server = http.createServer(app);
|
/**
|
||||||
|
* Create HTTP server.
|
||||||
|
*/
|
||||||
|
|
||||||
var io = require('socket.io')(server);
|
var server = http.createServer(app);
|
||||||
app.io = io;
|
|
||||||
|
|
||||||
/**
|
var io = require('socket.io')(server);
|
||||||
* Listen on provided port, on all network interfaces.
|
app.io = io;
|
||||||
*/
|
|
||||||
|
|
||||||
server.listen(port);
|
/**
|
||||||
server.on('error', onError);
|
* Listen on provided port, on all network interfaces.
|
||||||
server.on('listening', onListening);
|
*/
|
||||||
|
|
||||||
|
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.
|
* Normalize a port into a number, string, or false.
|
||||||
@@ -52,47 +109,3 @@ function normalizePort(val) {
|
|||||||
|
|
||||||
return false;
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ module.exports = {
|
|||||||
logo: "/static/img/theta42.svg", // shown in the nav; point at your own file under public/ (or an absolute URL) to white-label
|
logo: "/static/img/theta42.svg", // shown in the nav; point at your own file under public/ (or an absolute URL) to white-label
|
||||||
userModel: 'redis', // pam, redis, ldap
|
userModel: 'redis', // pam, redis, ldap
|
||||||
ldap: {
|
ldap: {
|
||||||
url: 'ldap://192.168.1.55:389',
|
url: 'ldap://localhost',
|
||||||
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com',
|
bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
|
||||||
bindPassword: '__IN SRECREST FILE__',
|
bindPassword: '__IN SRECREST FILE__',
|
||||||
searchBase: 'ou=people,dc=theta42,dc=com',
|
searchBase: 'ou=people,dc=example,dc=com',
|
||||||
userFilter: '(objectClass=inetOrgPerson)',
|
userFilter: '(objectClass=inetOrgPerson)',
|
||||||
userNameAttribute: 'uid'
|
userNameAttribute: 'uid'
|
||||||
},
|
},
|
||||||
@@ -29,11 +29,11 @@ module.exports = {
|
|||||||
// redirectUri MUST be registered on the SSO client and match exactly.
|
// redirectUri MUST be registered on the SSO client and match exactly.
|
||||||
oidc: {
|
oidc: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
issuer: 'https://sso.theta42.com',
|
issuer: 'https://sso.example.com',
|
||||||
authorizationEndpoint: 'https://sso.theta42.com/oauth/authorize',
|
authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
|
||||||
tokenEndpoint: 'https://sso.theta42.com/oauth/token',
|
tokenEndpoint: 'https://sso.example.com/oauth/token',
|
||||||
userinfoEndpoint: 'https://sso.theta42.com/oauth/userinfo',
|
userinfoEndpoint: 'https://sso.example.com/oauth/userinfo',
|
||||||
endSessionEndpoint: 'https://sso.theta42.com/oauth/logout',
|
endSessionEndpoint: 'https://sso.example.com/oauth/logout',
|
||||||
clientId: '__SET_ME__',
|
clientId: '__SET_ME__',
|
||||||
// Where the SSO sends the user back. Must be an absolute URL reachable
|
// Where the SSO sends the user back. Must be an absolute URL reachable
|
||||||
// by the browser and registered on the SSO client.
|
// by the browser and registered on the SSO client.
|
||||||
@@ -49,7 +49,8 @@ module.exports = {
|
|||||||
// Per-user overrides are Grant records managed in the app.
|
// Per-user overrides are Grant records managed in the app.
|
||||||
auth: {
|
auth: {
|
||||||
// Members of these SSO/LDAP groups are always global admins.
|
// 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.
|
// Optional default role mapping for groups, e.g.
|
||||||
// { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } }
|
// { 'dns-team': { role: 'manager', scope: 'domain', domain: 'foo.com' } }
|
||||||
// { 'proxy-viewers': { role: 'viewer', scope: 'global' } }
|
// { 'proxy-viewers': { role: 'viewer', scope: 'global' } }
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
userModel: 'redis', // pam, redis, ldap
|
userModel: 'redis', // pam, redis, ldap
|
||||||
ldap: {
|
ldap: {
|
||||||
url: 'ldap://192.168.1.55:389',
|
url: 'ldap://localhost',
|
||||||
bindDN: 'cn=ldapclient service,ou=people,dc=theta42,dc=com',
|
bindDN: 'cn=ldapclient service,ou=people,dc=example,dc=com',
|
||||||
bindPassword: '__IN SRECREST FILE__',
|
bindPassword: '__IN SRECREST FILE__',
|
||||||
searchBase: 'ou=people,dc=theta42,dc=com',
|
searchBase: 'ou=people,dc=example,dc=com',
|
||||||
userFilter: '(objectClass=inetOrgPerson)',
|
userFilter: '(objectClass=inetOrgPerson)',
|
||||||
userNameAttribute: 'uid'
|
userNameAttribute: 'uid'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const {Auth} = require('../models/auth');
|
const {Auth} = require('../models');
|
||||||
|
|
||||||
async function auth(req, res, next){
|
async function auth(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -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};
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
const Table = require('.');
|
const Table = require('.');
|
||||||
const ModelPs = require('../utils/model_pubsub');
|
const ModelPs = require('../utils/model_pubsub');
|
||||||
|
|
||||||
@@ -139,11 +140,26 @@ class DnsProvider extends Table{
|
|||||||
let __intraModel = this.__intraModel(data.dnsProvider);
|
let __intraModel = this.__intraModel(data.dnsProvider);
|
||||||
Provider = __intraModel.Provider;
|
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
|
// This is here test if the given API key is valid
|
||||||
let provider = new __intraModel.Provider(data, ...args);
|
let provider = new __intraModel.Provider(data, ...args);
|
||||||
let domains = await provider.listDomains();
|
let domains = await provider.listDomains();
|
||||||
|
|
||||||
|
for (let key in secrets) data[key] = '********';
|
||||||
|
|
||||||
let instance = await super.create.call(__intraModel, data, ...args);
|
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{
|
try{
|
||||||
await instance.updateDomains(domains);
|
await instance.updateDomains(domains);
|
||||||
}catch(updateError){
|
}catch(updateError){
|
||||||
@@ -189,7 +205,63 @@ class DnsProvider extends Table{
|
|||||||
let instance = await super.get(data, ...args);
|
let instance = await super.get(data, ...args);
|
||||||
let __intraModel = this.__intraModel(instance.dnsProvider);
|
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(){
|
static listProviders(){
|
||||||
|
|||||||
@@ -9,10 +9,23 @@ const tldExtract = require('tld-extract').parse_host;
|
|||||||
const LetsEncrypt = require('../utils/letsencrypt');
|
const LetsEncrypt = require('../utils/letsencrypt');
|
||||||
const conf = require('@simpleworkjs/conf');
|
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({
|
const letsEncrypt = new LetsEncrypt({
|
||||||
directoryUrl: conf.environment === "production" ?
|
directoryUrl: conf.environment === "production" ?
|
||||||
LetsEncrypt.AcmeClient.directory.letsencrypt.production :
|
LetsEncrypt.AcmeClient.directory.letsencrypt.production :
|
||||||
LetsEncrypt.AcmeClient.directory.letsencrypt.staging,
|
LetsEncrypt.AcmeClient.directory.letsencrypt.staging,
|
||||||
|
accountKeyPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
class Host extends Table{
|
class Host extends Table{
|
||||||
@@ -28,6 +41,7 @@ class Host extends Table{
|
|||||||
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
'host': {isRequired: true, type: 'string', min: 1, max: 500},
|
||||||
'ip': {isRequired: true, type: 'string', min: 3, max: 500},
|
'ip': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||||
'targetPort': {isRequired: true, type: 'number', min:0, max:65535},
|
'targetPort': {isRequired: true, type: 'number', min:0, max:65535},
|
||||||
|
'targets': {default: function(){return []}, isRequired: false, type: 'object'},
|
||||||
'forcessl': {isRequired: false, default: true, type: 'boolean'},
|
'forcessl': {isRequired: false, default: true, type: 'boolean'},
|
||||||
'targetssl': {isRequired: false, default: false, type: 'boolean'},
|
'targetssl': {isRequired: false, default: false, type: 'boolean'},
|
||||||
|
|
||||||
@@ -518,24 +532,38 @@ class Host extends Table{
|
|||||||
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the wildcard covering @host as its own base domain (e.g.
|
// Find the wildcard that could cover @host, regardless of whether @host is
|
||||||
// "*.cool.mysite.com" for host="cool.mysite.com"), regardless of whether
|
// already registered as its own host. Unlike lookUp(), which walks to and
|
||||||
// @host is already registered as its own host. Unlike lookUp(), which
|
// returns @host's own exact-match leaf when one exists, this keeps looking
|
||||||
// walks to and returns @host's own exact-match leaf when one exists, this
|
// for a sibling/child "*" slot, so it still finds the parent wildcard even
|
||||||
// walks to that exact position and looks one level deeper at its "*"
|
// when @host already has its own (non-wildcard) record. Used when attaching
|
||||||
// child -- the sibling wildcard slot -- so it still finds the parent
|
// an already-created host to a wildcard after the fact (see update() below);
|
||||||
// wildcard even when @host already has its own (non-wildcard) record.
|
// Host.create()'s own wildcardChild handling can keep using plain lookUp()
|
||||||
// Used when attaching an already-created host to a wildcard after the
|
// since a host being newly created hasn't claimed its own leaf yet.
|
||||||
// fact (see update() below); Host.create()'s own wildcardChild handling
|
//
|
||||||
// can keep using plain lookUp() since a host being newly created hasn't
|
// Two tree positions qualify, and we must check BOTH:
|
||||||
// claimed its own leaf yet.
|
// 1. Child "*" of @host's own node -- @host is the wildcard's base domain
|
||||||
|
// (e.g. "*.cool.mysite.com" covers host="cool.mysite.com").
|
||||||
|
// 2. Sibling "*" one level up -- @host is a single-label subdomain of the
|
||||||
|
// wildcard (e.g. "*.nl.wgnode.com" covers host="sso.nl.wgnode.com").
|
||||||
|
// Case 2 is the common one and was previously missed: the walk consumed the
|
||||||
|
// leftmost label ("sso") and only inspected that leaf's "*" child, so an
|
||||||
|
// already-existing sibling subdomain could never be attached to its wildcard.
|
||||||
static lookUpWildcardParent(host){
|
static lookUpWildcardParent(host){
|
||||||
let place = this.lookUpObj;
|
let place = this.lookUpObj;
|
||||||
|
let parent = undefined;
|
||||||
for(let fragment of host.split('.').reverse()){
|
for(let fragment of host.split('.').reverse()){
|
||||||
if(!place[fragment]) return undefined;
|
// @host may have no leaf of its own (brand-new subdomain); that case
|
||||||
|
// is already handled by plain lookUp()'s wildcard fallback in the
|
||||||
|
// caller, so just stop -- we've still tracked `parent` for case 2.
|
||||||
|
if(!place[fragment]){ place = undefined; break; }
|
||||||
|
parent = place;
|
||||||
place = place[fragment];
|
place = place[fragment];
|
||||||
}
|
}
|
||||||
if(place['*'] && place['*']['#record']) return place['*']['#record'];
|
// Case 1: wildcard is a child of @host's own node.
|
||||||
|
if(place && place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||||
|
// Case 2: wildcard is a sibling of @host's leftmost label.
|
||||||
|
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static async lookUpReady(){
|
static async lookUpReady(){
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const {setUpTable} = require('model-redis');
|
const {setUpTable} = require('model-redis');
|
||||||
|
const {createOidcClient, bootstrapLocalAdmin} = require('@simpleworkjs/oidc-client');
|
||||||
|
|
||||||
const Table = setUpTable(conf.redis);
|
const Table = setUpTable(conf.redis);
|
||||||
|
|
||||||
module.exports = Table;
|
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('./dns_provider');
|
||||||
require('./dynamic_record');
|
require('./dynamic_record');
|
||||||
require('./host');
|
require('./host');
|
||||||
require('./token');
|
|
||||||
require('./user');
|
|
||||||
require('./local_group');
|
require('./local_group');
|
||||||
require('./permission');
|
require('./permission');
|
||||||
require('./oidc_state');
|
|
||||||
require('./sso_session');
|
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'});
|
||||||
@@ -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};
|
|
||||||
@@ -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};
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { Client, Attribute, Change } = require('ldapts');
|
const { Client, Attribute, Change } = require('ldapts');
|
||||||
const {Token} = require('./token');
|
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
const { escapeFilter } = require('@simpleworkjs/ldap');
|
||||||
|
|
||||||
// tlsOptions is optional and forwarded to ldapts so the proxy can bind to
|
// 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
|
// 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);
|
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, {
|
const res = await client.search(conf.searchBase, {
|
||||||
scope: 'sub',
|
scope: 'sub',
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
const linuxUser = require('linux-sys-user').promise();
|
const linuxUser = require('linux-sys-user').promise();
|
||||||
const objValidate = require('../utils/object_validate');
|
const objValidate = require('../utils/object_validate');
|
||||||
const {Token} = require('./token');
|
|
||||||
const {promisify} = require('util');
|
const {promisify} = require('util');
|
||||||
const pam = require('authenticate-pam');
|
const pam = require('authenticate-pam');
|
||||||
const authenticate = promisify(pam.authenticate);
|
const authenticate = promisify(pam.authenticate);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
const Table = require('.');
|
const Table = require('.');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
|
|
||||||
class User extends Table{
|
class User extends Table{
|
||||||
@@ -86,26 +85,6 @@ class User extends Table{
|
|||||||
|
|
||||||
User.register();
|
User.register();
|
||||||
|
|
||||||
(async function(){
|
// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
|
||||||
var defaultUser = 'proxyadmin2'
|
// (bootstrapLocalAdmin); invoked once from models/index.js after User is
|
||||||
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
|
// registered. See the package lib/bootstrap.js for the original logic.
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.13",
|
"version": "1.14.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.13",
|
"version": "1.14.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/conf": "^1.1.0",
|
"@simpleworkjs/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",
|
"acme-client": "^5.4.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -21,18 +26,19 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.1.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"linux-sys-user": "^1.2.0",
|
"linux-sys-user": "^1.2.0",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.5.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"redis": "^6.1.0",
|
"redis": "^6.1.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"tld-extract": "^2.1.0"
|
"tld-extract": "^2.1.0",
|
||||||
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.11"
|
"nodemon": "^3.1.11"
|
||||||
@@ -280,10 +286,34 @@
|
|||||||
"@redis/client": "^6.1.0"
|
"@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": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
"integrity": "sha512-MKRQQ4JAH2tbEm87NdkmfikTT58Tyk/SFbvCC7zKja0bK6j8zYyBXTQUJ0rnvFOVEalDWd/au4AEiptOCEqgvA==",
|
"integrity": "sha512-X4u1oRb0A0x7wzmyiIH5hPYYIFJYUXhYVe9CPX6G6INouRIeZuHlx0pthHlihiAAIc3+KqZBx18qirFN8RoJwA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"extend": "^3.0.2"
|
"extend": "^3.0.2"
|
||||||
@@ -292,6 +322,41 @@
|
|||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/@socket.io/component-emitter": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
@@ -459,20 +524,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.2.2",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^2.0.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.1",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.2",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"qs": "^6.14.1",
|
"qs": "^6.15.2",
|
||||||
"raw-body": "^3.0.1",
|
"raw-body": "^3.0.2",
|
||||||
"type-is": "^2.0.1"
|
"type-is": "^2.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -482,6 +547,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/bootstrap": {
|
||||||
"version": "5.3.8",
|
"version": "5.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||||
@@ -502,16 +580,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
@@ -611,6 +689,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/compressible": {
|
"node_modules/compressible": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
@@ -722,6 +806,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cssfilter": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1375,9 +1465,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jq-repeat": {
|
"node_modules/jq-repeat": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
|
||||||
"integrity": "sha512-e1OmSWeBEHEtyOhNVysx0bnT5wd6HlZ37JZgPcGPmACJ0K9bXDPq0xOwrM1slQMSTw7FOSNDX+MD6VwvPeeZyQ==",
|
"integrity": "sha512-OdKAQJ8SOTZzoNL/76o5+WJehXnMCoP8aXbDtZCmDh3vuGGdXfN14FkPTqLpZC5xmlv+QVfTXu/UaIRsDjVuhA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -1495,9 +1585,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/model-redis": {
|
"node_modules/model-redis": {
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.6.0.tgz",
|
||||||
"integrity": "sha512-eVXQQN+k3cR5aJBvPQurgr8WXYpAvVoLu6ydMWenSOJZBDVcHqeNqUCp8n5lYrxv6iZ8PlF0WQzZFmbJ1zP6/A==",
|
"integrity": "sha512-QinykZ0H9vhyj0qY9NnNd1m1YcBXBXuc0viENJuEyTwplhOWT0cpjpGxObqoD1ysb8AJkrBkJB9tAf4EZbISWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"redis": "^6.1.0"
|
"redis": "^6.1.0"
|
||||||
@@ -2181,17 +2271,34 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^2.0.0",
|
||||||
"media-typer": "^1.1.0",
|
"media-typer": "^1.1.0",
|
||||||
"mime-types": "^3.0.0"
|
"mime-types": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"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": {
|
"node_modules/undefsafe": {
|
||||||
@@ -2251,6 +2358,22 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xss": {
|
||||||
|
"version": "1.0.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||||
|
"integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^2.20.3",
|
||||||
|
"cssfilter": "0.0.10"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xss": "bin/xss"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.13",
|
"version": "1.14.2",
|
||||||
"private": true,
|
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"name": "William Mantly",
|
"name": "William Mantly",
|
||||||
@@ -11,10 +10,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./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": "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": "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: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": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -22,7 +21,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/conf": "^1.1.0",
|
"@simpleworkjs/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",
|
"acme-client": "^5.4.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -32,23 +36,24 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.1.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"linux-sys-user": "^1.2.0",
|
"linux-sys-user": "^1.2.0",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.5.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"redis": "^6.1.0",
|
"redis": "^6.1.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"tld-extract": "^2.1.0"
|
"tld-extract": "^2.1.0",
|
||||||
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.theta42.com/wmantly/proxy.git"
|
"url": "https://github.com/theta42/proxy.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.11"
|
"nodemon": "^3.1.11"
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
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 {
|
#spa-shell {
|
||||||
@@ -18,3 +24,7 @@ body {
|
|||||||
.card-title{
|
.card-title{
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-required{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,3 +91,72 @@ app.apiToken = (function(app){
|
|||||||
|
|
||||||
return {list, get, add, update, remove, rotate};
|
return {list, get, add, update, remove, rotate};
|
||||||
})(app);
|
})(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 );
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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 = {};
|
var app = {};
|
||||||
|
|
||||||
app.pubsub = (function(){
|
app.pubsub = (function(){
|
||||||
@@ -75,10 +84,26 @@ app.socket = (function(app){
|
|||||||
app.api = (function(app){
|
app.api = (function(app){
|
||||||
var baseURL = '/api/'
|
var baseURL = '/api/'
|
||||||
|
|
||||||
function post(url, data, callback){
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
if(typeof callback !== 'function') callback = callback2;
|
// (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({
|
return $.ajax({
|
||||||
type: 'POST',
|
type: method,
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{
|
headers:{
|
||||||
'auth-token': app.auth.getToken()
|
'auth-token': app.auth.getToken()
|
||||||
@@ -87,40 +112,44 @@ app.api = (function(app){
|
|||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
callback ? callback(
|
callback(
|
||||||
text !== 'success' ? res.statusText : null,
|
text !== 'success' ? res.statusText : null,
|
||||||
JSON.parse(res.responseText),
|
JSON.parse(res.responseText),
|
||||||
res.status
|
res.status
|
||||||
) : function(){}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function post(url, data, callback){
|
||||||
|
return body('POST', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
function put(url, data, callback){
|
function put(url, data, callback){
|
||||||
if(typeof callback !== 'function') callback = callback2;
|
return body('PUT', url, data, callback);
|
||||||
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(){}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(url, callback, callback2){
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
if(typeof callback !== 'function') callback = callback2;
|
// 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({
|
return $.ajax({
|
||||||
type: 'delete',
|
type: 'DELETE',
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{
|
headers:{
|
||||||
'auth-token': app.auth.getToken()
|
'auth-token': app.auth.getToken()
|
||||||
@@ -128,11 +157,11 @@ app.api = (function(app){
|
|||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
callback ? callback(
|
callback(
|
||||||
text !== 'success' ? res.statusText : null,
|
text !== 'success' ? res.statusText : null,
|
||||||
JSON.parse(res.responseText),
|
JSON.parse(res.responseText),
|
||||||
res.status
|
res.status
|
||||||
) : function(){}
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -179,7 +208,11 @@ app.api = (function(app){
|
|||||||
})(app)
|
})(app)
|
||||||
|
|
||||||
app.auth = (function(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){
|
function setToken(token){
|
||||||
localStorage.setItem('APIToken', token);
|
localStorage.setItem('APIToken', token);
|
||||||
}
|
}
|
||||||
@@ -188,18 +221,95 @@ app.auth = (function(app){
|
|||||||
return localStorage.getItem('APIToken');
|
return localStorage.getItem('APIToken');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoggedIn(callback){
|
async function getUser(){
|
||||||
if(getToken()){
|
try{
|
||||||
return app.api.get('user/me', function(error, data){
|
return await app.api.get('user/me');
|
||||||
// data now carries effective rights (isAdmin, global, domains).
|
}catch(error){
|
||||||
if(!error) app.auth.user = app.auth.perms = data;
|
if(error && error.status === 401) return null;
|
||||||
return callback(error, data);
|
throw error;
|
||||||
});
|
|
||||||
}else{
|
|
||||||
callback(null, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||||
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||||
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||||
@@ -230,46 +340,68 @@ app.auth = (function(app){
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when the logged-in user is a global admin (per user/me).
|
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
||||||
function isAdmin(){
|
// unauthorised user is kept off the page by a redirect / an error panel
|
||||||
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
// rather than by pausing document ready.
|
||||||
}
|
//
|
||||||
|
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
||||||
function logIn(args, callback){
|
// `admin` group covers the OIDC clients' isAdmin flag.
|
||||||
app.api.post('auth/login', args, function(error, data){
|
async function forceLogin(requiredGroups){
|
||||||
if(data.login){
|
var user = await loadUser();
|
||||||
setToken(data.token);
|
|
||||||
}
|
if(!user){
|
||||||
callback(error, !!data.token);
|
logOut(function(){});
|
||||||
});
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
}
|
location.pathname + location.search
|
||||||
|
));
|
||||||
function logOut(callback){
|
return false;
|
||||||
localStorage.removeItem('APIToken');
|
}
|
||||||
callback();
|
|
||||||
}
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
|
location.replace('/onboarding');
|
||||||
function forceLogin(){
|
return false;
|
||||||
// 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(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
if(error || !isLoggedIn){
|
app.messages.action(
|
||||||
app.auth.logOut(function(){})
|
`<h1>
|
||||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
<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(){
|
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 {
|
return {
|
||||||
getToken: getToken,
|
getToken: getToken,
|
||||||
setToken: setToken,
|
setToken: setToken,
|
||||||
isLoggedIn: isLoggedIn,
|
getUser: getUser,
|
||||||
consumeTokenFragment: consumeTokenFragment,
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
isAdmin: isAdmin,
|
isAdmin: isAdmin,
|
||||||
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
perms: null,
|
perms: null,
|
||||||
logIn: logIn,
|
logIn: logIn,
|
||||||
logOut: logOut,
|
logOut: logOut,
|
||||||
@@ -279,6 +411,11 @@ app.auth = (function(app){
|
|||||||
|
|
||||||
})(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){
|
app.user = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
app.api.get('user/?detail=true', function(error, data){
|
app.api.get('user/?detail=true', function(error, data){
|
||||||
@@ -308,6 +445,8 @@ app.user = (function(app){
|
|||||||
|
|
||||||
})(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){
|
app.permission = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
app.api.get('permission/', function(error, data){
|
app.api.get('permission/', function(error, data){
|
||||||
@@ -381,29 +520,15 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $target, type, callback){
|
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||||
message = message || '';
|
// app.util.escapeHtml and app.messages.action/confirm.
|
||||||
$target = $target.closest('div.card').find('.actionMessage');
|
function escapeHtml(s){
|
||||||
type = type || 'info';
|
return String(s == null ? '' : s)
|
||||||
callback = callback || function(){};
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
if($target.html() === message) return;
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
if($target.html()){
|
.replace(/'/g, ''');
|
||||||
$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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
@@ -459,14 +584,65 @@ app.util = (function(app){
|
|||||||
document.body.removeChild(element);
|
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 {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage
|
escapeHtml: escapeHtml,
|
||||||
|
revealItem: revealItem,
|
||||||
}
|
}
|
||||||
})(app);
|
})(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
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
//panel button's
|
//panel button's
|
||||||
@@ -487,9 +663,9 @@ $( document ).ready(function(){
|
|||||||
$(this).closest('.card').slideUp('fast');
|
$(this).closest('.card').slideUp('fast');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||||
app.util.actionMessage(null, $(this));
|
// app.messages.js (delegated on document, so it also covers messages
|
||||||
});
|
// rendered after this ready handler runs).
|
||||||
|
|
||||||
setInterval(()=>{
|
setInterval(()=>{
|
||||||
$('.momentFromNow').each((idx, el)=>{
|
$('.momentFromNow').each((idx, el)=>{
|
||||||
@@ -520,18 +696,17 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||||
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>',
|
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||||
$form,
|
// render literally instead of as an element.
|
||||||
'info'
|
app.messages.action('Saving…', $form, 'info');
|
||||||
);
|
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
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();
|
$form.validateClear();
|
||||||
if(!error){
|
if(!error){
|
||||||
$form.trigger("reset");
|
$form.trigger("reset");
|
||||||
@@ -539,7 +714,7 @@ function formAJAX(btn){
|
|||||||
}else{
|
}else{
|
||||||
console.log('formAJAX res error', error, data)
|
console.log('formAJAX res error', error, data)
|
||||||
if(data && data.name === 'ObjectValidateError'){
|
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){
|
if(data && data.keys){
|
||||||
console.log('form key errors', data.keys)
|
console.log('form key errors', data.keys)
|
||||||
|
|||||||
@@ -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)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -6,7 +6,7 @@ const middleware = require('../middleware/auth');
|
|||||||
const authz = require('../middleware/authz');
|
const authz = require('../middleware/authz');
|
||||||
|
|
||||||
// API routes for authentication.
|
// 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.
|
// 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
|
// User management is admin-only; the router allows self-service exceptions
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -5,6 +5,7 @@ const path = require('path');
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const {rateLimit} = require('express-rate-limit');
|
const {rateLimit} = require('express-rate-limit');
|
||||||
const {marked} = require('marked');
|
const {marked} = require('marked');
|
||||||
|
const xss = require('xss');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ router.get('/:slug', function(req, res, next) {
|
|||||||
docs: docList,
|
docs: docList,
|
||||||
currentSlug: req.params.slug,
|
currentSlug: req.params.slug,
|
||||||
docTitle: doc.title,
|
docTitle: doc.title,
|
||||||
docHtml: fixDocLinks(fixImagePaths(marked(content))),
|
docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* Per-host SSO endpoints (#57), served under /__proxy_auth on EVERY proxied host
|
* 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
|
* (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
|
* successful + authorized login, mint a Redis-backed SsoSession and set the
|
||||||
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that
|
* `__proxy_sso` cookie for the host. OpenResty then gates the host on that
|
||||||
* session (ops/nginx_conf/hostfeatures.lua).
|
* session (ops/nginx_conf/hostfeatures.lua).
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const oidc = require('../utils/oidc');
|
const {oidc} = require('@simpleworkjs/oidc-client');
|
||||||
const {Host} = require('../models').models;
|
const {Host} = require('../models').models;
|
||||||
const {HostSsoState, SsoSession} = require('../models/sso_session');
|
const {HostSsoState, SsoSession} = require('../models/sso_session');
|
||||||
const {identityAllowed} = require('../utils/host_sso');
|
const {identityAllowed} = require('../utils/host_sso');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const express = require('express');
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
|
||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
@@ -15,24 +16,16 @@ const values ={
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List of front end node modules to be served
|
// 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),
|
// Vendor libraries only change when package versions are bumped (a rebuild),
|
||||||
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
// 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.
|
// still cover that rare case with a cheap 304 instead of a stale asset. The
|
||||||
frontEndModules.forEach(dep => {
|
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
|
||||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
|
// 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) => {
|
router.get('/', (req, res) => {
|
||||||
res.redirect(301, '/hosts');
|
res.redirect(301, '/hosts');
|
||||||
});
|
});
|
||||||
@@ -47,6 +40,15 @@ router.get('/hosts', async function(req, res, next) {
|
|||||||
res.render('hosts', {...values});
|
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) {
|
router.get('/dns', async function(req, res, next) {
|
||||||
res.render('dns', {...values});
|
res.render('dns', {...values});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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){
|
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
validatePassword(req.body.password);
|
validatePassword(req.body.password);
|
||||||
let user = await User.get(req.params.username);
|
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)});
|
return res.json({results: await user.setPassword(req.body)});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -185,6 +185,35 @@ describe('Host wildcard base-domain lookup', () => {
|
|||||||
await populateTree(Host, ['*.cool.mysite.com']);
|
await populateTree(Host, ['*.cool.mysite.com']);
|
||||||
assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined);
|
assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: the common case -- an already-existing single-label subdomain
|
||||||
|
// (its own auto-SSL/HTTP-01 host) sitting beside a wildcard, e.g.
|
||||||
|
// sso.nl.wgnode.com under *.nl.wgnode.com. The wildcard is a SIBLING of the
|
||||||
|
// subdomain's leftmost label, not a child of its node, so the old walk (which
|
||||||
|
// consumed "sso" and only checked that leaf's "*" child) never found it and
|
||||||
|
// the edit form's "Parent Wildcard" option stayed permanently greyed out.
|
||||||
|
test('lookUpWildcardParent finds a sibling wildcard for an existing single-label subdomain', async () => {
|
||||||
|
await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']);
|
||||||
|
const result = Host.lookUpWildcardParent('sso.nl.wgnode.com');
|
||||||
|
assert.ok(result, 'Should find the sibling wildcard');
|
||||||
|
assert.strictEqual(result.host, '*.nl.wgnode.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
// A subdomain with no leaf of its own (never created) is deliberately NOT
|
||||||
|
// this method's job -- the walk stops before reaching the sibling "*" slot.
|
||||||
|
// The route resolves that case via plain lookUp()'s wildcard fallback first
|
||||||
|
// (covered in the route-fallback describe block below).
|
||||||
|
test('lookUpWildcardParent returns undefined for a subdomain with no leaf of its own', async () => {
|
||||||
|
await populateTree(Host, ['*.nl.wgnode.com']);
|
||||||
|
assert.strictEqual(Host.lookUpWildcardParent('api.nl.wgnode.com'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookUpWildcardParent does not treat a deeper wildcard as covering a shallower host', async () => {
|
||||||
|
// *.deep.nl.wgnode.com must NOT be offered as a parent for sso.nl.wgnode.com
|
||||||
|
// (a single-level wildcard covers only its own direct children).
|
||||||
|
await populateTree(Host, ['sso.nl.wgnode.com', '*.deep.nl.wgnode.com']);
|
||||||
|
assert.strictEqual(Host.lookUpWildcardParent('sso.nl.wgnode.com'), undefined);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,6 +260,24 @@ describe('Host wildcard-parent route fallback (lookUp then lookUpWildcardParent)
|
|||||||
await populateTree(Host, ['cool.mysite.com']);
|
await populateTree(Host, ['cool.mysite.com']);
|
||||||
assert.strictEqual(findWildcardParent('cool.mysite.com'), null);
|
assert.strictEqual(findWildcardParent('cool.mysite.com'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The user's scenario: sso.nl.wgnode.com already exists as its own host, and
|
||||||
|
// a *.nl.wgnode.com wildcard is added afterward. lookUp() resolves to sso's
|
||||||
|
// own (non-wildcard) leaf, so the fallback to lookUpWildcardParent() is what
|
||||||
|
// surfaces the sibling wildcard and lets the edit form offer conversion.
|
||||||
|
test('finds the sibling wildcard for an already-existing single-label subdomain', async () => {
|
||||||
|
await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']);
|
||||||
|
const result = findWildcardParent('sso.nl.wgnode.com');
|
||||||
|
assert.ok(result);
|
||||||
|
assert.strictEqual(result.host, '*.nl.wgnode.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds the sibling wildcard for a never-created single-label subdomain', async () => {
|
||||||
|
await populateTree(Host, ['*.nl.wgnode.com']);
|
||||||
|
const result = findWildcardParent('api.nl.wgnode.com');
|
||||||
|
assert.ok(result);
|
||||||
|
assert.strictEqual(result.host, '*.nl.wgnode.com');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -265,11 +312,17 @@ function createMockHostClassWithWildcardParentFix() {
|
|||||||
|
|
||||||
static lookUpWildcardParent(host) {
|
static lookUpWildcardParent(host) {
|
||||||
let place = this.lookUpObj;
|
let place = this.lookUpObj;
|
||||||
|
let parent = undefined;
|
||||||
for(let fragment of host.split('.').reverse()){
|
for(let fragment of host.split('.').reverse()){
|
||||||
if(!place[fragment]) return undefined;
|
if(!place[fragment]){ place = undefined; break; }
|
||||||
|
parent = place;
|
||||||
place = place[fragment];
|
place = place[fragment];
|
||||||
}
|
}
|
||||||
if(place['*'] && place['*']['#record']) return place['*']['#record'];
|
// Case 1: wildcard is a child of host's own node (base domain).
|
||||||
|
if(place && place['*'] && place['*']['#record']) return place['*']['#record'];
|
||||||
|
// Case 2: wildcard is a sibling of host's leftmost label
|
||||||
|
// (single-label subdomain, e.g. sso.nl.wgnode.com -> *.nl.wgnode.com).
|
||||||
|
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, []);
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ const {describe, test} = require('node:test');
|
|||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const oidc = require('../../utils/oidc');
|
const oidc = require('@simpleworkjs/oidc-client').oidc;
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
const {describe, test} = require('node:test');
|
const {describe, test} = require('node:test');
|
||||||
const assert = require('node:assert');
|
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
|
* safeInternalPath guards the OIDC post-login redirect against open-redirect
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
'use strict';
|
'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 path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||||
const { version: buildVersion } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo
|
module.exports = createBuildInfo({
|
||||||
// stage in Dockerfile) -- the final image has no git binary and no .git
|
version,
|
||||||
// directory, so `git rev-parse` below always fails there. Bare-metal/dev
|
buildCommitPath: path.join(__dirname, '../.build_commit'),
|
||||||
// runs have no baked file, so they fall back to asking git directly.
|
cwd: __dirname,
|
||||||
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(),
|
|
||||||
};
|
|
||||||
@@ -252,6 +252,7 @@ function normalizeHostFeatures(body){
|
|||||||
if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled);
|
if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled);
|
||||||
if('sso_allow_users' in body) body.sso_allow_users = parseAllowList(body.sso_allow_users);
|
if('sso_allow_users' in body) body.sso_allow_users = parseAllowList(body.sso_allow_users);
|
||||||
if('sso_allow_groups' in body) body.sso_allow_groups = parseAllowList(body.sso_allow_groups);
|
if('sso_allow_groups' in body) body.sso_allow_groups = parseAllowList(body.sso_allow_groups);
|
||||||
|
if('targets' in body) body.targets = parseAllowList(body.targets);
|
||||||
|
|
||||||
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
|
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
|
||||||
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
|
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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};
|
|
||||||
@@ -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']},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -27,12 +27,14 @@ class SocketServerJson {
|
|||||||
this.onClientClose = new CallbackQueue(args.onClientClose);
|
this.onClientClose = new CallbackQueue(args.onClientClose);
|
||||||
this.onClientError = new CallbackQueue(args.onClientError);
|
this.onClientError = new CallbackQueue(args.onClientError);
|
||||||
|
|
||||||
// Set socket file permissions after listening
|
// Set socket file permissions after listening. 660 (owner + group read/write)
|
||||||
// 777 is acceptable here for single-use container environments
|
// is the safest default; the Docker image runs both processes as root, and
|
||||||
// Wrapped in try-catch as chmod may fail in test/restricted environments
|
// bare-metal operators should ensure the proxy service and openresty share a
|
||||||
|
// group when running as separate users. Wrapped in try-catch as chmod may
|
||||||
|
// fail in test/restricted environments.
|
||||||
this.onListen.push(() => {
|
this.onListen.push(() => {
|
||||||
try {
|
try {
|
||||||
fs.chmodSync(this.socketFile, '777');
|
fs.chmodSync(this.socketFile, '660');
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
// Chmod may fail in test environments or certain filesystems
|
// Chmod may fail in test environments or certain filesystems
|
||||||
// Socket will still work with default permissions
|
// Socket will still work with default permissions
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
</div>
|
</div><!-- end spa-shell -->
|
||||||
|
|
||||||
<footer class="py-2 bg-dark text-light mt-4">
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||||
<span class="d-flex align-items-center gap-2">
|
app.locals in app.js). Edit all three copies together. -->
|
||||||
<a href="https://theta42.com" target="_blank">
|
<footer class="py-2 bg-dark text-light mt-4">
|
||||||
<img width="64" src="/static/img/theta42.svg"/>
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||||
</a>
|
<span class="d-flex align-items-center gap-2">
|
||||||
© <%- buildYear %> theta42 ·
|
<a href="https://theta42.com" target="_blank">
|
||||||
<a href="https://github.com/theta42/proxy/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
<img width="64" src="/static/img/theta42.svg"/>
|
||||||
</span>
|
</a>
|
||||||
<span class="d-flex align-items-center gap-3">
|
© <%- buildYear %> theta42 ·
|
||||||
<a href="/docs" class="text-light text-decoration-none">
|
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||||
<i class="fa-solid fa-book"></i> Docs
|
</span>
|
||||||
</a>
|
<span class="d-flex align-items-center gap-3">
|
||||||
<a href="https://github.com/theta42/proxy" target="_blank" class="text-light text-decoration-none">
|
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
</a>
|
</a>
|
||||||
</span>
|
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||||
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
</div>
|
</a>
|
||||||
</footer>
|
<% if(ui.tosUrl){ %>
|
||||||
</body>
|
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
|
||||||
|
<% } %>
|
||||||
|
</span>
|
||||||
|
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -113,6 +113,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="row mb-3" style="display:none">
|
<div class="row mb-3" style="display:none">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card shadow-lg mb-3">
|
<div class="card shadow-lg mb-3">
|
||||||
@@ -191,7 +192,7 @@
|
|||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h3><img height="32px" src="{{ displayIconHtml }}"/> {{name}} </h3>
|
<h3>{{name}} </h3>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{#domains}}
|
{{#domains}}
|
||||||
@@ -299,4 +300,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -28,14 +28,14 @@
|
|||||||
|
|
||||||
function removeGroup(name){
|
function removeGroup(name){
|
||||||
app.group.remove(name, function(error, data){
|
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);
|
$.scope.LocalGroup.remove(name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMember(group, username){
|
function removeMember(group, username){
|
||||||
app.group.removeMember(group, username, function(error, data){
|
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.
|
// websocket update echoes the new member list.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -47,14 +47,14 @@
|
|||||||
let username = ($input.val() || '').trim();
|
let username = ($input.val() || '').trim();
|
||||||
if(!username) return;
|
if(!username) return;
|
||||||
app.group.addMember(group, username, function(error, data){
|
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('');
|
$input.val('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
app.group.list(function(error, data){
|
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);
|
for(let g of data.results) $.scope.LocalGroup.push(g);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<datalist id="groupUsers"></datalist>
|
<datalist id="groupUsers"></datalist>
|
||||||
|
|
||||||
<div class="row" style="display:none">
|
<div class="row" style="display:none">
|
||||||
@@ -143,4 +144,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -61,9 +61,9 @@
|
|||||||
return host;
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostPopulate(){
|
function hostPopulate(onLoaded){
|
||||||
app.api.get('host?detail=1&provider=1', function(error, res){
|
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){
|
for(let host of res.results){
|
||||||
$.scope.hosts.push(hostParseRow(host));
|
$.scope.hosts.push(hostParseRow(host));
|
||||||
@@ -75,6 +75,8 @@
|
|||||||
$el.removeClass('table-success');
|
$el.removeClass('table-success');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (onLoaded) onLoaded();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +92,341 @@
|
|||||||
|
|
||||||
// ----- Add / Edit modal --------------------------------------------------
|
// ----- Add / Edit modal --------------------------------------------------
|
||||||
|
|
||||||
function hostModal(){
|
function hostModalClose(){ app.modal.close(); }
|
||||||
return bootstrap.Modal.getOrCreateInstance(document.getElementById('hostModal'));
|
|
||||||
|
// 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 & 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 — 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.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 — 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){
|
function hostShowTab(id){
|
||||||
bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show();
|
bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show();
|
||||||
@@ -152,9 +485,9 @@
|
|||||||
let password = $pass.val();
|
let password = $pass.val();
|
||||||
if(!password) return;
|
if(!password) return;
|
||||||
app.api.put('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), {password}, function(error, data){
|
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('');
|
$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
|
// 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>');
|
let $del = $('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button>');
|
||||||
$del.on('click', function(){
|
$del.on('click', function(){
|
||||||
app.api.delete('host/' + encodeURIComponent(host) + '/basicauth-user/' + encodeURIComponent(username), function(error, data){
|
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();
|
$tr.remove();
|
||||||
$('.basicauth-current').text(Object.keys((data && data.basicauth_users) || {}).join(', ') || 'none');
|
$('.basicauth-current').text(Object.keys((data && data.basicauth_users) || {}).join(', ') || 'none');
|
||||||
});
|
});
|
||||||
@@ -206,20 +539,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hostAddOpen(){
|
function hostAddOpen(){
|
||||||
|
openHostModal('Add host', 'Add host', null);
|
||||||
hostFormReset();
|
hostFormReset();
|
||||||
$('#hostModalTitle').text('Add host');
|
|
||||||
$('#hostModalSubmitText').text('Add host');
|
|
||||||
hostModal().show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hostEditOpen(host){
|
async function hostEditOpen(host){
|
||||||
hostFormReset();
|
|
||||||
let h = $.scope.hosts.getByKey(host);
|
let h = $.scope.hosts.getByKey(host);
|
||||||
|
openHostModal('Edit ' + host, 'Save changes', h);
|
||||||
|
hostFormReset();
|
||||||
let $f = $('#hostForm');
|
let $f = $('#hostForm');
|
||||||
|
|
||||||
$f.attr('method', 'PUT').attr('action', 'host/' + encodeURIComponent(host));
|
$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
|
// Scalar fields: booleans drive the matching radio, everything else the
|
||||||
// input with that name. Object/array fields are handled as text below.
|
// 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='req_headers']").val(hostFeatureHeadersToText(h.req_headers));
|
||||||
|
$f.find("textarea[name='targets']").val(hostFeatureListToText(h.targets));
|
||||||
$f.find("textarea[name='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers));
|
$f.find("textarea[name='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers));
|
||||||
$f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow));
|
$f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow));
|
||||||
$f.find("textarea[name='ip_deny']").val(hostFeatureListToText(h.ip_deny));
|
$f.find("textarea[name='ip_deny']").val(hostFeatureListToText(h.ip_deny));
|
||||||
@@ -284,13 +615,11 @@
|
|||||||
$('#challengeType-HTTP-01').prop('checked', true);
|
$('#challengeType-HTTP-01').prop('checked', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hostModal().show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostDownloadCert(host, type){
|
function hostDownloadCert(host, type){
|
||||||
app.host.getCert({host}, function(error, data){
|
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])
|
app.util.downloadFile(`${host}-${type}.crt`, data[type])
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -321,9 +650,9 @@
|
|||||||
app.host.clearCache(function(error, data){
|
app.host.clearCache(function(error, data){
|
||||||
$btn.prop('disabled', false);
|
$btn.prop('disabled', false);
|
||||||
if(error){
|
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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,41 +669,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(){
|
$(document).ready(function(){
|
||||||
// Populate the host UI table
|
// Populate the host UI table, then check for a deep-linked host
|
||||||
hostPopulate();
|
// (e.g. a direct load of /hosts/some-host, or app.modal's url option
|
||||||
hostLoadAuthSuggestions();
|
// having pushed that path) once the table's data is actually loaded.
|
||||||
|
hostPopulate(function(){
|
||||||
// Determine what Let's Encrypt challenge type the given host name can use.
|
const slug = app.modal.deepLinkSlug('/hosts');
|
||||||
let $hostField = $('#hostForm [name=host]');
|
if (slug) {
|
||||||
$hostField.on('keyup', async function(){
|
const h = $.scope.hosts.getByKey(decodeURIComponent(slug));
|
||||||
// Reset the allowed types on start
|
if (h) hostEditOpen(h.host);
|
||||||
$('#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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$.scope.hosts.take = function($el, item, list){
|
$.scope.hosts.take = function($el, item, list){
|
||||||
@@ -412,6 +752,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="row" style="display:none">
|
<div class="row" style="display:none">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-lg hostListPanel">
|
<div class="card shadow-lg hostListPanel">
|
||||||
@@ -479,7 +820,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{{#domain.provider}}
|
{{#domain.provider}}
|
||||||
<br />
|
<br />
|
||||||
<img width="24px" src="{{displayIconHtml}}" /> {{displayName}} - {{name}}
|
{{displayName}} - {{name}}
|
||||||
{{/domain.provider}}
|
{{/domain.provider}}
|
||||||
|
|
||||||
{{#wildcard_parent}}
|
{{#wildcard_parent}}
|
||||||
@@ -546,326 +887,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
<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-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 & 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 — 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 — 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>
|
</div>
|
||||||
|
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -4,14 +4,18 @@
|
|||||||
// If we arrived from the OIDC callback with a token in the URL fragment,
|
// If we arrived from the OIDC callback with a token in the URL fragment,
|
||||||
// store it and forward on before doing anything else.
|
// store it and forward on before doing anything else.
|
||||||
if(!app.auth.consumeTokenFragment()){
|
if(!app.auth.consumeTokenFragment()){
|
||||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
// The reveal below touches an element further down this page, so wait
|
||||||
if(isLoggedIn){
|
// for the DOM — isLoggedIn can answer before the parser gets there.
|
||||||
app.auth.logInRedirect();
|
$(document).ready(function(){
|
||||||
}else{
|
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||||
// Reveal the login card once we know the user is not logged in.
|
if(isLoggedIn){
|
||||||
document.getElementById('login-card-row').style.display = '';
|
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>
|
</script>
|
||||||
|
|||||||
@@ -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){
|
function removePermission(id){
|
||||||
app.permission.remove(id, function(error, data){
|
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.
|
// The websocket echo removes the row; drop it locally too for snappiness.
|
||||||
$.scope.Permission.remove(id);
|
$.scope.Permission.remove(id);
|
||||||
});
|
});
|
||||||
@@ -51,7 +95,7 @@
|
|||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
// Existing permissions.
|
// Existing permissions.
|
||||||
app.permission.list(function(error, data){
|
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);
|
for(let p of data.results) $.scope.Permission.push(p);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +110,7 @@
|
|||||||
app.subscribe(/^model:Permission:create/, function(data){
|
app.subscribe(/^model:Permission:create/, function(data){
|
||||||
$.scope.Permission.remove(data.id);
|
$.scope.Permission.remove(data.id);
|
||||||
$.scope.Permission.unshift(data);
|
$.scope.Permission.unshift(data);
|
||||||
|
setTimeout(function(){ app.util.revealItem($('#permission-row-' + data.id)); }, 100);
|
||||||
});
|
});
|
||||||
app.subscribe(/^model:Permission:remove/, function(data, topic){
|
app.subscribe(/^model:Permission:remove/, function(data, topic){
|
||||||
$.scope.Permission.remove(topic.split(':')[3]);
|
$.scope.Permission.remove(topic.split(':')[3]);
|
||||||
@@ -73,67 +118,12 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<datalist id="subjectUsers"></datalist>
|
<datalist id="subjectUsers"></datalist>
|
||||||
<datalist id="subjectGroups"></datalist>
|
<datalist id="subjectGroups"></datalist>
|
||||||
|
|
||||||
<div class="row" style="display:none">
|
<div class="row" style="display:none">
|
||||||
<div class="col-md-4">
|
<div class="col-12">
|
||||||
<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>
|
|
||||||
<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">
|
|
||||||
<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="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
|
|
||||||
<div class="card-header text-center">
|
<div class="card-header text-center">
|
||||||
@@ -141,38 +131,41 @@
|
|||||||
<i class="fa-solid fa-list-check"></i>
|
<i class="fa-solid fa-list-check"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="card-title">Permissions</span>
|
<span class="card-title">Permissions</span>
|
||||||
<a href="/docs/access" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
<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>
|
||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="table-responsive">
|
<div class="card-body">
|
||||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
<div class="row row-cols-1 row-cols-lg-2 g-3" id="permission-cards">
|
||||||
<thead>
|
<div class="col" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
|
||||||
<th>Type</th>
|
<div class="card shadow-sm h-100">
|
||||||
<th>Subject</th>
|
<div class="card-body">
|
||||||
<th>Scope</th>
|
<h6 class="mb-2">
|
||||||
<th>Domain</th>
|
<span class="badge text-bg-secondary">{{ subjectType }}</span>
|
||||||
<th>Role</th>
|
{{ subject }}
|
||||||
<th>Delete</th>
|
</h6>
|
||||||
</thead>
|
<dl class="row mb-2 small">
|
||||||
<tbody>
|
<dt class="col-4">Scope</dt><dd class="col-8">{{ scope }}</dd>
|
||||||
<tr jq-repeat="Permission" jq-repeat-index="id" style="display:none">
|
<dt class="col-4">Domain</dt><dd class="col-8">{{ domain }}</dd>
|
||||||
<td class="align-middle">{{ subjectType }}</td>
|
<dt class="col-4">Role</dt><dd class="col-8">{{ role }}</dd>
|
||||||
<td class="align-middle">{{ subject }}</td>
|
</dl>
|
||||||
<td class="align-middle">{{ scope }}</td>
|
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
|
||||||
<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}}')">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -63,12 +63,13 @@
|
|||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
app.api.get('user/me', function(error, data){
|
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);
|
renderProfile(data);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card shadow-lg" id="profile-card">
|
<div class="card shadow-lg" id="profile-card">
|
||||||
@@ -102,43 +103,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 <token></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">
|
<script type="text/javascript">
|
||||||
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
|
||||||
var tokensById = {};
|
var tokensById = {};
|
||||||
|
|
||||||
function showSecret(secret){
|
// Shared "reveal secret once" display -- same pattern as jump-host's
|
||||||
document.getElementById('secretValue').value = secret;
|
// showToken(), which sso-manager-node also uses.
|
||||||
secretModal.show();
|
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(){
|
// Not the checkmark-flash technique some of this codebase's other copy
|
||||||
var el = document.getElementById('secretValue');
|
// buttons use -- FontAwesome replaces <i> icons with inline <svg>, so
|
||||||
el.select(); el.setSelectionRange(0, 99999);
|
// swapping the <i>'s class silently no-ops. A toast doesn't have that
|
||||||
try { document.execCommand('copy'); } catch(_){}
|
// 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){
|
function fmtTime(ms){
|
||||||
@@ -170,94 +161,162 @@
|
|||||||
|
|
||||||
function tableAJAX(){
|
function tableAJAX(){
|
||||||
app.apiToken.list(function(error, data){
|
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();
|
$.scope.apiTokenCard.empty();
|
||||||
(data.results || []).forEach(function(token){
|
tokens.forEach(function(token){
|
||||||
$.scope.apiTokenCard.push(processToken(token));
|
$.scope.apiTokenCard.push(processToken(token));
|
||||||
});
|
});
|
||||||
|
$('#api-tokens-empty').toggle(tokens.length === 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeToken(id, name, btn){
|
async function revokeToken(id, name, btn){
|
||||||
if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return;
|
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){
|
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);
|
$.scope.apiTokenCard.remove('id', id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateToken(id, name, btn){
|
async function rotateToken(id, name, btn){
|
||||||
if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return;
|
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){
|
app.apiToken.rotate({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');
|
||||||
showSecret(data.token);
|
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();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
tableAJAX();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="row mt-3">
|
<div class="row mt-3 justify-content-center">
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token
|
|
||||||
<a href="/docs/api-tokens" 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">
|
|
||||||
<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="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||||
<div class="card-header">
|
<span>
|
||||||
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
|
<a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
<small class="text-muted font-monospace">{{ id_short }}</small>
|
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header actionMessage" style="display:none"></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">
|
<div class="card-body">
|
||||||
{{ #description }}<p>{{ description }}</p>{{ /description }}
|
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
|
||||||
<dl class="row mb-0">
|
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
|
||||||
<dt class="col-sm-3">Token ID</dt>
|
<div class="card-header">
|
||||||
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
|
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
|
||||||
<dt class="col-sm-3">Created</dt>
|
<small class="text-muted font-monospace">{{ id_short }}</small>
|
||||||
<dd class="col-sm-9">{{{ created_display }}}</dd>
|
</div>
|
||||||
<dt class="col-sm-3">Last used</dt>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
|
<div class="card-body">
|
||||||
<dt class="col-sm-3">Expires</dt>
|
{{ #description }}<p>{{ description }}</p>{{ /description }}
|
||||||
<dd class="col-sm-9">{{{ expires_display }}}</dd>
|
<dl class="row mb-0">
|
||||||
</dl>
|
<dt class="col-sm-3">Token ID</dt>
|
||||||
</div>
|
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
|
||||||
<div class="card-footer">
|
<dt class="col-sm-3">Created</dt>
|
||||||
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
|
<dd class="col-sm-9">{{{ created_display }}}</dd>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title><%- name %> <%- title %></title>
|
<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 -->
|
<!-- 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 -->
|
<!-- CSS are placed here -->
|
||||||
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
<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">
|
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||||
@@ -14,63 +17,52 @@
|
|||||||
<!-- Scripts are placed here -->
|
<!-- Scripts are placed here -->
|
||||||
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
<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-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/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/@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/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-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-modules/moment/moment.js"></script>
|
||||||
<script type="text/javascript" src="/static/lib/js/app-base.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>
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
<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">
|
<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>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav top-nav">
|
<ul class="navbar-nav top-nav">
|
||||||
<li class="nav-item">
|
<%# Items gated on a group start hidden (.group-required) and are
|
||||||
<a class="nav-link active" href="/hosts">
|
revealed by app-base.js for the groups the user is in. %>
|
||||||
<i class="fa-solid fa-network-wired"></i>
|
<% for(const item of ui.nav){ %>
|
||||||
Hosts
|
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||||
</a>
|
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||||
</li>
|
<%- item.label %>
|
||||||
<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
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<% } %>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
<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>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</a>
|
</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;">
|
<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
|
Login
|
||||||
</a>
|
</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>
|
<i class="fas fa-sign-out"></i>
|
||||||
Log Out
|
Log Out
|
||||||
</button>
|
</button>
|
||||||
@@ -78,6 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
||||||
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
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
|
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
|
||||||
@@ -89,19 +82,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<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(){
|
function showUpdateBanner(){
|
||||||
let $nav = $('nav.fixed-top');
|
let $nav = $('nav.fixed-top');
|
||||||
let $banner = $('#update-banner');
|
let $banner = $('#update-banner');
|
||||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
$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(){
|
function dismissUpdateBanner(){
|
||||||
$('#update-banner').hide();
|
$('#update-banner').hide();
|
||||||
$('#spa-shell').css('margin-top', '');
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
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(){
|
$(document).ready(function(){
|
||||||
|
|
||||||
// Set the correct link to active in the top nav bar
|
// 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
|
// Set the correct login/logout button, and reveal the current user's
|
||||||
// items for global admins.
|
// name once we know who they are. Group-gated nav items are revealed
|
||||||
app.auth.isLoggedIn(function(error, data){
|
// by app-base.js off the same cached user/me.
|
||||||
if(data){
|
app.auth.isLoggedIn(function(error, me){
|
||||||
|
if(me){
|
||||||
$('#cl-logout-button').show();
|
$('#cl-logout-button').show();
|
||||||
if(data.username){
|
let username = me.uid || me.username;
|
||||||
$('#cl-username-text').text(data.username);
|
if(username){
|
||||||
|
$('#cl-username-text').text(username);
|
||||||
$('#cl-username').css('display', '');
|
$('#cl-username').css('display', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
if(me.isAdmin) checkForUpdate();
|
||||||
|
<% } %>
|
||||||
}else{
|
}else{
|
||||||
$('#cl-login-button').show();
|
$('#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 -->
|
<!-- Container -->
|
||||||
<div id="spa-shell" class="container-fluid">
|
<div id="spa-shell" class="container-fluid">
|
||||||
|
<div class="actionMessage" style="display:none;"></div>
|
||||||
|
|||||||
@@ -20,11 +20,16 @@
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
|
||||||
|
function processUser(user){
|
||||||
|
user.isExternal = user.backing === 'oidc';
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
function populateUsers(actionMessage){
|
function populateUsers(actionMessage){
|
||||||
app.user.list(function(error, data){
|
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){
|
for(let user of data.results){
|
||||||
$.scope.users.push(user);
|
$.scope.users.push(processUser(user));
|
||||||
}
|
}
|
||||||
$.scope.users.__put = function($el, item, list){
|
$.scope.users.__put = function($el, item, list){
|
||||||
$el.addClass('bg-success');
|
$el.addClass('bg-success');
|
||||||
@@ -37,11 +42,37 @@
|
|||||||
|
|
||||||
function removeUser(username){
|
function removeUser(username){
|
||||||
app.user.remove({username: username}, function(error, data){
|
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);
|
$.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(){
|
$(document).ready(function(){
|
||||||
populateUsers(); //populate the table
|
populateUsers(); //populate the table
|
||||||
|
|
||||||
@@ -54,50 +85,9 @@
|
|||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="row" style="display:none">
|
<div class="row" style="display:none">
|
||||||
<div class="col-md-4">
|
<div class="col-12">
|
||||||
<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">
|
|
||||||
<a href="/docs/access" 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>
|
|
||||||
|
|
||||||
<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="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
|
|
||||||
<div class="card-header text-center">
|
<div class="card-header text-center">
|
||||||
@@ -109,42 +99,50 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="float-end">
|
<span class="float-end">
|
||||||
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
<a href="/docs/access" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
<i class="fa-solid fa-circle-minus"></i>
|
<button type="button" class="btn btn-sm btn-success" onclick="userAddOpen()">
|
||||||
|
<i class="fa-solid fa-user-plus"></i>
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="table-responsive">
|
<div class="card-body">
|
||||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
<div class="row row-cols-1 row-cols-lg-2 g-3" id="user-cards">
|
||||||
<thead>
|
<div class="col" jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
|
||||||
<th>Name</th>
|
<div class="card shadow-sm h-100">
|
||||||
<th>Password</th>
|
<div class="card-body">
|
||||||
<th>Delete</th>
|
<h6 class="d-flex align-items-center mb-2">
|
||||||
</thead>
|
<i class="fa-solid fa-user me-2"></i>
|
||||||
<tbody>
|
{{ username }}
|
||||||
<tr jq-repeat="users" jq-repeat-index="username" style="display:none" >
|
{{#isExternal}}
|
||||||
<td class="align-middle">
|
<span class="badge text-bg-secondary ms-2" title="Provisioned via SSO login; no local password to manage here.">
|
||||||
{{ username }}
|
<i class="fa-solid fa-cloud"></i> External (SSO)
|
||||||
</td>
|
</span>
|
||||||
<td>
|
{{/isExternal}}
|
||||||
|
</h6>
|
||||||
|
|
||||||
<form class="input-group" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
|
{{^isExternal}}
|
||||||
<input type="password" name="password" class="form-control" placeholder="Change {{ username }} password" aria-label="Update password">
|
<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>
|
<button class="btn btn-warning" type="submit">Change</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{/isExternal}}
|
||||||
|
{{#isExternal}}
|
||||||
|
<p class="text-muted small mb-2">Authenticates via SSO -- cannot be edited here.</p>
|
||||||
|
{{/isExternal}}
|
||||||
|
|
||||||
</td>
|
<button type="button" class="btn btn-sm btn-danger" onclick="removeUser('{{username}}')">
|
||||||
<td class="align-middle">
|
|
||||||
<button type="button" class="btn btn-danger" onclick="removeUser('{{username}}')">
|
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
<i class="fa-solid fa-user-slash"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -9,19 +9,29 @@
|
|||||||
# update is just "sync the repo + reload" -- the files under /etc always track
|
# update is just "sync the repo + reload" -- the files under /etc always track
|
||||||
# the repo, so there is nothing to re-copy.
|
# the repo, so there is nothing to re-copy.
|
||||||
#
|
#
|
||||||
|
# Secrets live at $SECRETS_FILE (/etc/proxy/secrets.js by default), outside the
|
||||||
|
# repo checkout so they survive the hard reset below. First run seeds it from
|
||||||
|
# secrets.js.example (placeholders you must fill in); later runs never touch
|
||||||
|
# an existing file.
|
||||||
|
#
|
||||||
# Intended to be driven by CI/CD with no human writes on prod: the checkout is
|
# Intended to be driven by CI/CD with no human writes on prod: the checkout is
|
||||||
# hard-reset to origin/$BRANCH on every run, so the box deterministically mirrors
|
# hard-reset to origin/$BRANCH on every run, so the box deterministically mirrors
|
||||||
# the repo (any drift on the box is discarded).
|
# the repo (any drift on the box is discarded).
|
||||||
#
|
#
|
||||||
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=)
|
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
|
||||||
|
# SECRETS_FILE=)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Never block on an interactive git credential prompt in CI.
|
# Never block on an interactive git credential prompt in CI.
|
||||||
export GIT_TERMINAL_PROMPT=0
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
# Never block on an interactive debconf prompt (e.g. tzdata, pulled in as a
|
||||||
|
# dependency on a box that's never configured it).
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
REPO_URL="${REPO_URL:-https://github.com/theta42/proxy.git}"
|
REPO_URL="${REPO_URL:-https://github.com/theta42/proxy.git}"
|
||||||
REPO_DIR="${REPO_DIR:-/var/www/proxy}"
|
REPO_DIR="${REPO_DIR:-/opt/theta42/proxy}"
|
||||||
BRANCH="${BRANCH:-master}"
|
BRANCH="${BRANCH:-master}"
|
||||||
NODE_MAJOR=22
|
NODE_MAJOR=22
|
||||||
|
SECRETS_FILE="${SECRETS_FILE:-/etc/proxy/secrets.js}"
|
||||||
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
echo "This script must be run as root (try: sudo $0)" >&2
|
echo "This script must be run as root (try: sudo $0)" >&2
|
||||||
@@ -34,6 +44,19 @@ link(){
|
|||||||
echo "linked $2 -> $1"
|
echo "linked $2 -> $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Read the "version" field out of a package.json without depending on Node
|
||||||
|
# being installed yet (this runs before the Node.js install step below).
|
||||||
|
pkg_version(){
|
||||||
|
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installed version before this run touches anything, for the upgrade banner
|
||||||
|
# at the end. Empty on a fresh install (no prior checkout).
|
||||||
|
CURRENT_VERSION=""
|
||||||
|
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
|
||||||
|
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Base packages"
|
echo "==> Base packages"
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
@@ -100,6 +123,7 @@ apt-get install -y nodejs openresty
|
|||||||
echo "==> Lua modules"
|
echo "==> Lua modules"
|
||||||
luarocks install lua-resty-auto-ssl
|
luarocks install lua-resty-auto-ssl
|
||||||
luarocks install luasocket
|
luarocks install luasocket
|
||||||
|
luarocks install lua-resty-balancer
|
||||||
# CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua).
|
# CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua).
|
||||||
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
||||||
luarocks install lua-resty-ipmatcher
|
luarocks install lua-resty-ipmatcher
|
||||||
@@ -134,6 +158,20 @@ else
|
|||||||
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
|
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
|
||||||
|
|
||||||
|
echo "==> Secrets file at ${SECRETS_FILE}"
|
||||||
|
install -d -m 0750 "$(dirname "$SECRETS_FILE")"
|
||||||
|
if [ ! -f "$SECRETS_FILE" ]; then
|
||||||
|
cp "$REPO_DIR/secrets.js.example" "$SECRETS_FILE"
|
||||||
|
chmod 600 "$SECRETS_FILE"
|
||||||
|
echo " seeded ${SECRETS_FILE} from secrets.js.example -- EDIT IT before the proxy will work:"
|
||||||
|
echo " \$EDITOR ${SECRETS_FILE}"
|
||||||
|
echo " then re-run this script (or: sudo systemctl restart proxy)"
|
||||||
|
else
|
||||||
|
echo " ${SECRETS_FILE} already exists, leaving it untouched"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Symlink config from the repo"
|
echo "==> Symlink config from the repo"
|
||||||
install -d /etc/openresty/sites-enabled /var/log/nginx
|
install -d /etc/openresty/sites-enabled /var/log/nginx
|
||||||
link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf
|
link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf
|
||||||
@@ -162,4 +200,12 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Done. Update later with: sudo BRANCH=${BRANCH} $0"
|
echo "==> Done."
|
||||||
|
if [ -z "$CURRENT_VERSION" ]; then
|
||||||
|
echo " Installed v${NEW_VERSION}."
|
||||||
|
elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
|
||||||
|
echo " Already up to date (v${NEW_VERSION})."
|
||||||
|
else
|
||||||
|
echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
|
||||||
|
fi
|
||||||
|
echo " Update later with: sudo BRANCH=${BRANCH} $0"
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function M.get(ngx, domain, targetInfo)
|
|||||||
|
|
||||||
local json = require "cjson"
|
local json = require "cjson"
|
||||||
local redis = require "resty.redis"
|
local redis = require "resty.redis"
|
||||||
|
local roundrobin = require "resty.roundrobin"
|
||||||
|
|
||||||
if not domain then
|
if not domain then
|
||||||
return nil, 499
|
return nil, 499
|
||||||
@@ -95,6 +96,44 @@ function M.get(ngx, domain, targetInfo)
|
|||||||
return nil, 406
|
return nil, 406
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Load balancing
|
||||||
|
local target_list = {}
|
||||||
|
table.insert(target_list, res["ip"] .. ":" .. tostring(res["targetPort"]))
|
||||||
|
|
||||||
|
if res["targets"] and res["targets"] ~= "" and res["targets"] ~= "[]" then
|
||||||
|
local decodeOk, decodedTargets = pcall(json.decode, res["targets"])
|
||||||
|
if decodeOk and type(decodedTargets) == "table" then
|
||||||
|
for _, t in ipairs(decodedTargets) do
|
||||||
|
table.insert(target_list, t)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #target_list > 1 then
|
||||||
|
if not M.host_balancers then M.host_balancers = {} end
|
||||||
|
local cache_key = domain .. "_" .. (res["updated_on"] or "0")
|
||||||
|
|
||||||
|
if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then
|
||||||
|
local 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
|
ngx.ctx.targetInfo = res
|
||||||
-- Remember which host this target was resolved for, so the reuse guard at
|
-- Remember which host this target was resolved for, so the reuse guard at
|
||||||
-- the top can tell a genuine cache hit from a coalesced request for a
|
-- the top can tell a genuine cache hit from a coalesced request for a
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ Type=simple
|
|||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=1
|
RestartSec=1
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=/var/www/proxy/nodejs
|
WorkingDirectory=/opt/theta42/proxy/nodejs
|
||||||
Environment="NODE_ENV=production"
|
Environment="NODE_ENV=production"
|
||||||
ExecStart=/usr/bin/env node /var/www/proxy/nodejs/bin/www
|
Environment="CONF_SECRETS=/etc/proxy/secrets.js"
|
||||||
|
ExecStart=/usr/bin/env node /opt/theta42/proxy/nodejs/bin/www
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -6,13 +6,16 @@
|
|||||||
// direct LDAP client for user lookups. This file supplies that wiring.
|
// direct LDAP client for user lookups. This file supplies that wiring.
|
||||||
//
|
//
|
||||||
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
|
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
|
||||||
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks
|
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points the
|
||||||
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. No app_* env
|
// CONF_SECRETS env var at it so @simpleworkjs/conf reads it. No app_* env
|
||||||
// should be passed — app_* env beats this file in @simpleworkjs/conf, so the
|
// should be passed — app_* env beats this file in @simpleworkjs/conf, so the
|
||||||
// file is authoritative only if the matching app_* env is absent.
|
// file is authoritative only if the matching app_* env is absent.
|
||||||
//
|
//
|
||||||
// Bare-metal: copy to nodejs/conf/secrets.js and fill in your values. Values
|
// Bare-metal: ops/install.sh seeds this file at /etc/proxy/secrets.js on first
|
||||||
// here override conf/base.js and win over <environment>.js.
|
// run (with placeholders for the values it can't guess) and points the
|
||||||
|
// systemd unit's CONF_SECRETS env var at it. Fill in your values, then
|
||||||
|
// `sudo systemctl restart proxy`. Values here override conf/base.js and win
|
||||||
|
// over <environment>.js.
|
||||||
//
|
//
|
||||||
// Only the keys the app reads are listed below. The `stack` key is read by the
|
// Only the keys the app reads are listed below. The `stack` key is read by the
|
||||||
// theta-env orchestrator (setup.sh) and ignored by the app.
|
// theta-env orchestrator (setup.sh) and ignored by the app.
|
||||||
@@ -64,12 +67,14 @@ module.exports = {
|
|||||||
adminUsers: ['proxyadmin'],
|
adminUsers: ['proxyadmin'],
|
||||||
groupRoleMap: {},
|
groupRoleMap: {},
|
||||||
// Optional: the local anti-lockout admin's initial password, used
|
// Optional: the local anti-lockout admin's initial password, used
|
||||||
// ONLY the first time that account is created. Leave unset and it
|
// ONLY the first time that account is created. Leave unset and a
|
||||||
// defaults to the username itself ("proxyadmin2") — fine for a quick
|
// random password is generated and printed to the container log on
|
||||||
// local test, but change it (or set this) before exposing the proxy
|
// first boot — fine for a quick local test if you copy it from the
|
||||||
// publicly. Once the account exists, this key is never read again;
|
// log right away, but set this (or change the password afterward)
|
||||||
// change the password via the app itself (or delete the Redis user
|
// before exposing the proxy publicly. Once the account exists, this
|
||||||
// to force it to be re-bootstrapped with a new value here).
|
// key is never read again; change the password via the app itself
|
||||||
|
// (or delete the Redis user to force it to be re-bootstrapped with a
|
||||||
|
// new value here).
|
||||||
// localAdminPass: 'change-me',
|
// localAdminPass: 'change-me',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||