Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cc769cc3c | |||
| 94ff5c76eb | |||
| d74e3168ed | |||
| 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 |
+171
-1
@@ -1,3 +1,9 @@
|
|||||||
|
## v1.13.3
|
||||||
|
- fix: remove missing DEPLOYMENT.md and docs/ from Docker build context
|
||||||
|
|
||||||
|
## v1.13.2
|
||||||
|
- chore: Update CI pipeline integration
|
||||||
|
|
||||||
# Changelog
|
# 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 +12,164 @@ 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
|
## [1.1.16] - 2026-07-18
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -13,6 +177,10 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
- 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.
|
- 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.
|
- 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
|
### 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.
|
- 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.
|
- `DEPLOYMENT.md` and `docs/docker.md` now correctly describe the `CONF_SECRETS` env-var mechanism instead of the old symlink behavior.
|
||||||
@@ -123,7 +291,9 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
|||||||
- Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention.
|
- 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.15...HEAD
|
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.17...HEAD
|
||||||
|
[1.1.17]: https://github.com/theta42/proxy/compare/v1.1.16...v1.1.17
|
||||||
|
[1.1.16]: https://github.com/theta42/proxy/compare/v1.1.15...v1.1.16
|
||||||
[1.1.15]: https://github.com/theta42/proxy/compare/v1.1.14...v1.1.15
|
[1.1.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.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
|
||||||
|
|||||||
-287
@@ -1,287 +0,0 @@
|
|||||||
# Deployment Guide — theta42/proxy
|
|
||||||
|
|
||||||
The proxy is an OpenID Connect-protected reverse proxy (OpenResty front + Node
|
|
||||||
management app + Redis) that is **both** an OIDC client of an SSO Manager *and*
|
|
||||||
a direct LDAP client for user lookups. Two deployment methods:
|
|
||||||
|
|
||||||
1. **Docker** — a single all-in-one image bundling OpenResty + the app + Redis
|
|
||||||
(`docker compose up`).
|
|
||||||
2. **Bare metal** — `ops/install.sh` on Debian/Ubuntu (installs Node.js,
|
|
||||||
OpenResty, Lua modules, Redis, and a systemd unit).
|
|
||||||
|
|
||||||
## How configuration works
|
|
||||||
|
|
||||||
The app loads configuration via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf),
|
|
||||||
which deep-merges, in order:
|
|
||||||
|
|
||||||
1. `conf/base.js` (committed, generic defaults)
|
|
||||||
2. `conf/<NODE_ENV>.js` (optional)
|
|
||||||
3. `conf/secrets.js` (gitignored — secrets + per-deployment values)
|
|
||||||
4. **`app_*` environment variables** — the highest-precedence layer
|
|
||||||
|
|
||||||
Any env var whose name starts with `app_` overrides the merged config. The rest
|
|
||||||
of the name is split on **double-underscore** (`__`) into a nested path. Values
|
|
||||||
are `JSON.parse`-coerced when possible and kept as raw strings otherwise.
|
|
||||||
|
|
||||||
| Env var | Sets | Notes |
|
|
||||||
|---------|------|-------|
|
|
||||||
| `app_oidc__issuer` | `conf.oidc.issuer` | browser-facing SSO URL |
|
|
||||||
| `app_oidc__authorizationEndpoint` | `conf.oidc.authorizationEndpoint` | browser-facing |
|
|
||||||
| `app_oidc__tokenEndpoint` | `conf.oidc.tokenEndpoint` | server-to-server; can be internal |
|
|
||||||
| `app_oidc__userinfoEndpoint` | `conf.oidc.userinfoEndpoint` | server-to-server; can be internal |
|
|
||||||
| `app_oidc__endSessionEndpoint` | `conf.oidc.endSessionEndpoint` | browser-facing |
|
|
||||||
| `app_oidc__clientId` / `app_oidc__clientSecret` | OIDC client creds | register in the SSO first |
|
|
||||||
| `app_oidc__redirectUri` | `conf.oidc.redirectUri` | must match the SSO client exactly |
|
|
||||||
| `app_oidc__enabled` | `conf.oidc.enabled` | boolean |
|
|
||||||
| `app_ldap__url` | `conf.ldap.url` | `ldaps://…:636` or `ldap://…:389` |
|
|
||||||
| `app_ldap__bindDN` / `app_ldap__bindPassword` | LDAP service account | don't reuse the admin DN |
|
|
||||||
| `app_ldap__searchBase` / `app_ldap__userFilter` | user search | |
|
|
||||||
| `app_ldap__tlsOptions__rejectUnauthorized` | `conf.ldap.tlsOptions.rejectUnauthorized` | `false` for self-signed LDAPS |
|
|
||||||
| `app_ldap__tlsOptions__ca` | `conf.ldap.tlsOptions.ca` | path to a CA cert for strict trust |
|
|
||||||
| `app_auth__adminUsers` | `conf.auth.adminUsers` | local anti-lockout admin (uid) |
|
|
||||||
| `app_auth__adminGroups` | `conf.auth.adminGroups` | SSO/LDAP groups that are global admin (JSON array) |
|
|
||||||
| `app_auth__localAdminPass` | `conf.auth.localAdminPass` | initial password for the local anti-lockout admin (used once, on first creation only — defaults to the username itself if unset) |
|
|
||||||
| `app_redis__prefix` | `conf.redis.prefix` | default `proxy_` |
|
|
||||||
|
|
||||||
See [`docs/docker.md`](docs/docker.md) for a shorter, container-focused version
|
|
||||||
of this reference.
|
|
||||||
|
|
||||||
> **Requires `@simpleworkjs/conf` >= 1.1.0.** The Docker image will not honor
|
|
||||||
> `app_*` env vars on 1.0.0. The lock is already on `^1.1.0`; if you regenerate it:
|
|
||||||
> ```bash
|
|
||||||
> cd nodejs && npm install @simpleworkjs/conf@^1.1.0
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Method 1: Docker (all-in-one)
|
|
||||||
|
|
||||||
The image (`Dockerfile`) bundles OpenResty + the app + Redis in one container,
|
|
||||||
mirroring the bare-metal `ops/install.sh` layout. `docker-entrypoint.sh`:
|
|
||||||
generates the fallback SSL cert, parameterizes the OpenResty `resolver`/
|
|
||||||
`set_real_ip_from` directives, starts Redis + the node app, and execs OpenResty
|
|
||||||
in the foreground under `dumb-init`.
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
The bundled `docker-compose.yml` reads the OIDC + LDAP + auth wiring from a
|
|
||||||
bind-mounted `./config/proxy-secrets.js` (not from `app_*` env). Copy the
|
|
||||||
example, fill in your secrets, then build + start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p config && chmod 700 config
|
|
||||||
cp secrets.js.example config/proxy-secrets.js
|
|
||||||
$EDITOR config/proxy-secrets.js # set oidc.clientId/clientSecret, ldap.bindPassword, ...
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
`docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so
|
|
||||||
`@simpleworkjs/conf` reads it directly. No `app_*` env is passed — `app_*` env
|
|
||||||
would override the file (env beats secrets.js in `@simpleworkjs/conf`), so the
|
|
||||||
file is kept authoritative. `RESOLVER` / `REAL_IP_FROM` / `NODE_ENV` /
|
|
||||||
`NODE_PORT` are OpenResty-runtime / process env, not `app_*` config, so they
|
|
||||||
stay in the compose.
|
|
||||||
|
|
||||||
> Running the unified `theta-env` stack? Its `setup.sh` generates
|
|
||||||
> `./config/proxy-secrets.js` (+ `./config/sso-secrets.js`) for you and
|
|
||||||
> registers the OAuth client with the SSO — see the theta-env README.
|
|
||||||
|
|
||||||
### Access
|
|
||||||
|
|
||||||
- Proxy (public): `https://<host>/` — OpenResty front, auto-SSL (Let's Encrypt)
|
|
||||||
- Management UI / API: `http://127.0.0.1:3000/` (bound to localhost; the front
|
|
||||||
proxies the UI under its own TLS)
|
|
||||||
- Health: `http://127.0.0.1:3000/health` → `{"status":"ok"}`
|
|
||||||
|
|
||||||
### API tokens (personal access tokens)
|
|
||||||
|
|
||||||
Any logged-in user can mint a long-lived bearer token to call the management
|
|
||||||
API from scripts/CI/other services, without an OIDC browser session. Tokens are
|
|
||||||
self-service and authenticate **as their creator**: the creator's groups are
|
|
||||||
snapshotted at mint time (mirroring how the proxy's browser session captures
|
|
||||||
groups at login — the proxy never re-queries the IdP), and the existing authz
|
|
||||||
layer (`Permission.effectiveFor` / `roles.resolveEffective`) applies unchanged.
|
|
||||||
Local groups and owned-domain rights are recomputed live each request; only the
|
|
||||||
SSO/LDAP group membership is the mint-time snapshot.
|
|
||||||
|
|
||||||
Create one in the UI under **API Tokens** (the token string is shown **once**),
|
|
||||||
then use it as a bearer token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer prx_<id>_<secret>" https://proxy.example.com/api/host
|
|
||||||
```
|
|
||||||
|
|
||||||
Format: `prx_<id>_<secret>` — the `id` is the lookup key, the `secret` is
|
|
||||||
bcrypt-hashed and never stored in plaintext. Rotate or revoke from the same page
|
|
||||||
(immediate effect). Optional expiry (in days) at creation. Tokens persist in the
|
|
||||||
bundled Redis (AOF — see *Backups and restore*), so they survive rebuilds.
|
|
||||||
|
|
||||||
The token carries the creator's effective rights: a global admin's token can
|
|
||||||
manage Hosts/Users/Groups; a domain manager's token can manage their own
|
|
||||||
domains but `requireAdmin` routes return 403. To tighten permissions after group
|
|
||||||
changes, revoke and re-mint the token.
|
|
||||||
|
|
||||||
### OpenResty runtime env
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `RESOLVER` | `127.0.0.11` | DNS for upstream names in Host records (Docker's embedded DNS) |
|
|
||||||
| `REAL_IP_FROM` | _empty_ | Trusted CIDR for `X-Real-IP`. Empty = proxy is the front (removes the real_ip block). Set to an upstream proxy's CIDR if one sits in front. |
|
|
||||||
|
|
||||||
### Auto-SSL / Let's Encrypt
|
|
||||||
|
|
||||||
`lua-resty-auto-ssl` stores certs in the bundled Redis. Redis is now AOF+RDB
|
|
||||||
persisted to the `proxy-data` volume (not in-memory), so **Let's Encrypt certs
|
|
||||||
survive container recreation** — no re-issue / rate-limit on rebuild. Port 80 is
|
|
||||||
required for HTTP-01 challenges (mapped in the compose).
|
|
||||||
|
|
||||||
### Backups and restore
|
|
||||||
|
|
||||||
**What lives where**
|
|
||||||
|
|
||||||
| State | Location | Persisted? |
|
|
||||||
|-------|----------|------------|
|
|
||||||
| Host records, permissions, DNS creds, local users | `proxy-data` volume (`/data`, Redis) | yes (AOF + RDB) |
|
|
||||||
| Let's Encrypt certs (auto-ssl) | `proxy-data` volume (`/data`, Redis) | yes — same Redis |
|
|
||||||
| nginx response cache / logs | `proxy-cache` / `proxy-logs` volumes | yes (volume) |
|
|
||||||
| Secrets (OIDC client secret, LDAP bind password) | `./config/proxy-secrets.js` (bind mount) | your responsibility — back up off-host |
|
|
||||||
|
|
||||||
**Automatic snapshots** — when run as part of the unified `theta-env` stack,
|
|
||||||
`setup.sh` snapshots Redis + `./config/` to `./backups/<timestamp>/` before every
|
|
||||||
rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone deployments
|
|
||||||
should run `ops/backup.sh` the same way (on a cron/systemd timer, or by hand
|
|
||||||
before an upgrade):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./ops/backup.sh # keeps the last 5 by default
|
|
||||||
./ops/backup.sh 10 # or override retention
|
|
||||||
BACKUP_KEEP=10 ./ops/backup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
It snapshots Redis (`BGSAVE`, falling back to a synchronous `SAVE` if that
|
|
||||||
doesn't complete quickly) and `./config/` to `./backups/<timestamp>/`,
|
|
||||||
pruning older backups beyond the retention count — the same approach
|
|
||||||
`theta-env`'s `setup.sh` uses, just scoped to this one container. Equivalent
|
|
||||||
manual steps, if you'd rather not use the script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Redis — hot snapshot: trigger a save, then copy the RDB out
|
|
||||||
docker compose exec proxy redis-cli BGSAVE
|
|
||||||
docker compose cp proxy:/data/dump.rdb proxy-redis-$(date +%F).rdb
|
|
||||||
|
|
||||||
# Secrets — copy the config dir (holds OIDC client secret, LDAP bind password)
|
|
||||||
cp -a ./config config-backup-$(date +%F) && chmod 700 config-backup-$(date +%F)
|
|
||||||
```
|
|
||||||
Store the backup **off the host** — it contains secrets and the whole
|
|
||||||
Host/permission/user dataset.
|
|
||||||
|
|
||||||
**Restore — Redis (full proxy state + certs)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp -a config-backup-<date> ./config && chmod 700 ./config
|
|
||||||
docker compose up -d
|
|
||||||
docker compose stop proxy
|
|
||||||
# AOF wins on startup — delete it so the RDB is loaded instead (see note).
|
|
||||||
docker compose run --rm --no-deps --entrypoint sh proxy -c \
|
|
||||||
'rm -f /data/appendonly.aof /data/appendonly.aof.*'
|
|
||||||
docker compose cp proxy-redis-<date>.rdb proxy:/data/dump.rdb
|
|
||||||
docker compose start proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
> **AOF vs RDB (important):** with `--appendonly yes`, Redis loads
|
|
||||||
> `appendonly.aof` on startup and **ignores** `dump.rdb` if the AOF exists. To
|
|
||||||
> restore from an RDB snapshot you **must delete the AOF first** (the runbook
|
|
||||||
> does this); Redis then loads the RDB and writes a fresh AOF. Verify:
|
|
||||||
> `docker compose exec proxy redis-cli DBSIZE`.
|
|
||||||
>
|
|
||||||
> Restoring Redis restores cert state **at the snapshot time** — certs issued
|
|
||||||
> after the snapshot are lost and will be re-issued on next request.
|
|
||||||
|
|
||||||
**Upgrades**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./setup.sh # backs up, then rebuilds — proxy-data keeps Redis state
|
|
||||||
# (standalone) docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
Host records, permissions, DNS creds, local users, and Let's Encrypt certs all
|
|
||||||
survive the rebuild because they live on the `proxy-data` volume, not in the
|
|
||||||
image. **Migrations note:** if a release ships a `nodejs/migrations/` script,
|
|
||||||
run it after upgrading (it transforms in-Redis records); see the release notes.
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
OpenResty runs in the foreground and the Node app in the background, both
|
|
||||||
writing to the container's stdout/stderr. nginx access/error logs go to files
|
|
||||||
(`/var/log/nginx`, on the `proxy-logs` volume), so they do **not** appear in
|
|
||||||
`docker logs`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f proxy # app + OpenResty (stdout/stderr)
|
|
||||||
docker compose exec proxy tail -f /var/log/nginx/error.log # nginx errors
|
|
||||||
docker compose exec proxy tail -f /var/log/nginx/access.log # nginx access
|
|
||||||
docker compose logs --tail=200 --since=10m proxy # recent context
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Method 2: Bare metal (Debian/Ubuntu)
|
|
||||||
|
|
||||||
`ops/install.sh` is an idempotent installer: it installs Node.js 22.x, OpenResty
|
|
||||||
(from openresty.org), Lua modules (luarocks), Redis, force-syncs the repo to
|
|
||||||
`/opt/theta42/proxy`, symlinks the OpenResty + systemd config from the repo, and
|
|
||||||
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
|
|
||||||
sudo ./ops/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Configuration is file-based: on first run the installer seeds
|
|
||||||
`/etc/proxy/secrets.js` from `secrets.js.example` (placeholders you must fill
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fronting an SSO Manager
|
|
||||||
|
|
||||||
The proxy is a natural front for [`theta42/sso-manager-node`](https://github.com/theta42/sso-manager-node):
|
|
||||||
it terminates TLS for the SSO's UI and protects it with OIDC login, while also
|
|
||||||
binding to the SSO's LDAP directly for user lookups. To run both together:
|
|
||||||
|
|
||||||
1. **One Docker network** so the proxy can reach the SSO internally at
|
|
||||||
`http://sso-manager:3001` (token/userinfo, server-to-server) and
|
|
||||||
`ldaps://sso-manager:636` (LDAP).
|
|
||||||
2. **Set the SSO's `OAUTH_ISSUER`** to the browser-facing HTTPS URL the proxy
|
|
||||||
serves the SSO at (e.g. `https://sso.example.com`).
|
|
||||||
3. **Register the proxy as an OIDC client** in the SSO, with a `redirectUri`
|
|
||||||
matching the proxy's callback (`https://proxy.example.com/api/auth/oidc/callback`).
|
|
||||||
4. **LDAP**: point `app_ldap__url` at `ldaps://sso-manager:636` and create a
|
|
||||||
dedicated service account (`cn=ldapclient,ou=people,…`) — don't reuse the
|
|
||||||
admin DN. For the SSO's self-signed LDAPS cert, set
|
|
||||||
`app_ldap__tlsOptions__rejectUnauthorized=false` (or mount the cert and use
|
|
||||||
`app_ldap__tlsOptions__ca=<path>`).
|
|
||||||
|
|
||||||
The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo
|
|
||||||
automates all four steps with `./setup.sh`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security notes
|
|
||||||
|
|
||||||
1. **Never commit `secrets.js`** — it's in `.gitignore`.
|
|
||||||
2. **Bind the management port to localhost** (the compose does: `127.0.0.1:3000`).
|
|
||||||
The OpenResty front proxies the UI under TLS; don't expose 3000 to the LAN.
|
|
||||||
3. **`REAL_IP_FROM` empty by default** — the proxy trusts no one to set `X-Real-IP`
|
|
||||||
(it's the front). Only set it if a trusted proxy sits in front.
|
|
||||||
4. **LDAPS for any LDAP that crosses the network.** Use `ldaps://`/StartTLS; plain
|
|
||||||
`ldap://` is fine only on a private docker network.
|
|
||||||
5. The image runs OpenResty workers as `nobody` and the node app as root (matches
|
|
||||||
the bare-metal systemd unit). Harden to a non-root user for production if needed.
|
|
||||||
+1
-2
@@ -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,6 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -157,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.
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Documentation
|
|
||||||
|
|
||||||
This directory contains the GitHub Pages documentation site for the Proxy project.
|
|
||||||
|
|
||||||
**Live site:** https://theta42.github.io/proxy/
|
|
||||||
|
|
||||||
## Pages
|
|
||||||
|
|
||||||
- `index.md` - Home page with project overview
|
|
||||||
- `installation.md` - Installation and setup guide
|
|
||||||
- `api.md` - Complete API reference
|
|
||||||
- `architecture.md` - System architecture and design
|
|
||||||
- `contributing.md` - Development and contribution guide
|
|
||||||
|
|
||||||
## Local Preview
|
|
||||||
|
|
||||||
To preview the site locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Jekyll (one-time setup)
|
|
||||||
gem install jekyll bundler
|
|
||||||
|
|
||||||
# Run local server
|
|
||||||
cd docs
|
|
||||||
jekyll serve
|
|
||||||
|
|
||||||
# View at http://localhost:4000/proxy/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Theme
|
|
||||||
|
|
||||||
The site uses the Cayman theme (`jekyll-theme-cayman`). Configuration is in `_config.yml`.
|
|
||||||
|
|
||||||
## Updating Documentation
|
|
||||||
|
|
||||||
1. Edit markdown files in this directory
|
|
||||||
2. Commit and push to master branch
|
|
||||||
3. GitHub Pages automatically rebuilds (may take 1-2 minutes)
|
|
||||||
4. Changes visible at https://theta42.github.io/proxy/
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
title: Proxy
|
|
||||||
description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with an OIDC + LDAP-aware management API and web GUI.
|
|
||||||
url: "https://theta42.github.io"
|
|
||||||
baseurl: "/proxy"
|
|
||||||
logo: /assets/img/theta42.svg
|
|
||||||
lang: en_US
|
|
||||||
|
|
||||||
plugins:
|
|
||||||
- jekyll-seo-tag
|
|
||||||
- jekyll-sitemap
|
|
||||||
|
|
||||||
github:
|
|
||||||
repository_url: https://github.com/theta42/proxy
|
|
||||||
zip_url: https://github.com/theta42/proxy/archive/refs/heads/master.zip
|
|
||||||
tar_url: https://github.com/theta42/proxy/archive/refs/heads/master.tar.gz
|
|
||||||
repository_name: theta42/proxy
|
|
||||||
|
|
||||||
nav:
|
|
||||||
- title: Home
|
|
||||||
page: /
|
|
||||||
icon: fa-house
|
|
||||||
- title: Installation
|
|
||||||
page: /installation.html
|
|
||||||
icon: fa-download
|
|
||||||
- title: Architecture
|
|
||||||
page: /architecture.html
|
|
||||||
icon: fa-sitemap
|
|
||||||
- title: API
|
|
||||||
page: /api.html
|
|
||||||
icon: fa-code
|
|
||||||
- title: Docker
|
|
||||||
page: /docker.html
|
|
||||||
icon: fa-box
|
|
||||||
- title: Contributing
|
|
||||||
page: /contributing.html
|
|
||||||
icon: fa-code-branch
|
|
||||||
- title: Changelog
|
|
||||||
url: https://github.com/theta42/proxy/blob/master/CHANGELOG.md
|
|
||||||
icon: fa-list
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
- scope:
|
|
||||||
path: ""
|
|
||||||
type: "pages"
|
|
||||||
values:
|
|
||||||
layout: default
|
|
||||||
image: /assets/img/theta42.svg
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/favicon.svg' | relative_url }}">
|
|
||||||
|
|
||||||
{% seo title=false %}
|
|
||||||
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
|
||||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
|
||||||
</head>
|
|
||||||
<body class="d-flex flex-column min-vh-100">
|
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
|
||||||
<div class="container-fluid px-3">
|
|
||||||
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
|
|
||||||
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
|
|
||||||
{{ site.title }}
|
|
||||||
</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse justify-content-end" id="navMain">
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
{% for item in site.nav %}
|
|
||||||
<li class="nav-item">
|
|
||||||
{% if item.page %}
|
|
||||||
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
|
|
||||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
|
|
||||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="flex-grow-1" style="margin-top: 4.5rem;">
|
|
||||||
<div class="container-fluid py-4 py-md-5">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-12 col-lg-10 col-xl-8">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-body p-4 p-md-5 site-content">
|
|
||||||
{{ content }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="py-3 bg-dark text-light mt-auto">
|
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
|
|
||||||
<span class="d-flex align-items-center gap-2">
|
|
||||||
<a href="https://theta42.com" target="_blank" rel="noopener">
|
|
||||||
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
|
|
||||||
</a>
|
|
||||||
© {{ 'now' | date: '%Y' }} theta42 ·
|
|
||||||
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
|
|
||||||
</span>
|
|
||||||
<span class="d-flex align-items-center gap-3">
|
|
||||||
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
|
||||||
</a>
|
|
||||||
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
|
||||||
<i class="fa-solid fa-list"></i> Changelog
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-972
@@ -1,972 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: API Reference
|
|
||||||
description: The proxy's management REST API — hosts, DNS providers, users, groups, and permissions.
|
|
||||||
---
|
|
||||||
|
|
||||||
# API Documentation
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
|
|
||||||
All API endpoints require authentication unless otherwise noted. Three
|
|
||||||
authentication methods are supported:
|
|
||||||
|
|
||||||
- **`auth-token` header** — a browser-session token from `POST /api/auth/login`
|
|
||||||
or the OIDC flow (below).
|
|
||||||
- **`Authorization: Bearer <token>` header** — a self-service API token (PAT,
|
|
||||||
see [API Tokens](#api-tokens)), for scripts/CI without a browser session.
|
|
||||||
- **OIDC (browser)** — if the proxy is configured as an OIDC client of an SSO
|
|
||||||
(`app_oidc__*` / `conf.oidc`, see [DEPLOYMENT.md](https://github.com/theta42/proxy/blob/master/DEPLOYMENT.md)),
|
|
||||||
users can log in via `GET /api/auth/oidc/start` instead of posting a
|
|
||||||
username/password.
|
|
||||||
|
|
||||||
The proxy can also be configured as a **direct LDAP client** (`app_ldap__*` /
|
|
||||||
`conf.ldap`) for looking up/validating users, independent of the OIDC flow —
|
|
||||||
see DEPLOYMENT.md for the full configuration reference.
|
|
||||||
|
|
||||||
Authenticated requests also carry **RBAC** (role-based access control):
|
|
||||||
global admins can manage everything; other users are scoped to `viewer` or
|
|
||||||
`manager` rights on specific domains via [Permissions](#permissions) and
|
|
||||||
[Groups](#groups).
|
|
||||||
|
|
||||||
Base URL: `https://your-proxy-host.com/api`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
### Login
|
|
||||||
|
|
||||||
**POST** `/api/auth/login`
|
|
||||||
|
|
||||||
Authenticate a user and receive an auth token.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"username": "myuser", "password": "mypassword"}' \
|
|
||||||
https://proxy-host.com/api/auth/login
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"login": true, "token": "027d3964-7d81-4462-a6f9-2c1f9b40b4be", "message": "myuser logged in!"}`
|
|
||||||
- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}`
|
|
||||||
|
|
||||||
### Logout
|
|
||||||
|
|
||||||
**ALL** `/api/auth/logout`
|
|
||||||
|
|
||||||
Invalidate the current auth token.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
https://proxy-host.com/api/auth/logout
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Bye"}`
|
|
||||||
|
|
||||||
### OIDC Login (start)
|
|
||||||
|
|
||||||
**GET** `/api/auth/oidc/start`
|
|
||||||
|
|
||||||
Begin the OIDC authorization-code flow: creates a PKCE + state challenge and
|
|
||||||
redirects the browser to the configured SSO's authorize endpoint. Only
|
|
||||||
available when `conf.oidc.enabled` is true.
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `redirect` - Internal path to return to after login (optional; sanitized to same-origin)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -i "https://proxy-host.com/api/auth/oidc/start?redirect=/hosts"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `302` Redirect to the SSO's authorization endpoint
|
|
||||||
- `404` `{"name": "OidcDisabled", "message": "OIDC login is not enabled."}`
|
|
||||||
|
|
||||||
### OIDC Callback
|
|
||||||
|
|
||||||
**GET** `/api/auth/oidc/callback`
|
|
||||||
|
|
||||||
Redirect target for the SSO after login. Validates the one-time `state`,
|
|
||||||
exchanges the authorization `code` for tokens, reads identity from the
|
|
||||||
userinfo endpoint, establishes a session, and redirects the browser back to
|
|
||||||
the login page with the app's own `auth-token` in a URL fragment.
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `code` (required) - Authorization code from the SSO
|
|
||||||
- `state` (required) - State value from the `start` step
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Not called directly — the SSO redirects the browser here after login.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `302` Redirect to `/login#token=...&redirect=...`
|
|
||||||
- `400` `{"name": "OidcCallbackInvalid", "message": "Missing code or state."}` or expired/unknown state
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Tokens
|
|
||||||
|
|
||||||
Self-service personal access tokens (PATs) for scripting/CI without a browser
|
|
||||||
session. Every endpoint is owner-scoped: a user only sees/manages tokens they
|
|
||||||
created. Mounted at `/api/api-token`.
|
|
||||||
|
|
||||||
### List API Tokens
|
|
||||||
|
|
||||||
**GET** `/api/api-token`
|
|
||||||
|
|
||||||
List the current user's API tokens.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/api-token
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": [{"id": "...", "name": "ci", ...}, ...]}`
|
|
||||||
|
|
||||||
### Create API Token
|
|
||||||
|
|
||||||
**POST** `/api/api-token`
|
|
||||||
|
|
||||||
Create a new API token. The raw token string is only returned once, at
|
|
||||||
creation.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `name` (required) - Display name
|
|
||||||
- `description` (optional)
|
|
||||||
- `expires_in_days` (optional) - `0` or omitted means no expiry
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"name": "ci", "expires_in_days": 90}' \
|
|
||||||
https://proxy-host.com/api/api-token
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": {...}, "token": "prx_<id>_<secret>", "message": "API token 'ci' created. Save it now — it will not be shown again."}`
|
|
||||||
|
|
||||||
### Get API Token
|
|
||||||
|
|
||||||
**GET** `/api/api-token/:id`
|
|
||||||
|
|
||||||
Get a token's metadata (not the raw secret, which is never stored/returned again).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/api-token/<id>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": {...}}`
|
|
||||||
- `403` Not your token
|
|
||||||
|
|
||||||
### Update API Token
|
|
||||||
|
|
||||||
**PUT** `/api/api-token/:id`
|
|
||||||
|
|
||||||
Update a token's name/description/expiry.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X PUT \
|
|
||||||
-d '{"name": "ci-updated"}' \
|
|
||||||
https://proxy-host.com/api/api-token/<id>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": {...}, "message": "API token 'ci-updated' updated."}`
|
|
||||||
|
|
||||||
### Delete (Revoke) API Token
|
|
||||||
|
|
||||||
**DELETE** `/api/api-token/:id`
|
|
||||||
|
|
||||||
Revoke a token immediately.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X DELETE \
|
|
||||||
https://proxy-host.com/api/api-token/<id>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"id": "<id>", "message": "API token 'ci' revoked."}`
|
|
||||||
|
|
||||||
### Rotate API Token
|
|
||||||
|
|
||||||
**POST** `/api/api-token/:id/rotate`
|
|
||||||
|
|
||||||
Issue a new secret for an existing token (same id, new raw value shown once).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
https://proxy-host.com/api/api-token/<id>/rotate
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"token": "prx_<id>_<new-secret>", "message": "API token 'ci' rotated. Save it — it will not be shown again."}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Users
|
|
||||||
|
|
||||||
All user endpoints require authentication. `GET /me` and `PUT /password`
|
|
||||||
(self-service) work for any authenticated user; everything else (listing,
|
|
||||||
creating, deleting users, resetting another user's password) requires global
|
|
||||||
admin.
|
|
||||||
|
|
||||||
### List Users
|
|
||||||
|
|
||||||
**GET** `/api/user`
|
|
||||||
|
|
||||||
Get list of all users. Admin only.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/user
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `detail` - Include full user details (optional)
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": ["user1", "user2"]}`
|
|
||||||
- `200` `{"results": [{"username": "user1", ...}, ...]}` (with `?detail=true`)
|
|
||||||
- `403` Not an admin
|
|
||||||
|
|
||||||
### Get Current User
|
|
||||||
|
|
||||||
**GET** `/api/user/me`
|
|
||||||
|
|
||||||
Get the currently authenticated user's identity and effective RBAC rights
|
|
||||||
(drives the web UI's nav/button gating).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/user/me
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"username": "myuser", "groups": [...], "localGroups": [...], "externalGroups": [...], "isAdmin": false, "global": null, "domains": {...}}`
|
|
||||||
|
|
||||||
### Create User
|
|
||||||
|
|
||||||
**POST** `/api/user`
|
|
||||||
|
|
||||||
Create a new local user. Admin only.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"username": "newuser", "password": "newpassword"}' \
|
|
||||||
https://proxy-host.com/api/user
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` User created successfully
|
|
||||||
- `403` Not an admin
|
|
||||||
- `409` Username already exists
|
|
||||||
- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error (also returned for weak passwords)
|
|
||||||
|
|
||||||
### Delete User
|
|
||||||
|
|
||||||
**DELETE** `/api/user/:username`
|
|
||||||
|
|
||||||
Delete a user account. Admin only.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X DELETE \
|
|
||||||
https://proxy-host.com/api/user/olduser
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"username": "olduser", "results": ...}`
|
|
||||||
- `403` Not an admin
|
|
||||||
- `404` User not found
|
|
||||||
|
|
||||||
### Change Password (Self)
|
|
||||||
|
|
||||||
**PUT** `/api/user/password`
|
|
||||||
|
|
||||||
Change the password for the currently authenticated user.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X PUT \
|
|
||||||
-d '{"password": "newpassword"}' \
|
|
||||||
https://proxy-host.com/api/user/password
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": ...}` Password changed successfully
|
|
||||||
- `422` Weak password rejected by the password policy
|
|
||||||
|
|
||||||
### Change Password (Other User)
|
|
||||||
|
|
||||||
**PUT** `/api/user/password/:username`
|
|
||||||
|
|
||||||
Change the password for another user. Admin only.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X PUT \
|
|
||||||
-d '{"password": "newpassword"}' \
|
|
||||||
https://proxy-host.com/api/user/password/otheruser
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": ...}` Password changed successfully
|
|
||||||
- `403` Not an admin
|
|
||||||
- `404` User not found
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Permissions
|
|
||||||
|
|
||||||
RBAC: grants a `viewer` or `manager` role to a user or group, either globally
|
|
||||||
or scoped to one domain. Global-admin-only. Mounted at `/api/permission`.
|
|
||||||
|
|
||||||
### List Permissions
|
|
||||||
|
|
||||||
**GET** `/api/permission`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/permission
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": [{"id": "...", "subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com", ...}, ...]}`
|
|
||||||
|
|
||||||
### List Permission Subjects
|
|
||||||
|
|
||||||
**GET** `/api/permission/subjects`
|
|
||||||
|
|
||||||
Autocomplete source for the "Subject" field: known usernames plus known group
|
|
||||||
names (local groups, groups already used in permissions, and groups from
|
|
||||||
`conf.auth.adminGroups` / `conf.auth.groupRoleMap`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/permission/subjects
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"users": ["alice", "bob"], "groups": ["ops", "sre"]}`
|
|
||||||
|
|
||||||
### Create Permission
|
|
||||||
|
|
||||||
**POST** `/api/permission`
|
|
||||||
|
|
||||||
Grant a role to a subject.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `subjectType` (required) - `user` or `group`
|
|
||||||
- `subject` (required) - username or group name
|
|
||||||
- `role` (required) - `viewer` or `manager`
|
|
||||||
- `scope` (required) - `global` or `domain`
|
|
||||||
- `domain` (required if `scope` is `domain`)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com"}' \
|
|
||||||
https://proxy-host.com/api/permission
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Granted manager to user \"alice\" on example.com.", ...}`
|
|
||||||
- `422` Validation error
|
|
||||||
|
|
||||||
### Delete Permission
|
|
||||||
|
|
||||||
**DELETE** `/api/permission/:id`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X DELETE \
|
|
||||||
https://proxy-host.com/api/permission/<id>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Permission <id> removed."}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Groups
|
|
||||||
|
|
||||||
Local groups (independent of any SSO/LDAP groups) used as subjects for
|
|
||||||
permission grants. Global-admin-only. Mounted at `/api/group`.
|
|
||||||
|
|
||||||
### List Groups
|
|
||||||
|
|
||||||
**GET** `/api/group`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/group
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": [{"name": "ops", "members": ["alice", "bob"], ...}, ...]}`
|
|
||||||
|
|
||||||
### Create Group
|
|
||||||
|
|
||||||
**POST** `/api/group`
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `name` (required)
|
|
||||||
- `members` (optional) - array of usernames
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"name": "ops", "members": ["alice"]}' \
|
|
||||||
https://proxy-host.com/api/group
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Group \"ops\" created.", ...}`
|
|
||||||
|
|
||||||
### Delete Group
|
|
||||||
|
|
||||||
**DELETE** `/api/group/:name`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X DELETE \
|
|
||||||
https://proxy-host.com/api/group/ops
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Group \"ops\" removed."}`
|
|
||||||
|
|
||||||
### Add Group Member
|
|
||||||
|
|
||||||
**POST** `/api/group/:name/members`
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `username` (required)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"username": "bob"}' \
|
|
||||||
https://proxy-host.com/api/group/ops/members
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Added \"bob\" to \"ops\".", ...}`
|
|
||||||
|
|
||||||
### Remove Group Member
|
|
||||||
|
|
||||||
**DELETE** `/api/group/:name/members/:username`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X DELETE \
|
|
||||||
https://proxy-host.com/api/group/ops/members/bob
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Removed \"bob\" from \"ops\".", ...}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hosts
|
|
||||||
|
|
||||||
Manage proxy host configurations.
|
|
||||||
|
|
||||||
### List Hosts
|
|
||||||
|
|
||||||
**GET** `/api/host`
|
|
||||||
|
|
||||||
Get list of all configured hosts.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/host
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `detail` - Include full host details (optional)
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": ["example.com", "*.wildcard.com"]}`
|
|
||||||
- `200` `{"results": [{"host": "example.com", "ip": "192.168.1.10", ...}, ...]}` (with `?detail=true`)
|
|
||||||
|
|
||||||
### Get Host
|
|
||||||
|
|
||||||
**GET** `/api/host/:host`
|
|
||||||
|
|
||||||
Get configuration for a specific host.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/host/example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"item": "example.com", "results": {"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, ...}}`
|
|
||||||
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
|
|
||||||
|
|
||||||
### Lookup Host
|
|
||||||
|
|
||||||
**GET** `/api/host/lookup/:domain`
|
|
||||||
|
|
||||||
Test the host lookup algorithm (supports wildcard matching).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/host/lookup/sub.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"string": "sub.example.com", "results": {"host": "*.example.com", ...}}`
|
|
||||||
- `200` `{"string": "sub.example.com", "results": null}` (no match)
|
|
||||||
|
|
||||||
### Get Lookup Tree
|
|
||||||
|
|
||||||
**GET** `/api/host/lookupobj`
|
|
||||||
|
|
||||||
Get the internal lookup tree structure (for debugging).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/host/lookupobj
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": {"com": {"example": {...}}}}`
|
|
||||||
|
|
||||||
### Create Host
|
|
||||||
|
|
||||||
**POST** `/api/host`
|
|
||||||
|
|
||||||
Add a new host configuration.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `host` (required) - Domain name (e.g., `example.com`, `*.example.com`)
|
|
||||||
- `ip` (required) - Target IP address or FQDN
|
|
||||||
- `targetPort` (required) - Target port number (1-65535)
|
|
||||||
- `forcessl` (optional) - Force HTTPS redirect (default: true)
|
|
||||||
- `targetssl` (optional) - Use HTTPS to backend (default: false)
|
|
||||||
- `challengeType` (optional) - For wildcards: `DNS-01-wildcard` or `wildcardChild`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, "forcessl": true, "targetssl": false}' \
|
|
||||||
https://proxy-host.com/api/host
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "\"example.com\" added.", "host": "example.com", ...}`
|
|
||||||
- `409` `{"name": "HostNameUsed", "message": "Host already exists"}`
|
|
||||||
- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error
|
|
||||||
|
|
||||||
### Update Host
|
|
||||||
|
|
||||||
**PUT** `/api/host/:host`
|
|
||||||
|
|
||||||
Update an existing host configuration.
|
|
||||||
|
|
||||||
**Parameters:** Same as Create Host (all optional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X PUT \
|
|
||||||
-d '{"ip": "192.168.1.20", "targetPort": 9000}' \
|
|
||||||
https://proxy-host.com/api/host/example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "\"example.com\" updated.", ...}`
|
|
||||||
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
|
|
||||||
- `422` Validation error
|
|
||||||
|
|
||||||
### Delete Host
|
|
||||||
|
|
||||||
**DELETE** `/api/host/:host`
|
|
||||||
|
|
||||||
Remove a host configuration.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X DELETE \
|
|
||||||
https://proxy-host.com/api/host/example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "example.com deleted", ...}`
|
|
||||||
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
|
|
||||||
|
|
||||||
### Clear Host Cache
|
|
||||||
|
|
||||||
**DELETE** `/api/host/cache`
|
|
||||||
|
|
||||||
Remove all cached wildcard-subdomain host lookups. Cache entries are created on
|
|
||||||
demand when a wildcard host serves a subdomain; clearing them forces the next
|
|
||||||
request for each subdomain to be resolved fresh through the lookup tree.
|
|
||||||
Admin only.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X DELETE \
|
|
||||||
https://proxy-host.com/api/host/cache
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Cleared 3 cached hosts.", "count": 3}`
|
|
||||||
|
|
||||||
### Renew Wildcard Certificate
|
|
||||||
|
|
||||||
**PUT** `/api/host/:host/renew`
|
|
||||||
|
|
||||||
Manually trigger wildcard certificate renewal.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X PUT \
|
|
||||||
https://proxy-host.com/api/host/*.example.com/renew
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Requesting wildcard cert for *.example.com"}`
|
|
||||||
- `404` Host not found
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DNS Providers
|
|
||||||
|
|
||||||
Manage DNS provider integrations for wildcard SSL certificates.
|
|
||||||
|
|
||||||
### List DNS Providers
|
|
||||||
|
|
||||||
**GET** `/api/dns`
|
|
||||||
|
|
||||||
Get list of configured DNS providers.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/dns
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `detail` - Include full provider details (optional)
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": ["provider-id-1", "provider-id-2"]}`
|
|
||||||
|
|
||||||
### List Available Provider Types
|
|
||||||
|
|
||||||
**OPTIONS** `/api/dns`
|
|
||||||
|
|
||||||
Get list of supported DNS provider types and their configuration requirements.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X OPTIONS \
|
|
||||||
https://proxy-host.com/api/dns
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}`
|
|
||||||
|
|
||||||
### Create DNS Provider
|
|
||||||
|
|
||||||
**POST** `/api/dns`
|
|
||||||
|
|
||||||
Configure a new DNS provider.
|
|
||||||
|
|
||||||
**Cloudflare:**
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"name": "My Cloudflare", "dnsProvider": "Cloudflare", "token": "your-api-token"}' \
|
|
||||||
https://proxy-host.com/api/dns
|
|
||||||
```
|
|
||||||
|
|
||||||
**DigitalOcean:**
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"name": "My DO", "dnsProvider": "DigitalOcean", "token": "your-api-token"}' \
|
|
||||||
https://proxy-host.com/api/dns
|
|
||||||
```
|
|
||||||
|
|
||||||
**PorkBun:**
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"name": "My PorkBun", "dnsProvider": "PorkBun", "apiKey": "pk_xxx", "secretApiKey": "sk_xxx"}' \
|
|
||||||
https://proxy-host.com/api/dns
|
|
||||||
```
|
|
||||||
|
|
||||||
**DuckDNS (free):**
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "subdomains": "myhost,myhost2"}' \
|
|
||||||
https://proxy-host.com/api/dns
|
|
||||||
```
|
|
||||||
|
|
||||||
`subdomains` is a comma-separated list of the subdomains you've registered at
|
|
||||||
[duckdns.org](https://www.duckdns.org) (e.g. `myhost` for
|
|
||||||
`myhost.duckdns.org`), since DuckDNS has no API to list them for you.
|
|
||||||
DuckDNS only supports one A/AAAA record and one TXT record per domain (no
|
|
||||||
arbitrary sub-records) — enough for dynamic DNS and DNS-01 wildcard certs.
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "\"provider-id\" added.", ...}`
|
|
||||||
- `422` Validation error or invalid API credentials
|
|
||||||
|
|
||||||
### Get DNS Provider
|
|
||||||
|
|
||||||
**GET** `/api/dns/:id`
|
|
||||||
|
|
||||||
Get a specific DNS provider configuration.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/dns/provider-id
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"item": "provider-id", "results": {...}}`
|
|
||||||
- `404` Provider not found
|
|
||||||
|
|
||||||
### Update DNS Provider
|
|
||||||
|
|
||||||
**PUT** `/api/dns/:id`
|
|
||||||
|
|
||||||
Update DNS provider configuration.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X PUT \
|
|
||||||
-d '{"name": "Updated Name"}' \
|
|
||||||
https://proxy-host.com/api/dns/provider-id
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "\"provider-id\" updated.", ...}`
|
|
||||||
- `404` Provider not found
|
|
||||||
|
|
||||||
### Delete DNS Provider
|
|
||||||
|
|
||||||
**DELETE** `/api/dns/:id`
|
|
||||||
|
|
||||||
Remove a DNS provider and all associated domains.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X DELETE \
|
|
||||||
https://proxy-host.com/api/dns/provider-id
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "provider-id deleted", ...}`
|
|
||||||
- `404` Provider not found
|
|
||||||
|
|
||||||
### List Domains
|
|
||||||
|
|
||||||
**GET** `/api/dns/domain`
|
|
||||||
|
|
||||||
List all domains from all configured providers.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/dns/domain
|
|
||||||
```
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `detail` - Include full domain details (optional)
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": ["example.com", "test.com"]}`
|
|
||||||
|
|
||||||
### Get Domain
|
|
||||||
|
|
||||||
**GET** `/api/dns/domain/:domain`
|
|
||||||
|
|
||||||
Get details for a specific domain.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/dns/domain/example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": [{"domain": "example.com", "zoneId": "...", ...}]}`
|
|
||||||
- `404` Domain not found
|
|
||||||
|
|
||||||
### Refresh Domains
|
|
||||||
|
|
||||||
**POST** `/api/dns/domain/refresh/:providerId`
|
|
||||||
|
|
||||||
Refresh the domain list from a DNS provider's API.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
https://proxy-host.com/api/dns/domain/refresh/provider-id
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": ...}` Updated domain list
|
|
||||||
- `404` Provider not found
|
|
||||||
|
|
||||||
### Dynamic DNS
|
|
||||||
|
|
||||||
A-records kept automatically pointed at this box's public (WAN) IP. All
|
|
||||||
`/api/dns/dynamic*` routes are viewer/manager scoped to the record's domain
|
|
||||||
(via [Permissions](#permissions)), not admin-only like the rest of `/api/dns`.
|
|
||||||
|
|
||||||
#### Get Current Public IP
|
|
||||||
|
|
||||||
**GET** `/api/dns/dynamic/ip`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/dns/dynamic/ip
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"ip": "203.0.113.5"}`
|
|
||||||
|
|
||||||
#### List Dynamic Records
|
|
||||||
|
|
||||||
**GET** `/api/dns/dynamic`
|
|
||||||
|
|
||||||
Lists records the caller may view (their own/granted domains, or all for admins).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/dns/dynamic
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"results": [{"id": "...", "domain": "example.com", "name": "home", "last_status": "ok", ...}, ...]}`
|
|
||||||
|
|
||||||
#### Create Dynamic Record
|
|
||||||
|
|
||||||
**POST** `/api/dns/dynamic`
|
|
||||||
|
|
||||||
Requires `manager` rights on the target domain. Applies the record immediately
|
|
||||||
against the current public IP (best-effort — failures are recorded in
|
|
||||||
`last_status` and retried by the scheduler).
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `domain` (required)
|
|
||||||
- `name` (required) - sub-label, or `@` for the apex
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"domain": "example.com", "name": "home"}' \
|
|
||||||
https://proxy-host.com/api/dns/dynamic
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "\"home.example.com\" added.", ...}`
|
|
||||||
- `403` Missing `manager` rights on the domain
|
|
||||||
- `422` Validation error
|
|
||||||
|
|
||||||
#### Refresh Dynamic Record
|
|
||||||
|
|
||||||
**POST** `/api/dns/dynamic/:id/refresh`
|
|
||||||
|
|
||||||
Force an immediate refresh of one record against the current public IP.
|
|
||||||
Requires `manager` rights on the record's domain.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X POST \
|
|
||||||
https://proxy-host.com/api/dns/dynamic/<id>/refresh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "Refreshed \"home.example.com\".", "result": {...}}`
|
|
||||||
- `403` Missing `manager` rights on the domain
|
|
||||||
|
|
||||||
#### Delete Dynamic Record
|
|
||||||
|
|
||||||
**DELETE** `/api/dns/dynamic/:id`
|
|
||||||
|
|
||||||
Stop managing a record. Requires `manager` rights on the record's domain.
|
|
||||||
Leaves the provider's A record in place at its last value.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
-X DELETE \
|
|
||||||
https://proxy-host.com/api/dns/dynamic/<id>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` `{"message": "home.example.com removed.", ...}`
|
|
||||||
- `403` Missing `manager` rights on the domain
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Certificates
|
|
||||||
|
|
||||||
Retrieve SSL certificate information.
|
|
||||||
|
|
||||||
### Get Certificate
|
|
||||||
|
|
||||||
**GET** `/api/cert/:host`
|
|
||||||
|
|
||||||
Get the SSL certificate for a host.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "auth-token: your-token-here" \
|
|
||||||
https://proxy-host.com/api/cert/example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responses:**
|
|
||||||
- `200` Certificate data including `cert_pem`, `fullchain_pem`, `privkey_pem`, expiry information
|
|
||||||
- `404` Certificate not found
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
All endpoints may return the following error responses:
|
|
||||||
|
|
||||||
- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}` - Authentication required or invalid
|
|
||||||
- `404` `{"name": "NotFound", "message": "..."}` - Resource not found
|
|
||||||
- `422` `{"name": "ObjectValidateError", "message": [...], "keys": [...]}` - Validation errors
|
|
||||||
- `500` Internal server error
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- All timestamps are in milliseconds since epoch
|
|
||||||
- Authenticated endpoints accept either the `auth-token` header (browser
|
|
||||||
session / OIDC login) or an `Authorization: Bearer <token>` API token
|
|
||||||
- Host names support wildcards: `*` (single level) and `**` (multi-level)
|
|
||||||
- DNS providers are validated on creation - invalid API credentials will be rejected
|
|
||||||
- Wildcard certificates are automatically renewed 30 days before expiration
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Architecture
|
|
||||||
description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit together.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Architecture
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
|
|
||||||
> Looking for a plainer explanation of hosts, HTTPS, or the local
|
|
||||||
> permission model instead of internals? See
|
|
||||||
> [Hosts & HTTPS](concepts-hosts.html) and
|
|
||||||
> [Users, Groups & Permissions](concepts-access.html).
|
|
||||||
|
|
||||||
## System Overview
|
|
||||||
|
|
||||||
The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
|
||||||
│ Internet │
|
|
||||||
└─────────────────────────┬────────────────────────────────────┘
|
|
||||||
│ HTTPS/HTTP
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
|
||||||
│ OpenResty/Nginx │
|
|
||||||
│ ┌────────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
|
||||||
│ │ SSL Termination│ │ Host Routing │ │ Request Proxying│ │
|
|
||||||
│ │ (lua-resty- │ │ (targetinfo. │ │ │ │
|
|
||||||
│ │ auto-ssl) │ │ lua) │ │ │ │
|
|
||||||
│ └────────────────┘ └──────┬───────┘ └─────────────────┘ │
|
|
||||||
└────────────┬──────────────────┼───────────────────────────┬──┘
|
|
||||||
│ │ │
|
|
||||||
Let's Encrypt 1. Check Redis FIRST Backend
|
|
||||||
HTTP-01 2. Unix Socket (fallback) Services
|
|
||||||
│ │ │
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌──────────────────────┐ ┌──────────────────────────────────┐
|
|
||||||
│ Redis │ │ Node.js Application │
|
|
||||||
│ (Primary Cache) │ │ ┌──────────────┐ ┌─────────┐ │
|
|
||||||
│ - Host configs ◄────┼──┼──┤ Services │ │ Routes │ │
|
|
||||||
│ - User accounts │ │ │ - host_lookup│ │ - /api/*│ │
|
|
||||||
│ - SSL certs │ │ │ - scheduler │ │ │ │
|
|
||||||
│ - Auth tokens │ │ └──────────────┘ └─────────┘ │
|
|
||||||
└──────────────────────┘ └─────────┬────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ DNS Providers │
|
|
||||||
│ - Cloudflare │
|
|
||||||
│ - DigitalOcean │
|
|
||||||
│ - PorkBun │
|
|
||||||
│ - DuckDNS (free) │
|
|
||||||
│ (DNS-01 challenges) │
|
|
||||||
└──────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Details
|
|
||||||
|
|
||||||
### OpenResty/Nginx (Frontend)
|
|
||||||
|
|
||||||
**Responsibilities:**
|
|
||||||
- Accept incoming HTTP/HTTPS requests
|
|
||||||
- SSL termination using lua-resty-auto-ssl
|
|
||||||
- Host-based routing decisions (Redis-first lookup)
|
|
||||||
- Proxy requests to backend services
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- HTTP-01 ACME challenge handling for automatic SSL
|
|
||||||
- Redis-first host lookup with Node.js fallback via Unix socket
|
|
||||||
- High-performance event-driven architecture
|
|
||||||
- Support for WebSocket connections
|
|
||||||
- Continues serving cached hosts even if Node.js is down
|
|
||||||
|
|
||||||
**Configuration Files:**
|
|
||||||
- `/etc/openresty/nginx.conf` - Main configuration
|
|
||||||
- `/etc/openresty/autossl.conf` - Let's Encrypt integration
|
|
||||||
- `/etc/openresty/sites-enabled/000-proxy` - Proxy configuration
|
|
||||||
- `/usr/local/openresty/lualib/targetinfo.lua` - Host lookup module
|
|
||||||
|
|
||||||
### Node.js Application (Backend)
|
|
||||||
|
|
||||||
**Responsibilities:**
|
|
||||||
- API for host/user/DNS management
|
|
||||||
- Wildcard SSL certificate orchestration
|
|
||||||
- Host lookup tree maintenance
|
|
||||||
- User authentication and authorization
|
|
||||||
|
|
||||||
**Directory Structure:**
|
|
||||||
```
|
|
||||||
nodejs/
|
|
||||||
├── bin/www # Application entry point
|
|
||||||
├── conf/ # Configuration (base.js, environment overlays, secrets.js)
|
|
||||||
├── controller/ # App-level wiring (pubsub, startup)
|
|
||||||
├── migrations/ # One-off Redis data migration scripts
|
|
||||||
├── models/ # Data models
|
|
||||||
│ ├── host.js # Host configuration and lookup
|
|
||||||
│ ├── auth.js # Authentication logic
|
|
||||||
│ ├── user.js # User management
|
|
||||||
│ └── dns_provider/ # DNS provider implementations
|
|
||||||
├── routes/ # API endpoints
|
|
||||||
│ ├── host.js # Host CRUD operations
|
|
||||||
│ ├── dns.js # DNS provider management
|
|
||||||
│ ├── user.js # User management
|
|
||||||
│ ├── auth.js # Authentication (login + OIDC)
|
|
||||||
│ ├── permission.js # RBAC permission management
|
|
||||||
│ ├── group.js # Local group management
|
|
||||||
│ └── api_token.js # Self-service API (PAT) tokens
|
|
||||||
├── services/ # Background services
|
|
||||||
│ ├── host_lookup.js # Unix socket server
|
|
||||||
│ └── host_scheduler.js # Cert renewal scheduler
|
|
||||||
├── middleware/ # Express middleware
|
|
||||||
│ └── auth.js # Authentication middleware
|
|
||||||
└── utils/ # Utility modules
|
|
||||||
└── unix_socket_json.js # Unix socket server
|
|
||||||
```
|
|
||||||
|
|
||||||
### Redis (Data Store)
|
|
||||||
|
|
||||||
**ORM:** [model-redis](https://www.npmjs.com/package/model-redis) - A lightweight Redis ORM for Node.js with schema validation, relationships, and automatic key management.
|
|
||||||
|
|
||||||
**Stored Data:**
|
|
||||||
- Host configurations (domain, IP, port, SSL settings)
|
|
||||||
- User accounts and hashed passwords
|
|
||||||
- Authentication tokens
|
|
||||||
- SSL certificates (for wildcard domains)
|
|
||||||
- DNS provider credentials
|
|
||||||
- Domain-to-provider mappings
|
|
||||||
|
|
||||||
**Key Prefixes:**
|
|
||||||
```
|
|
||||||
proxy_Host_<hostname> # Host configuration
|
|
||||||
proxy_User_<username> # User account
|
|
||||||
proxy_AuthToken_<token> # Auth tokens
|
|
||||||
proxy_DnsProvider_<id> # DNS provider
|
|
||||||
proxy_Domain_<domain> # Domain info
|
|
||||||
<hostname>:latest # SSL certificate cache
|
|
||||||
```
|
|
||||||
|
|
||||||
## Request Flow
|
|
||||||
|
|
||||||
### Standard HTTP/HTTPS Request
|
|
||||||
|
|
||||||
1. **Client** sends HTTPS request to `app.example.com`
|
|
||||||
2. **OpenResty** receives request, terminates SSL
|
|
||||||
3. **Lua script** (`targetinfo.lua`) queries **Redis first** for host config
|
|
||||||
4. If **found in Redis**, jump to step 7 (Node.js not involved)
|
|
||||||
5. If **not in Redis**, Lua queries Node.js via Unix socket as fallback
|
|
||||||
6. **Node.js** performs host lookup (supports wildcards), caches result in Redis
|
|
||||||
7. **OpenResty** proxies request to backend service using target IP and port
|
|
||||||
8. **Response** proxied back to client
|
|
||||||
|
|
||||||
**Resilience**: If Node.js goes down, all hosts already cached in Redis continue to work. Only new/uncached hosts will fail until Node.js recovers.
|
|
||||||
|
|
||||||
### Wildcard SSL Certificate Request
|
|
||||||
|
|
||||||
1. **User** creates wildcard host (`*.example.com`) via API
|
|
||||||
2. **Node.js** validates domain has DNS provider configured
|
|
||||||
3. **Let's Encrypt** DNS-01 challenge initiated
|
|
||||||
4. **DNS provider** API creates TXT record (`_acme-challenge.example.com`)
|
|
||||||
5. **Let's Encrypt** validates TXT record
|
|
||||||
6. **Certificate** generated and stored in Redis
|
|
||||||
7. **DNS provider** cleans up TXT record
|
|
||||||
8. **Background scheduler** monitors expiration, renews 30 days before expiry
|
|
||||||
|
|
||||||
## Host Lookup Algorithm
|
|
||||||
|
|
||||||
The lookup tree enables sophisticated domain matching:
|
|
||||||
|
|
||||||
```
|
|
||||||
Input: "api.v1.example.com"
|
|
||||||
|
|
||||||
Tree Structure:
|
|
||||||
{
|
|
||||||
"com": {
|
|
||||||
"example": {
|
|
||||||
"*": { // Matches api.example.com
|
|
||||||
"#record": {...}
|
|
||||||
},
|
|
||||||
"v1": {
|
|
||||||
"api": { // Matches api.v1.example.com (exact)
|
|
||||||
"#record": {...}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Priority: Exact > Single wildcard (*) > Double wildcard (**)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Wildcard Types:**
|
|
||||||
- `example.com` - Exact match only
|
|
||||||
- `*.example.com` - Matches `sub.example.com` (single level)
|
|
||||||
- `**.example.com` - Matches any depth (`sub.deep.example.com`)
|
|
||||||
- `api.*.example.com` - Matches `api.v1.example.com`, `api.v2.example.com`
|
|
||||||
|
|
||||||
## Security Architecture
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
|
|
||||||
1. User sends credentials to `/api/auth/login`
|
|
||||||
2. Credentials validated against stored hash (bcrypt)
|
|
||||||
3. Token generated and stored in Redis with TTL
|
|
||||||
4. Token returned to client
|
|
||||||
5. Subsequent requests include token in `auth-token` header
|
|
||||||
6. Middleware validates token before processing request
|
|
||||||
|
|
||||||
### SSL Certificate Security
|
|
||||||
|
|
||||||
- **Private keys** stored only in Redis (memory/disk based on config)
|
|
||||||
- **Fallback certificates** used when SNI unavailable
|
|
||||||
- **Let's Encrypt** rate limiting respected
|
|
||||||
- **DNS provider credentials** marked as `isPrivate` (not returned in API)
|
|
||||||
|
|
||||||
### Unix Socket Communication
|
|
||||||
|
|
||||||
- Socket file: `/var/run/proxy_lookup.socket`
|
|
||||||
- Permissions: `777` (container-safe, single-use deployment)
|
|
||||||
- Protocol: JSON over Unix stream socket
|
|
||||||
- Buffer handling: Accumulates partial messages until complete JSON
|
|
||||||
|
|
||||||
## Performance Optimizations
|
|
||||||
|
|
||||||
### Caching Strategy
|
|
||||||
|
|
||||||
The system uses a multi-tier caching approach:
|
|
||||||
|
|
||||||
1. **Redis (L1 Cache)** - OpenResty checks Redis FIRST for every request
|
|
||||||
- Primary host configuration storage
|
|
||||||
- Survives Node.js restarts/failures
|
|
||||||
- Shared across all OpenResty workers
|
|
||||||
|
|
||||||
2. **Node.js Lookup Tree (L2 Cache)** - In-memory host lookup with wildcard matching
|
|
||||||
- Only queried when Redis has no entry
|
|
||||||
- Rebuilt automatically when hosts change
|
|
||||||
- Supports complex wildcard resolution
|
|
||||||
|
|
||||||
3. **Wildcard Parent Caching** - Resolved wildcard matches stored back to Redis
|
|
||||||
- Subsequent requests to `api.example.com` hit Redis directly
|
|
||||||
- No repeated wildcard resolution needed
|
|
||||||
|
|
||||||
### Unix Socket vs HTTP API
|
|
||||||
|
|
||||||
Unix socket chosen over HTTP for host lookups:
|
|
||||||
- **Lower latency** - No TCP overhead
|
|
||||||
- **Higher throughput** - No HTTP parsing
|
|
||||||
- **Simpler** - Direct JSON communication
|
|
||||||
- **Secure** - Filesystem permissions, no network exposure
|
|
||||||
|
|
||||||
## Scalability Considerations
|
|
||||||
|
|
||||||
### Current Architecture
|
|
||||||
|
|
||||||
- **Single instance** - OpenResty + Node.js + Redis on one server
|
|
||||||
- **Vertical scaling** - Add CPU/RAM as needed
|
|
||||||
- **Limitations** - Unix socket ties OpenResty to Node.js on same host
|
|
||||||
|
|
||||||
### Future Scaling Options
|
|
||||||
|
|
||||||
- **Redis cluster** - Distribute data storage
|
|
||||||
- **Multiple OpenResty instances** - Load balance incoming requests
|
|
||||||
- **Stateless Node.js** - Run multiple API instances
|
|
||||||
- **Replace Unix socket** - Use TCP/HTTP for cross-host communication
|
|
||||||
- **Separate cert management** - Dedicated service for wildcard SSL
|
|
||||||
|
|
||||||
## Monitoring and Observability
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
- **OpenResty**: `/var/log/nginx/access.log`, `/var/log/nginx/error.log`
|
|
||||||
- **Node.js**: `journalctl -u proxy.service`
|
|
||||||
- **Redis**: `redis-cli MONITOR`
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
|
|
||||||
- Node.js API: `curl http://localhost:3000/api/host`
|
|
||||||
- Redis: `redis-cli PING`
|
|
||||||
- OpenResty: `systemctl status openresty`
|
|
||||||
- Unix socket: `ls -la /var/run/proxy_lookup.socket`
|
|
||||||
|
|
||||||
### Metrics to Monitor
|
|
||||||
|
|
||||||
- Request rate and response times
|
|
||||||
- SSL certificate expiration dates
|
|
||||||
- Redis memory usage
|
|
||||||
- Host lookup cache hit rate
|
|
||||||
- Background service execution times
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/* theta42 docs site — shares the in-app dark navbar/footer + card look
|
|
||||||
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
|
|
||||||
generic Jekyll theme. */
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #f4f5f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand img {
|
|
||||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-nav .nav-link.active {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown content typography, scoped to the card body so it doesn't leak
|
|
||||||
into the nav/footer. */
|
|
||||||
.site-content h1:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content h1,
|
|
||||||
.site-content h2,
|
|
||||||
.site-content h3 {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content h2 {
|
|
||||||
margin-top: 2.5rem;
|
|
||||||
padding-bottom: .4rem;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content h3 {
|
|
||||||
margin-top: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content a {
|
|
||||||
color: #a3671f;
|
|
||||||
text-decoration-color: rgba(163, 103, 31, .35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content a:hover {
|
|
||||||
color: #8a5a16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content pre {
|
|
||||||
background-color: #212529;
|
|
||||||
color: #f8f9fa;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
border-radius: .375rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content code {
|
|
||||||
color: #a3671f;
|
|
||||||
background-color: #f4f0e8;
|
|
||||||
padding: .15em .4em;
|
|
||||||
border-radius: .25rem;
|
|
||||||
font-size: .875em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content pre code {
|
|
||||||
color: inherit;
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content table {
|
|
||||||
display: block;
|
|
||||||
overflow-x: auto;
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 1.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content table th,
|
|
||||||
.site-content table td {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
padding: .5rem .75rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content table th {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content blockquote {
|
|
||||||
border-left: 4px solid #C59341;
|
|
||||||
padding: .5rem 1rem;
|
|
||||||
margin: 1.25rem 0;
|
|
||||||
background-color: #f8f6f1;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screenshot grids in the markdown use width="49%" inline attrs for a
|
|
||||||
two-up desktop layout -- stack them on narrow screens instead of
|
|
||||||
squeezing to illegibility. */
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.site-content img[width] {
|
|
||||||
width: 100% !important;
|
|
||||||
margin-bottom: .75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-content hr {
|
|
||||||
margin: 2rem 0;
|
|
||||||
border-top: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<!-- Background circle -->
|
|
||||||
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
|
|
||||||
|
|
||||||
<!-- Network nodes -->
|
|
||||||
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
|
|
||||||
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
|
|
||||||
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
|
|
||||||
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
|
|
||||||
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
|
|
||||||
|
|
||||||
<!-- Connection lines -->
|
|
||||||
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
|
||||||
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
|
||||||
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
|
||||||
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 788 B |
@@ -1,51 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#C59341" />
|
|
||||||
<stop offset="20%" stop-color="#E4B869" />
|
|
||||||
<stop offset="40%" stop-color="#FBF0B9" />
|
|
||||||
<stop offset="60%" stop-color="#DFB260" />
|
|
||||||
<stop offset="80%" stop-color="#BC8837" />
|
|
||||||
<stop offset="100%" stop-color="#A36F28" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" stop-color="#FFFFFF" />
|
|
||||||
<stop offset="40%" stop-color="#F5E3B5" />
|
|
||||||
<stop offset="70%" stop-color="#D4A343" />
|
|
||||||
<stop offset="100%" stop-color="#8A5A16" />
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<g filter="url(#drop-shadow)">
|
|
||||||
<g fill="url(#gold-grad)">
|
|
||||||
<path d="M 200,40
|
|
||||||
C 290,40 350,110 350,200
|
|
||||||
C 350,290 290,360 200,360
|
|
||||||
C 110,360 50,290 50,200
|
|
||||||
C 50,110 110,40 200,40 Z
|
|
||||||
M 200,75
|
|
||||||
C 130,75 88,130 88,200
|
|
||||||
C 88,270 130,325 200,325
|
|
||||||
C 270,325 312,270 312,200
|
|
||||||
C 312,130 270,75 200,75 Z"
|
|
||||||
fill-rule="evenodd" />
|
|
||||||
|
|
||||||
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
|
||||||
|
|
||||||
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<text x="200" y="222"
|
|
||||||
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
|
||||||
font-size="78"
|
|
||||||
font-weight="900"
|
|
||||||
fill="url(#text-grad)"
|
|
||||||
text-anchor="middle"
|
|
||||||
letter-spacing="-2">42</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,75 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Users, Groups & Permissions
|
|
||||||
description: A plain-language guide to local admin accounts, groups, and the domain-scoped permission model in theta42/proxy.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Users, Groups & Permissions
|
|
||||||
|
|
||||||
This page explains, in plain language, who can manage what in this app. For
|
|
||||||
the deeper system-design detail, see [Architecture](architecture.html).
|
|
||||||
|
|
||||||
## Two different ways to log in
|
|
||||||
|
|
||||||
Most people who use apps you've proxied through this app never see this
|
|
||||||
app's own login at all — they use whatever authentication you set up on
|
|
||||||
the *individual host* (basic auth, or single sign-on through your SSO
|
|
||||||
Manager). This page is about a different, smaller group: the people who
|
|
||||||
manage the proxy itself — adding hosts, registering DNS providers, and so
|
|
||||||
on.
|
|
||||||
|
|
||||||
There are two ways someone gets into the proxy's own management UI:
|
|
||||||
|
|
||||||
- **A local account**, created on the **Users** page — a username and
|
|
||||||
password specific to this app.
|
|
||||||
- **Single sign-on**, if you've connected this proxy to an SSO Manager (or
|
|
||||||
another OIDC provider) — the same login your other connected apps use.
|
|
||||||
|
|
||||||
Either way, once logged in, what they're actually *allowed to do* here is
|
|
||||||
controlled by permissions, described below.
|
|
||||||
|
|
||||||
## Groups
|
|
||||||
|
|
||||||
A **group** here is just a named list of local usernames, used to grant
|
|
||||||
the same permission to several people at once instead of one at a time.
|
|
||||||
If you're using SSO instead of local accounts, group membership normally
|
|
||||||
comes from your identity provider instead — local groups exist mainly for
|
|
||||||
the local-account case.
|
|
||||||
|
|
||||||
## Permissions: scope + role
|
|
||||||
|
|
||||||
Each **permission** entry grants one subject (a user or a group) one
|
|
||||||
**role**, at one **scope** — the two are independent choices:
|
|
||||||
|
|
||||||
**Scope** — *where* the role applies:
|
|
||||||
|
|
||||||
- **Domain** — only hosts under one specific domain (e.g. someone can
|
|
||||||
manage everything under `example.com`, but can't see or touch a
|
|
||||||
completely different domain you also proxy).
|
|
||||||
- **Global** — everywhere, across every domain this proxy manages.
|
|
||||||
|
|
||||||
**Role** — *what* they can do within that scope:
|
|
||||||
|
|
||||||
- **Viewer** — read-only. Can see hosts and their settings, but not
|
|
||||||
change anything.
|
|
||||||
- **Manager** — full control over hosts (create, edit, delete) within
|
|
||||||
that scope.
|
|
||||||
- **Admin** — same host control as Manager, **plus**, but *only when
|
|
||||||
granted at Global scope*, the ability to manage other people's
|
|
||||||
permissions, DNS providers, and local user accounts. An Admin role
|
|
||||||
granted at Domain scope instead of Global behaves exactly like Manager
|
|
||||||
for that one domain — it does not unlock those extra admin-only pages.
|
|
||||||
|
|
||||||
In practice: give someone **Manager** on just the domain(s) they're
|
|
||||||
responsible for to delegate day-to-day host management without handing
|
|
||||||
them the keys to everything. Reserve **Global Admin** for people who
|
|
||||||
should be able to change anything, anywhere, including who else has
|
|
||||||
access.
|
|
||||||
|
|
||||||
## Want more detail?
|
|
||||||
|
|
||||||
This page doesn't cover the exact permission-checking implementation or
|
|
||||||
how SSO group membership maps into this system internally — for that, see
|
|
||||||
[Architecture](architecture.html).
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: API Tokens
|
|
||||||
description: A plain-language guide to personal access tokens in theta42/proxy.
|
|
||||||
---
|
|
||||||
|
|
||||||
# API Tokens
|
|
||||||
|
|
||||||
This page explains what an API token is and when you'd want one. For the
|
|
||||||
full list of API endpoints a token can call, see the
|
|
||||||
[API reference](api.html).
|
|
||||||
|
|
||||||
## What's an API token, in plain terms?
|
|
||||||
|
|
||||||
Normally, you interact with this app by logging in through a web browser.
|
|
||||||
An **API token** (also called a personal access token, or PAT) is an
|
|
||||||
alternative way in — a long, random string that a script, a scheduled job,
|
|
||||||
or another program can use instead of a username and password, to act on
|
|
||||||
your behalf without a human typing a login in each time.
|
|
||||||
|
|
||||||
If you've ever set up a script to talk to GitHub, GitLab, or a similar
|
|
||||||
service using a "token" instead of your real password, this is the same
|
|
||||||
idea.
|
|
||||||
|
|
||||||
## When would you actually need one?
|
|
||||||
|
|
||||||
Most people never need to create one of these — you'll only want a token
|
|
||||||
if you're automating something, for example:
|
|
||||||
|
|
||||||
- A script that registers or updates hosts automatically (say, spinning up
|
|
||||||
a new service and wanting the proxy entry created for it without a
|
|
||||||
manual step).
|
|
||||||
- A monitoring or backup job that checks this app's health via its API.
|
|
||||||
- A configuration-management tool that keeps your host list in sync with
|
|
||||||
something else.
|
|
||||||
|
|
||||||
If you're not doing any of that, you don't need an API token — just log in
|
|
||||||
normally through the web UI.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
Create a token from your Profile page, give it a name so you remember what
|
|
||||||
it's for later, and optionally an expiry. You'll be shown the token's
|
|
||||||
value **exactly once** — copy it somewhere safe immediately, because it
|
|
||||||
can't be viewed again afterward (only revoked or rotated). Whatever script
|
|
||||||
or tool you're using it with sends it along with each request, the same
|
|
||||||
way a browser sends your login session.
|
|
||||||
|
|
||||||
A token acts **as you**, with **your** [permissions](concepts-access.html)
|
|
||||||
— if you're only a Manager on one domain, a token you create can't touch
|
|
||||||
any other domain either. If you ever suspect a token has leaked (ended up
|
|
||||||
somewhere it shouldn't have, like a public script or log file), revoke it
|
|
||||||
immediately from your Profile page; it stops working right away.
|
|
||||||
|
|
||||||
## Want more detail?
|
|
||||||
|
|
||||||
This page doesn't attempt to list every API endpoint or show request/
|
|
||||||
response examples — for that, see the full [API reference](api.html).
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: DNS Providers
|
|
||||||
description: A plain-language guide to why theta42/proxy needs a DNS provider, and only for wildcard certificates.
|
|
||||||
---
|
|
||||||
|
|
||||||
# DNS Providers
|
|
||||||
|
|
||||||
This page explains, in plain language, what a "DNS provider" is for in this
|
|
||||||
app and when you actually need one. For setup steps, see
|
|
||||||
[Installation](installation.html).
|
|
||||||
|
|
||||||
## Do you need this at all?
|
|
||||||
|
|
||||||
**Only if you want a [wildcard host](concepts-hosts.html)** (something like
|
|
||||||
`*.example.com` covering every subdomain with one certificate). A normal,
|
|
||||||
single-name host doesn't need a DNS provider configured at all — skip this
|
|
||||||
page entirely if that's all you're setting up.
|
|
||||||
|
|
||||||
## Why a wildcard cert needs this extra step
|
|
||||||
|
|
||||||
To prove you actually own `example.com` before issuing a certificate that
|
|
||||||
covers *every* possible subdomain of it, Let's Encrypt needs to see a
|
|
||||||
specific, temporary DNS record appear on that domain — something only the
|
|
||||||
real owner of the domain could add. A normal single-host certificate
|
|
||||||
doesn't need this because it can prove ownership a simpler way (by
|
|
||||||
responding to a web request instead).
|
|
||||||
|
|
||||||
So: to get a wildcard certificate, this app needs to be able to add (and
|
|
||||||
later remove) that one temporary DNS record on your domain automatically,
|
|
||||||
which means it needs your domain registrar or DNS host's API credentials —
|
|
||||||
that's what registering a **DNS provider** here does.
|
|
||||||
|
|
||||||
## What you're actually giving it access to
|
|
||||||
|
|
||||||
A DNS provider entry only needs enough access to add/remove TXT records —
|
|
||||||
it's not given your registrar account's full login, and it can't do
|
|
||||||
anything to your domain besides that one narrow task (and, for some
|
|
||||||
providers, keeping a dynamic A record updated if you use that feature
|
|
||||||
separately). Check your specific provider's page in the
|
|
||||||
[Installation guide](installation.html) for exactly what kind of
|
|
||||||
credential to generate and how narrowly you can scope it.
|
|
||||||
|
|
||||||
## Want more detail?
|
|
||||||
|
|
||||||
For exact setup steps per provider (Cloudflare, DigitalOcean, Porkbun,
|
|
||||||
DuckDNS, etc.), see [Installation](installation.html).
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Hosts & HTTPS
|
|
||||||
description: A plain-language guide to hosts, HTTPS certificates, and wildcards in theta42/proxy.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Hosts & HTTPS
|
|
||||||
|
|
||||||
This page explains, in plain language, what a "host" is and how this app
|
|
||||||
gets you working HTTPS without you having to think about certificates. For
|
|
||||||
the deeper system-design detail, see [Architecture](architecture.html); for
|
|
||||||
step-by-step setup, see [Installation](installation.html).
|
|
||||||
|
|
||||||
## What's a "host"?
|
|
||||||
|
|
||||||
A **host** is one entry telling the proxy: "when someone requests *this*
|
|
||||||
public address, send them to *that* server." For example: requests for
|
|
||||||
`photos.example.com` get sent to the little box in your closet running your
|
|
||||||
photo app on port 8080. Each app or service you want to reach from outside
|
|
||||||
your network — a home automation dashboard, a media server, this proxy's
|
|
||||||
own management UI — gets its own host entry.
|
|
||||||
|
|
||||||
Two settings on a host are easy to mix up:
|
|
||||||
|
|
||||||
- **Incoming host name** — the public address people type in their
|
|
||||||
browser (`photos.example.com`).
|
|
||||||
- **Target IP/port** — where the proxy actually sends the request behind
|
|
||||||
the scenes (`10.0.0.5:8080`, or a hostname like `photo-server`).
|
|
||||||
|
|
||||||
Everything else on the host form (traffic limits, access rules,
|
|
||||||
authentication) is optional — a bare host with just those two fields
|
|
||||||
already works.
|
|
||||||
|
|
||||||
## HTTPS certificates: mostly automatic
|
|
||||||
|
|
||||||
Every public website needs an HTTPS certificate so browsers show the lock
|
|
||||||
icon instead of a scary warning. This app gets one for you automatically
|
|
||||||
from [Let's Encrypt](https://letsencrypt.org) the first time a host is
|
|
||||||
actually requested — you don't manually request, install, or renew
|
|
||||||
anything for a normal host. This happens behind the scenes using a method
|
|
||||||
called **HTTP-01**, and it's the default for every new host.
|
|
||||||
|
|
||||||
## Wildcards: one certificate for a whole family of hosts
|
|
||||||
|
|
||||||
Sometimes you want *every* subdomain under one name to work — `app1.`,
|
|
||||||
`app2.`, `anything.example.com` — without registering each one by hand and
|
|
||||||
waiting for its own certificate. That's what a **wildcard** host does: a
|
|
||||||
single host entry named `*.example.com` gets one certificate that covers
|
|
||||||
the whole family at once. Setting one up needs one extra piece of
|
|
||||||
information the automatic method above doesn't need — see
|
|
||||||
[DNS Providers](concepts-dns.html) for why.
|
|
||||||
|
|
||||||
Once a wildcard exists, you have two ways to actually use it:
|
|
||||||
|
|
||||||
- **Register nothing else, and turn on "Match any subdomain"** on the
|
|
||||||
wildcard host itself — *any* subdomain that doesn't already have its own
|
|
||||||
entry gets automatically routed to the wildcard's target the first time
|
|
||||||
it's requested. Convenient, but it means literal typos and random scan
|
|
||||||
traffic get routed too, not just the subdomains you meant to use.
|
|
||||||
- **Register each subdomain as its own host, as a "Parent Wildcard"
|
|
||||||
child** — more setup, but each subdomain can point at a different
|
|
||||||
target/server while still reusing the one wildcard certificate instead
|
|
||||||
of getting its own. This is the recommended default and is what
|
|
||||||
"Match only subdomains defined here" (the host form's default) does.
|
|
||||||
|
|
||||||
You'll see the **"Parent Wildcard"** option light up automatically on the
|
|
||||||
host form whenever the name you're entering already has a matching
|
|
||||||
wildcard available to reuse — including the wildcard's own bare base
|
|
||||||
domain (e.g. `example.com` itself, not just `something.example.com`).
|
|
||||||
|
|
||||||
## Want more detail?
|
|
||||||
|
|
||||||
This page skips the system-internals (Redis, OpenResty, the lookup service)
|
|
||||||
and the exact install steps. For those, see
|
|
||||||
[Architecture](architecture.html) and [Installation](installation.html).
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Contributing
|
|
||||||
description: How to contribute to the proxy — dev setup, tests, and code conventions.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Contributing Guide
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
|
|
||||||
Thank you for considering contributing to the Proxy project! This guide will help you get started.
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18+ (18.x, 20.x, or 22.x recommended)
|
|
||||||
- Redis server
|
|
||||||
- Git
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/theta42/proxy.git
|
|
||||||
cd proxy/nodejs
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies**
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Start Redis** (if not already running)
|
|
||||||
```bash
|
|
||||||
redis-server
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Run in development mode**
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts the Node.js API with nodemon for auto-reload on file changes.
|
|
||||||
|
|
||||||
5. **Access the API**
|
|
||||||
- API: `http://localhost:3000/api`
|
|
||||||
- Web UI: `http://localhost:3000`
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The project uses Node.js built-in test runner (requires Node 18+).
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
npm test
|
|
||||||
|
|
||||||
# Run only unit tests
|
|
||||||
npm run test:unit
|
|
||||||
|
|
||||||
# Run only integration tests
|
|
||||||
npm run test:integration
|
|
||||||
|
|
||||||
# Watch mode for development
|
|
||||||
npm run test:watch
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
test/
|
|
||||||
├── unit/ # Unit tests for isolated components
|
|
||||||
│ ├── basicauth.test.js
|
|
||||||
│ ├── callback_queue.test.js
|
|
||||||
│ ├── dynamic_record.test.js
|
|
||||||
│ ├── host_features.test.js
|
|
||||||
│ ├── host_lookup.test.js
|
|
||||||
│ ├── hostname_validate.test.js
|
|
||||||
│ ├── host_sso.test.js
|
|
||||||
│ ├── oidc.test.js
|
|
||||||
│ ├── password_policy.test.js
|
|
||||||
│ ├── roles.test.js
|
|
||||||
│ ├── safe_redirect.test.js
|
|
||||||
│ ├── unix_socket.test.js
|
|
||||||
│ └── wildcard_matchany.test.js
|
|
||||||
├── integration/ # Integration tests
|
|
||||||
│ └── dns_provider.test.js
|
|
||||||
└── helpers/ # Test utilities
|
|
||||||
└── dns_provider_contract.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### Writing Tests
|
|
||||||
|
|
||||||
We test **custom logic**, not third-party libraries:
|
|
||||||
|
|
||||||
**DO test:**
|
|
||||||
- Host lookup algorithm
|
|
||||||
- Socket buffering logic
|
|
||||||
- DNS provider contracts
|
|
||||||
- Custom utility functions
|
|
||||||
|
|
||||||
**DON'T test:**
|
|
||||||
- Express.js routing
|
|
||||||
- Redis ORM
|
|
||||||
- External DNS APIs (use mocks instead)
|
|
||||||
|
|
||||||
### Adding DNS Provider Tests
|
|
||||||
|
|
||||||
When adding a new DNS provider, you **must** add contract tests:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
describe('NewProvider Provider', () => {
|
|
||||||
const NewProvider = require('../../models/dns_provider/newprovider');
|
|
||||||
|
|
||||||
test('should meet DNS provider contract', () => {
|
|
||||||
const mockCredentials = {api_key: 'mock-key'};
|
|
||||||
const instance = validateDnsProviderContract(NewProvider, mockCredentials);
|
|
||||||
assert.ok(instance);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have valid method signatures', () => {
|
|
||||||
const instance = new NewProvider({api_key: 'mock'});
|
|
||||||
validateMethodSignatures(instance);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate key mapping', () => {
|
|
||||||
const instance = new NewProvider({api_key: 'mock'});
|
|
||||||
validateKeyMapping(instance);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate type checking', () => {
|
|
||||||
const instance = new NewProvider({api_key: 'mock'});
|
|
||||||
validateTypeChecking(instance);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
See `test/integration/dns_provider.test.js` for examples.
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
### General Guidelines
|
|
||||||
|
|
||||||
- Use strict mode: `'use strict';`
|
|
||||||
- Use tabs for indentation
|
|
||||||
- Clear, descriptive variable names
|
|
||||||
- Comment complex logic
|
|
||||||
- No trailing whitespace
|
|
||||||
|
|
||||||
### File Organization
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// 1. Node.js built-ins
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// 2. Third-party modules
|
|
||||||
const express = require('express');
|
|
||||||
const redis = require('redis');
|
|
||||||
|
|
||||||
// 3. Local modules
|
|
||||||
const {Host} = require('./models');
|
|
||||||
const middleware = require('./middleware/auth');
|
|
||||||
|
|
||||||
// 4. Code...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
- Classes: `PascalCase`
|
|
||||||
- Functions: `camelCase`
|
|
||||||
- Constants: `UPPER_SNAKE_CASE`
|
|
||||||
- Private methods: `__privateMethod` (double underscore prefix)
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
Understanding the codebase:
|
|
||||||
|
|
||||||
```
|
|
||||||
nodejs/
|
|
||||||
├── conf/ # Configuration (base.js, environment overlays, secrets.js)
|
|
||||||
├── controller/ # App-level wiring (pubsub, startup)
|
|
||||||
├── migrations/ # One-off Redis data migration scripts
|
|
||||||
├── models/ # Data models (Host, User, DNS providers)
|
|
||||||
├── routes/ # API route handlers
|
|
||||||
├── services/ # Background services (lookup, scheduler)
|
|
||||||
├── middleware/ # Express middleware
|
|
||||||
├── utils/ # Utility functions
|
|
||||||
├── public/ # Static web assets
|
|
||||||
├── views/ # EJS templates
|
|
||||||
└── test/ # Test suite
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pull Request Process
|
|
||||||
|
|
||||||
### Before Submitting
|
|
||||||
|
|
||||||
1. **Run tests** - Ensure all tests pass
|
|
||||||
```bash
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Test locally** - Verify your changes work
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Update documentation** - Keep docs in sync with code changes
|
|
||||||
|
|
||||||
4. **Commit messages** - Use clear, descriptive messages
|
|
||||||
```
|
|
||||||
Add DNS provider for Route53
|
|
||||||
|
|
||||||
- Implement Route53 DNS API client
|
|
||||||
- Add contract tests for Route53
|
|
||||||
- Update documentation with Route53 setup
|
|
||||||
```
|
|
||||||
|
|
||||||
### Submitting a PR
|
|
||||||
|
|
||||||
1. **Fork the repository**
|
|
||||||
|
|
||||||
2. **Create a feature branch**
|
|
||||||
```bash
|
|
||||||
git checkout -b feature/my-new-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Make your changes**
|
|
||||||
|
|
||||||
4. **Commit your changes**
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "Description of changes"
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Push to your fork**
|
|
||||||
```bash
|
|
||||||
git push origin feature/my-new-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Open a Pull Request** on GitHub
|
|
||||||
|
|
||||||
### PR Requirements
|
|
||||||
|
|
||||||
- All tests must pass (CI/CD runs automatically)
|
|
||||||
- Tests run on Node.js 18.x, 20.x, and 22.x
|
|
||||||
- No merge conflicts with `master`
|
|
||||||
- Code follows project conventions
|
|
||||||
- New features include tests
|
|
||||||
- Documentation updated if needed
|
|
||||||
|
|
||||||
### CI/CD Process
|
|
||||||
|
|
||||||
When you open a PR:
|
|
||||||
1. GitHub Actions automatically runs tests
|
|
||||||
2. Tests execute on multiple Node.js versions
|
|
||||||
3. PR cannot be merged until all checks pass
|
|
||||||
4. Review from maintainers
|
|
||||||
5. Merge to master
|
|
||||||
|
|
||||||
## Data Models
|
|
||||||
|
|
||||||
The project uses [model-redis](https://www.npmjs.com/package/model-redis) as the ORM for Redis data storage. All models extend the `Table` class and use a declarative schema via `_keyMap`.
|
|
||||||
|
|
||||||
**Example Model:**
|
|
||||||
```javascript
|
|
||||||
const Table = require('../utils/redis_model');
|
|
||||||
|
|
||||||
class Host extends Table {
|
|
||||||
static _key = 'host'; // Primary key field
|
|
||||||
static _keyMap = {
|
|
||||||
'host': {isRequired: true, type: 'string', min: 3, max: 500},
|
|
||||||
'ip': {isRequired: true, type: 'string', min: 3, max: 500},
|
|
||||||
'targetPort': {isRequired: true, type: 'number', min: 0, max: 65535},
|
|
||||||
'forcessl': {default: true, type: 'boolean'},
|
|
||||||
'created_on': {default: () => Date.now(), type: 'number'}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Learn more:** [model-redis documentation](https://www.npmjs.com/package/model-redis)
|
|
||||||
|
|
||||||
## Adding Features
|
|
||||||
|
|
||||||
### Adding a DNS Provider
|
|
||||||
|
|
||||||
1. **Create provider file** in `models/dns_provider/yourprovider.js`
|
|
||||||
|
|
||||||
2. **Extend DnsApi base class**
|
|
||||||
```javascript
|
|
||||||
const {DnsApi} = require('./common');
|
|
||||||
|
|
||||||
class YourProvider extends DnsApi {
|
|
||||||
static _keyMap = {
|
|
||||||
api_key: {isRequired: true, type: 'string', isPrivate: true}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Implement required methods
|
|
||||||
async listDomains() { }
|
|
||||||
async getRecords(domain, options) { }
|
|
||||||
async createRecord(domain, options) { }
|
|
||||||
async deleteRecords(domain, options) { }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Add to provider list** in `models/dns_provider.js`
|
|
||||||
|
|
||||||
4. **Add contract tests** in `test/integration/dns_provider.test.js`
|
|
||||||
|
|
||||||
5. **Test your provider**
|
|
||||||
```bash
|
|
||||||
npm run test:integration
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding API Endpoints
|
|
||||||
|
|
||||||
1. **Add route** in appropriate file (`routes/`)
|
|
||||||
2. **Update API documentation** (`nodejs/api.md` and `docs/api.md` — keep them in sync)
|
|
||||||
3. **Test the endpoint** manually and add integration tests if needed
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
- **Questions?** Open a [GitHub Discussion](https://github.com/theta42/proxy/discussions)
|
|
||||||
- **Bug reports** Use [GitHub Issues](https://github.com/theta42/proxy/issues)
|
|
||||||
- **Security issues** Email maintainers directly (see package.json)
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
- Be respectful and inclusive
|
|
||||||
- Focus on constructive feedback
|
|
||||||
- Help others learn and grow
|
|
||||||
- Follow the project's technical direction
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[← Back to Home](index.html) | [View on GitHub](https://github.com/theta42/proxy)
|
|
||||||
-141
@@ -1,141 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Docker
|
|
||||||
description: Running the proxy's all-in-one Docker image — OpenResty, the management app, and Redis in one container.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Docker Deployment
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
|
|
||||||
The proxy ships as a single all-in-one Docker image bundling **OpenResty + the
|
|
||||||
Node management app + Redis** in one container, mirroring the bare-metal
|
|
||||||
[`ops/install.sh`](https://github.com/theta42/proxy/blob/master/ops/install.sh)
|
|
||||||
layout. This is the easiest way to run the proxy standalone, or as part of the
|
|
||||||
unified [theta-env](https://github.com/theta42/theta-env) stack.
|
|
||||||
|
|
||||||
## Quick start (standalone)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/theta42/proxy.git
|
|
||||||
cd proxy
|
|
||||||
mkdir -p config && chmod 700 config
|
|
||||||
cp secrets.js.example config/proxy-secrets.js # set OIDC/LDAP wiring
|
|
||||||
$EDITOR config/proxy-secrets.js
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
- Proxy (public, auto-SSL): `https://<host>/`
|
|
||||||
- Management UI / API: `http://127.0.0.1:3000/` (bound to localhost)
|
|
||||||
- Health: `http://127.0.0.1:3000/health` → `{"status":"ok"}`
|
|
||||||
|
|
||||||
## How configuration works
|
|
||||||
|
|
||||||
The app loads config via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf),
|
|
||||||
which deep-merges, in order:
|
|
||||||
|
|
||||||
1. `conf/base.js` (committed defaults)
|
|
||||||
2. `conf/<NODE_ENV>.js` (optional)
|
|
||||||
3. `conf/secrets.js` (gitignored)
|
|
||||||
4. **`app_*` environment variables** — the highest-precedence layer
|
|
||||||
|
|
||||||
The bundled `docker-compose.yml` mounts `./config/proxy-secrets.js` at `/config`,
|
|
||||||
and `docker-entrypoint.sh` sets `CONF_SECRETS=/config/proxy-secrets.js` so the
|
|
||||||
app reads the OIDC + LDAP + auth wiring from the file. **No `app_*` env is
|
|
||||||
passed** — `app_*` env beats `secrets.js`, so the file is authoritative only if
|
|
||||||
the matching `app_*` env is absent. See `secrets.js.example` for the shape.
|
|
||||||
|
|
||||||
Any env var starting with `app_` overrides the merged config; the rest of the
|
|
||||||
name splits on **double-underscore** (`__`) into a nested path. Values are
|
|
||||||
`JSON.parse`-coerced when possible, kept as strings otherwise. `app_*` env is
|
|
||||||
still supported for advanced/standalone use — add the vars to the compose
|
|
||||||
`environment:` block yourself (the bundled compose no longer sets them).
|
|
||||||
|
|
||||||
> **Requires `@simpleworkjs/conf` >= 1.1.0.** The `app_*` env layer is not
|
|
||||||
> honored on 1.0.0. The lock is already on `^1.1.0`.
|
|
||||||
|
|
||||||
### Key `app_*` variables
|
|
||||||
|
|
||||||
| Env var | Sets |
|
|
||||||
|---------|------|
|
|
||||||
| `app_oidc__issuer` | `conf.oidc.issuer` (browser-facing SSO URL) |
|
|
||||||
| `app_oidc__authorizationEndpoint` | `conf.oidc.authorizationEndpoint` |
|
|
||||||
| `app_oidc__tokenEndpoint` | `conf.oidc.tokenEndpoint` (server-to-server; can be internal) |
|
|
||||||
| `app_oidc__userinfoEndpoint` | `conf.oidc.userinfoEndpoint` (server-to-server) |
|
|
||||||
| `app_oidc__endSessionEndpoint` | `conf.oidc.endSessionEndpoint` |
|
|
||||||
| `app_oidc__clientId` / `app_oidc__clientSecret` | OIDC client creds |
|
|
||||||
| `app_oidc__redirectUri` | `conf.oidc.redirectUri` (must match the SSO client) |
|
|
||||||
| `app_oidc__enabled` | `conf.oidc.enabled` (boolean) |
|
|
||||||
| `app_ldap__url` | `conf.ldap.url` (`ldaps://…:636` or `ldap://…:389`) |
|
|
||||||
| `app_ldap__bindDN` / `app_ldap__bindPassword` | LDAP service account |
|
|
||||||
| `app_ldap__searchBase` / `app_ldap__userFilter` | user search |
|
|
||||||
| `app_ldap__tlsOptions__rejectUnauthorized` | `false` for self-signed LDAPS |
|
|
||||||
| `app_ldap__tlsOptions__ca` | path to a CA cert for strict trust |
|
|
||||||
| `app_auth__adminUsers` | local anti-lockout admin (uid) |
|
|
||||||
| `app_auth__adminGroups` | SSO/LDAP groups that are global admin (JSON array) |
|
|
||||||
| `app_redis__prefix` | `conf.redis.prefix` (default `proxy_`) |
|
|
||||||
|
|
||||||
See [`DEPLOYMENT.md`](https://github.com/theta42/proxy/blob/master/DEPLOYMENT.md)
|
|
||||||
for the complete reference.
|
|
||||||
|
|
||||||
## OpenResty runtime env
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `RESOLVER` | `127.0.0.11` | DNS for upstream names in Host records (Docker's embedded DNS) |
|
|
||||||
| `REAL_IP_FROM` | _empty_ | Trusted CIDR for `X-Real-IP`. Empty = the proxy is the front (removes the real_ip block). Set to an upstream proxy's CIDR if one sits in front. |
|
|
||||||
|
|
||||||
## Auto-SSL / Let's Encrypt
|
|
||||||
|
|
||||||
`lua-resty-auto-ssl` stores certs in the bundled Redis. Redis is now AOF+RDB
|
|
||||||
persisted to the `proxy-data` volume (not in-memory), so **Let's Encrypt certs
|
|
||||||
survive container recreation** — no re-issue / rate-limit on rebuild. Port 80 is
|
|
||||||
required for HTTP-01 challenges (mapped in the compose). Back up + restore Redis
|
|
||||||
to back up + restore cert state (see *Backups and restore* in `DEPLOYMENT.md`).
|
|
||||||
|
|
||||||
## Fronting an SSO Manager
|
|
||||||
|
|
||||||
The proxy is a natural front for
|
|
||||||
[`theta42/sso-manager-node`](https://github.com/theta42/sso-manager-node): it
|
|
||||||
terminates TLS for the SSO's UI and protects it with OIDC login, while also
|
|
||||||
binding to the SSO's LDAP directly for user lookups. To run both together:
|
|
||||||
|
|
||||||
1. **One Docker network** so the proxy reaches the SSO internally at
|
|
||||||
`http://sso-manager:3001` (token/userinfo) and `ldaps://sso-manager:636`.
|
|
||||||
2. **Set the SSO's `app_oauth__issuer`** to the browser-facing HTTPS URL the
|
|
||||||
proxy serves the SSO at (e.g. `https://sso.example.com`).
|
|
||||||
3. **Register the proxy as an OIDC client** in the SSO, with `redirectUri`
|
|
||||||
matching `https://proxy.example.com/api/auth/oidc/callback`.
|
|
||||||
4. **LDAP**: point `app_ldap__url` at `ldaps://sso-manager:636`, create a
|
|
||||||
dedicated service account (`cn=ldapclient,ou=people,…`), and for the SSO's
|
|
||||||
self-signed LDAPS cert set `app_ldap__tlsOptions__rejectUnauthorized=false`
|
|
||||||
(or mount the cert and use `app_ldap__tlsOptions__ca=<path>`).
|
|
||||||
|
|
||||||
The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo
|
|
||||||
automates all four steps with `./setup.sh` — see
|
|
||||||
[theta-env docs](https://theta42.github.io/theta-env/).
|
|
||||||
|
|
||||||
## API tokens (personal access tokens)
|
|
||||||
|
|
||||||
Any logged-in user can mint a long-lived bearer token to call the management API
|
|
||||||
from scripts/CI without an OIDC browser session. Self-service; authenticates as
|
|
||||||
the creator (groups snapshotted at mint; authz layer unchanged).
|
|
||||||
|
|
||||||
Create one under **API Tokens** in the UI (shown once), then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer prx_<id>_<secret>" https://proxy.example.com/api/host
|
|
||||||
```
|
|
||||||
|
|
||||||
Rotate/revoke from the same page (immediate effect). Optional expiry at
|
|
||||||
creation. The token carries the creator's rights (admin → full mgmt API;
|
|
||||||
domain manager → those domains; `requireAdmin` routes 403). To tighten after
|
|
||||||
group changes, revoke + re-mint. Tokens persist in Redis (AOF) and survive
|
|
||||||
rebuilds.
|
|
||||||
|
|
||||||
## Bare metal
|
|
||||||
|
|
||||||
Prefer a systemd install? See the [Installation Guide](installation.html) for
|
|
||||||
the `ops/install.sh` automated installer on Debian/Ubuntu.
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 151 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
@@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Home
|
|
||||||
description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with automatic Let's Encrypt certs, OIDC login, and direct LDAP access control per host.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Proxy
|
|
||||||
|
|
||||||
A reverse proxy and HTTPS termination service built on OpenResty/nginx, with a
|
|
||||||
management API and web GUI. It puts any of your apps behind single sign-on
|
|
||||||
(OIDC) and can also look users up directly in LDAP — so the same people who
|
|
||||||
log in to your SSO are the people allowed to reach your proxied apps.
|
|
||||||
|
|
||||||
Automatic HTTPS from Let's Encrypt (including wildcards), routing by hostname,
|
|
||||||
and per-host access control tied to your identity provider — managed from a
|
|
||||||
web UI or a REST API, with no downtime on config changes.
|
|
||||||
|
|
||||||
Part of the theta42 self-hosted identity stack, alongside
|
|
||||||
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
|
|
||||||
[theta-env](https://theta42.github.io/theta-env/) (the two composed with one
|
|
||||||
command).
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
<a href="images/hosts.png" target="_blank"><img src="images/hosts.png" alt="Host list" width="49%"></a>
|
|
||||||
<a href="images/host-auth-sso.png" target="_blank"><img src="images/host-auth-sso.png" alt="Per-host SSO auth" width="49%"></a>
|
|
||||||
|
|
||||||
Basic auth and SSO are mutually exclusive per host, with per-user password
|
|
||||||
management once basic auth is enabled:
|
|
||||||
|
|
||||||
<a href="images/host-auth-basic.png" target="_blank"><img src="images/host-auth-basic.png" alt="Per-host basic auth" width="60%"></a>
|
|
||||||
|
|
||||||
*(click any screenshot to view full size)*
|
|
||||||
|
|
||||||
## Why this over the alternatives
|
|
||||||
|
|
||||||
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
|
|
||||||
auto-HTTPS. This one is built around identity: it is both an **OIDC client**
|
|
||||||
of an SSO provider (for browser login) **and** a direct **LDAP client** (for
|
|
||||||
user lookups and per-host access control), so access decisions come from your
|
|
||||||
real user directory, not a static allow-list or a separate auth proxy bolted
|
|
||||||
on top. The trade-off is that it expects an OIDC/LDAP identity source to point
|
|
||||||
at — it is not a standalone auth server. Pair it with
|
|
||||||
[SSO Manager](https://theta42.github.io/sso-manager-node/) (bundled OpenLDAP +
|
|
||||||
OIDC) for a self-hosted SSO + proxy stack, or point it at any OIDC provider +
|
|
||||||
LDAP directory you already run.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
|
|
||||||
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
|
|
||||||
- Dynamic host routing with wildcard domain matching (`*`, `**`)
|
|
||||||
- **OIDC login** and **direct LDAP lookups**, independently of each other
|
|
||||||
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
|
|
||||||
it's never ambiguous which one gated a request)
|
|
||||||
- **Role-based access control** — global admins, local groups, and
|
|
||||||
per-domain permissions (viewer/manager)
|
|
||||||
- Self-service API tokens for scripting/CI without a browser session
|
|
||||||
- Web UI and a full REST API
|
|
||||||
|
|
||||||
## Get it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/theta42/proxy.git
|
|
||||||
cd proxy && docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
That's the standalone quick start. For the full set of install options (Docker,
|
|
||||||
bare-metal, or as part of the combined SSO + proxy stack), configuration
|
|
||||||
reference, and API docs, see the
|
|
||||||
**[GitHub repository](https://github.com/theta42/proxy)**.
|
|
||||||
|
|
||||||
## Related projects
|
|
||||||
|
|
||||||
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC
|
|
||||||
provider + LDAP directory this proxy is designed to sit in front of.
|
|
||||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this proxy and
|
|
||||||
SSO Manager together with one command.
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
---
|
|
||||||
layout: default
|
|
||||||
title: Installation
|
|
||||||
description: Installing the proxy — Docker, bare metal, or as part of the unified theta-env stack.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Installation Guide
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
|
|
||||||
> Looking for a plainer explanation of hosts, HTTPS, and DNS providers
|
|
||||||
> instead of install steps? See [Hosts & HTTPS](concepts-hosts.html) and
|
|
||||||
> [DNS Providers](concepts-dns.html).
|
|
||||||
|
|
||||||
## Quick Install (Recommended)
|
|
||||||
|
|
||||||
For modern Debian-based systems (Ubuntu 20.04+, Debian 11+):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
|
|
||||||
```
|
|
||||||
|
|
||||||
This automated installer will:
|
|
||||||
- Install Node.js 22.x
|
|
||||||
- Install OpenResty and required dependencies
|
|
||||||
- Install and configure Redis
|
|
||||||
- Set up SSL fallback certificates
|
|
||||||
- Install Lua dependencies
|
|
||||||
- Clone and install the proxy application
|
|
||||||
- Configure systemd service
|
|
||||||
- Start the proxy service
|
|
||||||
|
|
||||||
## Manual Installation
|
|
||||||
|
|
||||||
> **Recommended path:** `ops/install.sh` (above) is idempotent and safe to
|
|
||||||
> re-run — it symlinks the OpenResty/systemd config from the repo checkout,
|
|
||||||
> so future updates stay in sync automatically (`git pull` + re-run). The
|
|
||||||
> manual steps below *copy* those same files instead of symlinking them, so
|
|
||||||
> they will **not** auto-track later changes to `ops/nginx_conf/` or
|
|
||||||
> `ops/proxy.service` — you'd need to re-copy them by hand after every
|
|
||||||
> update. Prefer the manual path only if `install.sh` doesn't fit your
|
|
||||||
> distribution.
|
|
||||||
|
|
||||||
### System Requirements
|
|
||||||
|
|
||||||
- Modern Linux distribution (Ubuntu 20.04+, Debian 11+, or equivalent)
|
|
||||||
- Root access
|
|
||||||
- Inbound internet access for Let's Encrypt validation
|
|
||||||
- Minimum 1GB RAM, 10GB disk space
|
|
||||||
|
|
||||||
### Step 1: Install Dependencies
|
|
||||||
|
|
||||||
**Ubuntu/Debian:**
|
|
||||||
```bash
|
|
||||||
apt install libpam0g-dev build-essential redis-server luarocks -y
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Install Node.js 22.x
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | \
|
|
||||||
sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
|
||||||
|
|
||||||
NODE_MAJOR=22
|
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | \
|
|
||||||
sudo tee /etc/apt/sources.list.d/nodesource.list
|
|
||||||
|
|
||||||
apt update && apt install nodejs -y
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify installation:
|
|
||||||
```bash
|
|
||||||
node --version # Should show v22.x.x
|
|
||||||
npm --version
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Install OpenResty
|
|
||||||
|
|
||||||
openresty.org ships distinct trees (and components) for Debian and Ubuntu. The
|
|
||||||
Debian tree is published only up to **bookworm** (there is no trixie block) and
|
|
||||||
uses the **`openresty`** component; Ubuntu uses the host codename and **`main`**.
|
|
||||||
So on a Debian 13 (trixie) host, point at the `bookworm` distribution (binary-
|
|
||||||
compatible, same OpenSSL 3 era).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
. /etc/os-release
|
|
||||||
CODENAME="$(lsb_release -sc)"
|
|
||||||
case "$ID" in
|
|
||||||
debian)
|
|
||||||
OR_PATH=package/debian
|
|
||||||
OR_COMPONENT=openresty
|
|
||||||
# Debian tree only publishes up to bookworm; fall back to it for trixie+.
|
|
||||||
case "$CODENAME" in jessie|stretch|buster|bullseye|bookworm) OR_DISTRO="$CODENAME";; *) OR_DISTRO=bookworm;; esac
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
OR_PATH=package/ubuntu
|
|
||||||
OR_DISTRO="$CODENAME"
|
|
||||||
OR_COMPONENT=main
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
wget -O - https://openresty.org/package/pubkey.gpg | \
|
|
||||||
sudo gpg --dearmor -o /usr/share/keyrings/openresty.gpg
|
|
||||||
|
|
||||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/${OR_PATH} ${OR_DISTRO} ${OR_COMPONENT}" | \
|
|
||||||
sudo tee /etc/apt/sources.list.d/openresty.list
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Debian 13 (trixie):** apt's sequoia GPG backend rejects SHA-1 signatures by
|
|
||||||
> default, and the OpenResty signing key is still SHA-1, so `apt update` will
|
|
||||||
> refuse the repo. Extend the SHA-1 acceptance window before updating:
|
|
||||||
> ```bash
|
|
||||||
> sudo mkdir -p /etc/crypto-policies/back-ends
|
|
||||||
> sudo cp /usr/share/apt/default-sequoia.config /etc/crypto-policies/back-ends/apt-sequoia.config
|
|
||||||
> sudo sed -i 's/2026-02-01/2028-02-01/' /etc/crypto-policies/back-ends/apt-sequoia.config
|
|
||||||
> ```
|
|
||||||
> (The `default-sequoia.config` file only ships on Debian 13+, so this is a no-op
|
|
||||||
> on older releases. `ops/install.sh` applies this automatically.)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt update && apt install openresty -y
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Install Lua Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
luarocks install lua-resty-auto-ssl
|
|
||||||
luarocks install luasocket
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: SSL Configuration
|
|
||||||
|
|
||||||
Create fallback SSL certificates:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /etc/ssl/
|
|
||||||
|
|
||||||
openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
|
|
||||||
-subj '/CN=sni-support-required-for-valid-ssl' \
|
|
||||||
-keyout /etc/ssl/resty-auto-ssl-fallback.key \
|
|
||||||
-out /etc/ssl/resty-auto-ssl-fallback.crt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Configure OpenResty
|
|
||||||
|
|
||||||
Clone the repository and copy configuration files:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /opt/theta42
|
|
||||||
cd /opt/theta42
|
|
||||||
git clone https://github.com/theta42/proxy.git
|
|
||||||
cd proxy
|
|
||||||
|
|
||||||
# Copy nginx configs
|
|
||||||
mkdir -p /etc/openresty/sites-enabled/
|
|
||||||
cp ops/nginx_conf/nginx.conf /etc/openresty/nginx.conf
|
|
||||||
cp ops/nginx_conf/autossl.conf /etc/openresty/autossl.conf
|
|
||||||
cp ops/nginx_conf/proxy.conf /etc/openresty/sites-enabled/000-proxy
|
|
||||||
cp ops/nginx_conf/targetinfo.lua /usr/local/openresty/lualib/targetinfo.lua
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7: Install Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/theta42/proxy/nodejs
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp /opt/theta42/proxy/ops/proxy.service /etc/systemd/system/proxy.service
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable proxy.service
|
|
||||||
systemctl start proxy.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify service is running:
|
|
||||||
```bash
|
|
||||||
systemctl status proxy.service
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 9: Initial Setup
|
|
||||||
|
|
||||||
The proxy API will be available on port 3000 by default. You'll need to:
|
|
||||||
|
|
||||||
1. Create your first user account
|
|
||||||
2. Configure DNS providers (for wildcard SSL)
|
|
||||||
3. Add your first host
|
|
||||||
|
|
||||||
See the [API Reference](api.html) for details.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
- `NODE_ENV` - Set to `production` for production deployments
|
|
||||||
- `NODE_PORT` - Override default port (default: 3000)
|
|
||||||
|
|
||||||
### Redis Configuration
|
|
||||||
|
|
||||||
The proxy uses Redis with the prefix `proxy_`. To change this, edit `nodejs/conf/base.js`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
redis: {
|
|
||||||
prefix: 'proxy_'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenResty Configuration
|
|
||||||
|
|
||||||
Key configuration files in `/etc/openresty/`:
|
|
||||||
- `nginx.conf` - Main nginx configuration
|
|
||||||
- `autossl.conf` - Let's Encrypt HTTP-01 challenge handler
|
|
||||||
- `sites-enabled/000-proxy` - Proxy server configuration
|
|
||||||
|
|
||||||
### Unix Socket
|
|
||||||
|
|
||||||
The proxy communicates with OpenResty via Unix socket at:
|
|
||||||
```
|
|
||||||
/var/run/proxy_lookup.socket
|
|
||||||
```
|
|
||||||
|
|
||||||
This path is configurable in `nodejs/conf/base.js`.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Service won't start
|
|
||||||
|
|
||||||
Check logs:
|
|
||||||
```bash
|
|
||||||
journalctl -u proxy.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
Common issues:
|
|
||||||
- Port 3000 already in use
|
|
||||||
- Redis not running: `systemctl status redis-server`
|
|
||||||
- Permission issues: Service must run as root for user management
|
|
||||||
|
|
||||||
### SSL certificates not working
|
|
||||||
|
|
||||||
Check OpenResty logs:
|
|
||||||
```bash
|
|
||||||
tail -f /var/log/nginx/error.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Common issues:
|
|
||||||
- Firewall blocking ports 80/443
|
|
||||||
- DNS not pointing to server
|
|
||||||
- Let's Encrypt rate limits exceeded
|
|
||||||
|
|
||||||
### Host lookup not working
|
|
||||||
|
|
||||||
Check Unix socket:
|
|
||||||
```bash
|
|
||||||
ls -la /var/run/proxy_lookup.socket
|
|
||||||
# Should show srwxrwxrwx (socket permissions)
|
|
||||||
```
|
|
||||||
|
|
||||||
Test lookup:
|
|
||||||
```bash
|
|
||||||
echo '{"domain":"example.com"}' | nc -U /var/run/proxy_lookup.socket
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- [Configure DNS Providers](api.html#dns-providers) for wildcard SSL
|
|
||||||
- [Add your first host](api.html#hosts)
|
|
||||||
- [Set up the web interface](index.html)
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://theta42.github.io/proxy/sitemap.xml
|
|
||||||
@@ -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.
|
||||||
|
|||||||
+77
-64
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-1
@@ -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' } }
|
||||||
|
|||||||
@@ -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(){
|
||||||
|
|||||||
+41
-13
@@ -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(){
|
||||||
|
|||||||
+23
-4
@@ -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,35 +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 a well-known default. Only used on first
|
|
||||||
// creation -- once the account exists this is never read again, so it's
|
|
||||||
// safe to leave set. If unset, a random password is generated and printed
|
|
||||||
// once; save it from the log or set auth.localAdminPass explicitly.
|
|
||||||
var defaultPass = (conf.auth && conf.auth.localAdminPass);
|
|
||||||
if (!defaultPass) {
|
|
||||||
defaultPass = crypto.randomBytes(16).toString('hex');
|
|
||||||
console.warn(`====================================================================`);
|
|
||||||
console.warn(`Bootstrap admin "${defaultUser}" created with random password:`);
|
|
||||||
console.warn(`${defaultPass}`);
|
|
||||||
console.warn(`Set auth.localAdminPass in your secrets file to make this deterministic.`);
|
|
||||||
console.warn(`====================================================================`);
|
|
||||||
}
|
|
||||||
try{
|
|
||||||
let user = await User.get(defaultUser);
|
|
||||||
}catch(error){
|
|
||||||
try{
|
|
||||||
let user = await User.create({
|
|
||||||
username:defaultUser,
|
|
||||||
password: defaultPass,
|
|
||||||
created_by: defaultUser
|
|
||||||
});
|
|
||||||
console.log(defaultUser, 'created');
|
|
||||||
}catch(error){
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
Generated
+118
-24
@@ -1,17 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.16",
|
"version": "1.13.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.16",
|
"version": "1.13.3",
|
||||||
"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/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.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",
|
||||||
@@ -26,7 +31,7 @@
|
|||||||
"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",
|
||||||
@@ -281,6 +286,30 @@
|
|||||||
"@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.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
@@ -293,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",
|
||||||
@@ -460,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"
|
||||||
@@ -483,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",
|
||||||
@@ -503,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": {
|
||||||
@@ -1508,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"
|
||||||
@@ -2194,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": {
|
||||||
|
|||||||
+10
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxy-api",
|
"name": "proxy-api",
|
||||||
"version": "1.1.16",
|
"version": "1.13.3",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"name": "William Mantly",
|
"name": "William Mantly",
|
||||||
@@ -10,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"
|
||||||
@@ -21,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/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.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",
|
||||||
@@ -36,7 +41,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
+280
-105
@@ -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;
|
|
||||||
@@ -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');
|
||||||
|
|||||||
+16
-14
@@ -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});
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-1
@@ -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
|
||||||
|
|||||||
+12
-25
@@ -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']},
|
||||||
|
],
|
||||||
|
};
|
||||||
+28
-22
@@ -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">
|
||||||
@@ -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') %>
|
||||||
|
|||||||
+394
-373
@@ -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">
|
||||||
@@ -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') %>
|
||||||
|
|||||||
+12
-8
@@ -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') %>
|
||||||
|
|||||||
+153
-94
@@ -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') %>
|
||||||
|
|||||||
+63
-55
@@ -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>
|
||||||
|
|||||||
+67
-69
@@ -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') %>
|
||||||
|
|||||||
@@ -123,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
|
||||||
|
|||||||
@@ -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
-6
@@ -67,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',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user