Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cde45fff1d | |||
| 972f9ace0a | |||
| 8c184a7f9a | |||
| b6efcff25e | |||
| 29029914d2 | |||
| 111f5d9df7 | |||
| fe71ec1bcc | |||
| 99276f4ee5 | |||
| 02767cac47 | |||
| da03cdd666 | |||
| 03dd903e07 | |||
| 43349579e1 | |||
| 2a7a7c01da | |||
| 2a159428dd | |||
| 5a8dbaee0d | |||
| 24a6a718e0 | |||
| 57d0600fc0 | |||
| fedbe81690 | |||
| a7b3416619 | |||
| f357c89ac7 | |||
| b9415dcb17 | |||
| 65ba1b16e3 | |||
| 8c4ec67282 | |||
| 36e7dbf8aa | |||
| c56bfe21e5 | |||
| d533a94718 | |||
| fe18393d4e | |||
| e41e7ff9e1 | |||
| 1100872152 | |||
| 16ab12a61c | |||
| 0f6be51c35 | |||
| 463111dfd6 | |||
| 4874544955 | |||
| 256476268f | |||
| a57d579b0e | |||
| 49bf0fa1d3 | |||
| 1b4764e328 | |||
| db3333e26d | |||
| 1092031a9f | |||
| f386a5f9c3 | |||
| 82318da484 | |||
| 14784266b3 | |||
| 362e77f3dd | |||
| dfafffe154 | |||
| bd4464ed19 | |||
| 061a044a70 | |||
| c6a4a3c841 | |||
| 0ef451e15d | |||
| 6771904932 | |||
| c002afe043 | |||
| 0a2c25ae75 | |||
| a15decb6f7 | |||
| 599136e4dc | |||
| a7d5efc764 | |||
| 8a76f71edd | |||
| e482f52f10 | |||
| a6af160627 | |||
| 8c9646b65c | |||
| 1d09f243dd | |||
| ec4ca97af4 | |||
| 21ef8960c4 | |||
| a5bef2980b | |||
| ab2ee0fed3 | |||
| ab9e04e007 | |||
| a3a6787776 | |||
| 0ead0199a3 | |||
| 0af2fc7e3d | |||
| bc2180116f | |||
| 1b70701795 | |||
| 98e1e0e279 | |||
| b03fcefaae | |||
| 3474482f6f | |||
| da361a8a86 | |||
| 4326d5588e | |||
| 4a70b5b27e | |||
| 43caac13d5 | |||
| 465f923393 | |||
| 782a829cb9 | |||
| fd863b89ca | |||
| 4fb4e77007 | |||
| a56a31421d | |||
| d33e324b31 | |||
| 4879769cc7 | |||
| da274a3ced | |||
| b9be0fe4e1 | |||
| 9db530565d | |||
| fe6306b7d3 | |||
| 240563e093 | |||
| 047e54ce50 | |||
| ee76088f86 | |||
| 8db46bd9d9 | |||
| 67e2fc54c2 |
+195
@@ -1,9 +1,204 @@
|
||||
## v2.1.1
|
||||
- fix: **mesh peer removal now cleans up its kernel routes.** `wg_iface.removePeer()` dropped the WireGuard peer entry but left the `ip route` entries `setPeer()` had added, so a removed peer's subnet stayed routed into a dead tunnel. Fixed by querying `wg show <iface> allowed-ips` before removal and `ip route del`-ing each CIDR. Verified live: routes present after `setPeer`, gone after `removePeer`, this gateway's own local route untouched. Exposed via `DELETE /api/mesh/gateways/:id` + a remove button in the mesh UI.
|
||||
- feat: **`GET /api/mesh/self`** — this gateway's own mesh IP, gated by any valid self-service API token rather than a full jump-admin session, so an unattended local script (e.g. `theta-suite`'s no-inbound relay bootstrap) can discover it without admin credentials.
|
||||
- fix: **`/api/mesh/register` was unreachable via HTTP.** `routes/api.js` mounted `/` (admin-session-gated `routes/jump.js`) before `/mesh`; since `router.use('/', ...)` matches every `/api/*` path, every `/api/mesh/*` request — including `/register`, authenticated by a bearer mesh join token, not an admin session — hit that admin gate first and 401'd before `routes/mesh.js` ever ran. A real gateway-to-gateway `/join` call failed with a `checkApiToken`/`LoginFailed` error instead of registering. Found live-testing the new `/self` endpoint with two real containers; fixed by mounting `/mesh` first.
|
||||
- fix: **the initiating side of a mesh join never recorded its own identity.** `POST /register` (the receiving side) persists a `(self)` registry entry via `ensureOwnMeshIndex()`, but `POST /join` (the initiating side) never did, so `GET /api/mesh/self` and the mesh UI's own-entry handling silently saw nothing on whichever gateway called `/join`. Fixed by registering a self-entry there too, using the exact mesh index the remote assigned (`models/mesh_gateway.js`'s `register()` now accepts an explicit `meshIndex` instead of always auto-picking one from the local registry). Verified with two real meshed containers: both sides now report their own correct mesh IP.
|
||||
|
||||
## v2.1.0
|
||||
- feat: **Gateway-to-gateway WireGuard mesh** (`routes/mesh.js`) — real site-to-site tunnels between theta-gateway instances, distinct from the existing roaming-client/exit-node WireGuard feature. Join-token bootstrap (`POST /api/mesh/join-tokens`, `/register`, `/join`), mesh-index addressing (172.24.\<idx\>.0/16 + 10.\<idx\>.0.0/16, per `theta-suite`'s `docs/MULTI_SITE_SPEC.md`).
|
||||
- feat: **In-kernel WireGuard with a userspace fallback** (`utils/wg_iface.js`) — prefers `ip link add type wireguard`, falls back to `wireguard-go` when the kernel module isn't available (older/hardened kernels, some container images, non-Linux). Both packages added to the Dockerfile.
|
||||
- feat: **mDNS local-discovery announcer** (`services/mdns_announce.js`) — advertises which public hostnames this site fronts (opt-in via `THETA_LOCAL_DISCOVERY_HOSTS`) so a `theta-agent` on the same LAN segment can skip the relay/WAN path. Companion piece to `theta-agent`'s discovery listener.
|
||||
- feat: **Mesh UI** (`/mesh`) — gateway identity (interface, kernel-vs-userspace mode), join-token minting, remote-join form, meshed-gateways table.
|
||||
- Verified with real two-container tests, not mocks: an actual encrypted WireGuard tunnel passing ICMP traffic end to end (0% loss), and the mDNS announce/discover/apply/revert cycle over real multicast. Two real bugs found and fixed along the way: `wg set ... allowed-ips` doesn't add a kernel route (a real handshake completed with zero routing, `ping` still failed, until `setPeer()` was fixed to add `ip route add` itself); and mDNS's default IPv6 query aborting the entire lookup — discarding an already-valid IPv4 response — when IPv6 isn't available.
|
||||
|
||||
## v2.0.1
|
||||
- docs: **Rebranded to Theta Gateway across the docs.** README title/links updated; removed the "Standalone Docker" and "Bare metal" install paths, which contradicted the Deployment section's own "exclusively via Docker Compose within Theta Suite" claim. Fixed stale links to the old per-repo GitHub Pages sites (`sso-manager-node`, `theta-env`) — now point at the unified `theta42.github.io/theta-suite/` docs site.
|
||||
|
||||
## v2.0.0
|
||||
- feat: **WireGuard Gateway Management UI & API.** Integrated complete WireGuard exit node management (`/wireguard`), client peer creation with instant QR code rendering and `.conf` configuration file downloads.
|
||||
- feat: **Automatic WireGuard Bootstrap.** Automatically generates an X25519 gateway keypair on initial boot if missing and registers the local default exit node (`718it (This Site)`).
|
||||
- feat: **Query Token Authentication.** Added `?token=` parameter fallback to `middleware/auth.js` for direct browser `.conf` profile file downloads.
|
||||
- fix: **UI Confirm Banners.** Added `actionMessage` container placeholders to cards for `app.messages.confirm()` rendering.
|
||||
|
||||
## v1.19.1
|
||||
- docs: README.md and docs/architecture.md described host-access authorization as a client-side loop over each of a user's LDAP groups (`GET /api/discovery/resources?group=<cn>` per group). The actual code (`utils/access.js`, `accessibleHosts()`) makes one call to the SSO's `GET /api/discovery/access/:uid`, which resolves the user's groups server-side. Corrected both.
|
||||
|
||||
## v1.19.0
|
||||
- fix: **only catalog hosts are jump targets.** `isManagedHost` treated a missing `metadata.managed` flag as permission, so any host the SSO merely *discovered* — an unpromoted Proxmox guest, a UniFi client — was offered in the TUI picker and accepted by the username grammar. The filter is now `isCatalogHost`, mirroring the SSO Directory's own rule: a resource carrying `discovery_sources` but never promoted is excluded, while hand-created hosts (no `discovery_sources`) and promoted ones (`managed: true`) are included, and an explicit `managed: false` is always excluded.
|
||||
- test: regression coverage for all five cases (hand-made, discovered-unpromoted, discovered-promoted, `manual` source, explicitly unmanaged).
|
||||
- docs: `docs/connecting.md` states that discovery results are not jump targets until promoted into the catalog.
|
||||
|
||||
## v1.18.0
|
||||
- feat: Add SSO-style error page (404/500) for browser navigation instead of a bare text response
|
||||
- feat: navbar — username no longer underlined; only the active link is bold + underlined
|
||||
|
||||
## v1.16.1
|
||||
- fix: remove missing DEPLOYMENT.md from Docker build context
|
||||
|
||||
## v1.16.0
|
||||
- Added OpenBao PKI SSH Certificate Support
|
||||
- Fallback to LDAP Key injection
|
||||
|
||||
# v1.15.0
|
||||
- feat: Rename SSO Manager to Jump in UI
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. Format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [1.14.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
|
||||
jump host 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.14.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/jump-host/conf` over the file-loaded config. The jump
|
||||
host authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy
|
||||
`jump-host` — read-only on its own path), never the root token. Because the
|
||||
OIDC `clientSecret` is captured at require time inside `createOidcClient`
|
||||
(during `require('../models')`), `bin/www` now runs `bao-conf.init()`
|
||||
**before** `require('../models')`. Fail-soft: if OpenBao is unreachable,
|
||||
boot continues from `CONF_SECRETS`. The `config/jump-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.11.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
- **`app_super_admin` (cross-app) and `app_jump_admin` groups**: super admins are full admins here same as `app_sso_admin`; jump admins get audit page/data access without other admin rights. The Audit page/API is now actually admin-gated server-side (previously the page shell rendered for any logged-in user, only its data was gated).
|
||||
- **Host list adds Last connection/Last failed connection columns** and highlights rows green (a session is live right now) or yellow (the most recent attempt failed), backed by new per-host last-success/last-fail timestamps in `models/metrics.js`. `services/ssh_server.js` now attributes grammar/TUI connect failures to the resolved host when one was found, not just aggregate counters.
|
||||
|
||||
### Changed
|
||||
- **Dashboard's stat boxes and Top hosts/Top users cards moved to the Audit page** (audit is now the admin-facing metrics home; dashboard stays focused on "hosts I can reach"). "All hosts" renamed to "My hosts".
|
||||
|
||||
## [1.10.2] - 2026-07-30
|
||||
|
||||
### Changed
|
||||
- **Dashboard, Sessions, and Audit pages now match sso-manager-node/proxy's page width**, wrapping content in a standard container instead of rendering full-bleed inside the fluid shell.
|
||||
- **Audit's nav entry is now admin-gated** (`groups: ['admin']` in `utils/ui.js`), reusing the existing synthetic-admin-group nav-gating convention — the API route was already server-side admin-gated; this hides the nav link for non-admins too.
|
||||
|
||||
## [1.10.1] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **The API-token reveal modal silently didn't show after creating a token** — `submitApiToken()` called `app.modal.close()` immediately before `showToken()`'s `app.modal.open()` in the same tick, colliding with Bootstrap's hide-transition guard on the singleton modal. Same root cause as the OAuth-secret-reveal race fixed in sso-manager-node (v1.8.2) and the create-token race fixed in proxy (v1.7.0).
|
||||
|
||||
## [1.10.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **API-token UI unified with sso-manager-node/proxy**: card grid replacing the bare table, a new Edit modal (footer shows real created-by/on data), and a Description field on both the create and edit flows — the model and API already fully supported all of this, it just wasn't exposed anywhere in the dashboard.
|
||||
|
||||
### Changed
|
||||
- `@simpleworkjs/frontend` bumped to `^0.2.6` (this app was still on `^0.2.5`).
|
||||
|
||||
## [1.9.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **"Quick Jump" copy-to-clipboard section on the dashboard** — the `uid_-_target` grammar-mode SSH command was documented in the README but nowhere in the UI. A new card gives a one-click-copy command for interactive-picker mode, and every row in "Hosts you can reach" has its own copy button for the exact grammar-mode command to that host, ready to paste and run as-is (uses the logged-in user's own uid).
|
||||
|
||||
## [1.8.2] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **Audit records for a failed upstream connection only ever said `upstream-unreachable`** — `resolveAndConnect` discarded the real error from `connectUpstream` (ECONNREFUSED, ETIMEDOUT, an ssh2 auth-failure message, etc.) and replaced it with that one generic string, so there was no way to tell a network-layer failure from an auth failure from the audit log alone. This is what blocked root-causing the "Could not reach 192.168.1.206" (emby host) report — the real error is now captured and surfaced as a new `failDetail` field on the audit record, shown as a tooltip on the fail badge in the admin audit table.
|
||||
|
||||
## [1.8.1] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **Redis had zero persistence** (`--save '' --appendonly no`, no data-dir volume) — every container rebuild/recreation silently wiped all sessions, in-flight OAuth logins, and any admin-created API token. This is why re-running `setup.sh` appeared to "break OAuth with jump": the jump-host container gets recreated, and any token or in-flight login vanished with it. Now Redis persists (AOF + periodic RDB) to `/data`, mounted as a named volume (`jump-redis-data`) in theta-env's compose file. Verified live: minted a PAT, force-recreated the container, confirmed the same PAT still authenticated afterward.
|
||||
|
||||
## [1.8.0] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **TUI-mode SSH connections (a bare `ssh user@host`, no target) could drop with "PTY allocation request failed" / "shell request failed"** — `runTuiSession` awaited two real round-trips (an audit-log write, then a directory API call) *before* attaching the session's pty/shell/exec listeners, so a client that sent those requests quickly enough got auto-rejected by ssh2 before anything was listening. `runGrammar` (the `uid_-_target` path) already had the equivalent fix; this ports it to the picker path.
|
||||
- **`formAJAX`'s loading indicator showed literal HTML**, not a spinner — same fix as sso-manager-node/proxy's companion releases.
|
||||
|
||||
## [1.7.1] - 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. This app has never had one; keeps it that way.
|
||||
|
||||
## [1.7.0] - 2026-07-27
|
||||
|
||||
### Added
|
||||
- **Self-service API tokens (PATs)** — `models/api_token.js` + `routes/api_token.js` (mounted at `/api-token`), Bearer-token support in `middleware/auth.js`, and a create/list/rotate/revoke card on the dashboard. Ports proxy's `jmp_<id>_<secret>` pattern; unlike proxy's, a jump-host token carries no group claims, so it authenticates as its creator for non-admin routes only (never passes `requireAdmin`). jump-host previously had no PAT support at all.
|
||||
|
||||
## [1.6.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` (unused by any current view here, so this is dedup/future-proofing rather than a behavior change). `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched.
|
||||
|
||||
## [1.5.0] - 2026-07-27
|
||||
|
||||
### Added
|
||||
- **Web UI dashboard now lists the hosts you can reach** ("Hosts you can reach", or "All hosts" for admins) — previously the dashboard only showed usage metrics, with no way to see your actual access from the browser. Backed by a new `GET /api/user/hosts` endpoint (auth-only, not admin-gated): admins get the full inventory via `utils/access.js`'s new `allHosts()`, everyone else gets the same group-based resolution the SSH front door uses.
|
||||
- `utils/access.js`'s `accessibleHosts()` now accepts a pre-resolved `groups` array on the user object, skipping the LDAP `getGroups(dn)` round-trip — the web UI's OIDC session already has its groups claim and has no LDAP `dn` to query with.
|
||||
|
||||
### Fixed
|
||||
- **Bumped `@simpleworkjs/ldap` to 1.0.1**, which fixes `addSshKey` throwing `ObjectClassViolationError` (LDAP `0x41`) on accounts predating the `ldapPublicKey` auxiliary objectClass. This is the code path this jump host's key-injection (`utils/key_inject.js`) uses on every first connection for a user — on affected accounts it aborted the SSH connection entirely (`key-inject-failed`).
|
||||
|
||||
## [1.4.0] - 2026-07-26
|
||||
|
||||
### Added
|
||||
- **Standalone mode** — run the jump host with no LDAP directory and no SSO Manager at all. Set `standalone.enabled: true` and user authentication and host discovery switch to `@simpleworkjs/orm`-backed stores (Sequelize; SQLite by default, any Sequelize-supported dialect via `conf.orm`) instead of the directory services. `models/user_ldap.js` and `utils/access.js` become conditional facades that pick their backend at require time — `ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, and the web UI are unchanged either way.
|
||||
- New ORM models: `StandaloneUser` (`uid`, `passwordHash`, `sshPublicKeys`, `groups`) and `StandaloneHost` (`slug`, `displayName`, `kind`, `metadata`), plus `models/user_file.js` and `utils/hosts_file.js`, which implement the same interfaces as the LDAP client and `accessibleHosts()` respectively. There's no admin UI for standalone users/hosts yet — see the README's "Standalone mode" section for the ORM-model seeding snippet. In standalone mode every stored host is reachable by every stored user; there's no group-based authorization yet.
|
||||
- 47 tests pass (24 existing + 15 new unit + 3 existing integration + 5 new standalone integration).
|
||||
|
||||
### Fixed
|
||||
- **`services/ssh_server.js` used `|| 2222` for the listen port**, so an explicit `listenPort: 0` (ephemeral port, used by the test suite) was silently overridden back to 2222. Changed to `?? 2222`.
|
||||
- **`services/ssh_server.js` awaited `audit.create()` before registering session listeners.** A client that sends `exec`/`shell` immediately after connecting could have its request dropped because nothing was listening yet. Listener registration now happens first.
|
||||
|
||||
## [1.3.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.
|
||||
- `#spa-shell` dropped its inline `margin-top`; `styles.css` already sets it and the shared shell adjusts it when a banner is shown.
|
||||
|
||||
### 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.2.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`, `routes/auth.js`; `models/index.js` wires the factory and the local-admin bootstrap.
|
||||
- `@simpleworkjs/directory-schema` — the sso↔jump-host directory contract. `utils/access.js` now fetches reachable hosts through the shared `createDirectoryClient` (`getResourcesByGroup`).
|
||||
- `@simpleworkjs/ldap` — `models/user_ldap.js` is now a thin wrapper over `createLdapClient`, preserving this app's loose TLS default (`rejectUnauthorized: false`) and the exact export shape.
|
||||
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `build_info` moved from `models/` to `utils/`; `routes/render.js` uses `mountStaticModules`.
|
||||
|
||||
### Fixed
|
||||
- **Directory envelope drift was silently treated as "no reachable hosts".** `utils/access.js` previously read `data.results || []`, so if the SSO directory ever returned a bare array (envelope drift) every per-group query collapsed to `[]` and no user could bridge. The shared client now validates the `{ results }` envelope on every call and treats an envelope violation as a failed group fetch rather than silently returning `[]`.
|
||||
|
||||
### Changed
|
||||
- Dependency alignment: `ldapts` `^8.1.2` → `^8.1.8`, `redis` `^4.7` → `^6.1.0` (the direct `redis` dep is unused — only `model-redis` is used, which already brings `redis` ^6.1.0). The 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.1.0] - 2026-07-23
|
||||
|
||||
### Changed
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ FROM node:22-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
redis-server dumb-init ca-certificates \
|
||||
iproute2 wireguard-tools wireguard-go \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -37,7 +38,7 @@ COPY nodejs/utils ./utils
|
||||
COPY nodejs/views ./views
|
||||
COPY nodejs/public ./public
|
||||
|
||||
COPY README.md CHANGELOG.md DEPLOYMENT.md /
|
||||
COPY README.md CHANGELOG.md /
|
||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
|
||||
@@ -1,81 +1,54 @@
|
||||
# Theta42 Jump Host
|
||||
# Theta Gateway
|
||||
|
||||
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
|
||||
stack. Users SSH into one public host and land on any downstream host they're
|
||||
entitled to — authenticated against the shared LDAP directory, authorized from
|
||||
the [SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory
|
||||
graph, audited end to end.
|
||||
An SSH jump gateway and integrated WireGuard mesh network router for the [Theta Suite](https://github.com/theta42/theta-suite) ecosystem. Users SSH into one entry point (`:2222`) and reach target downstream hosts according to directory permissions, with full auditing end-to-end.
|
||||
|
||||
## Two ways to connect
|
||||
Theta Gateway authenticates users against the shared OpenLDAP directory, authorizes access using **Theta Directory** (`theta-directory`), and routes cross-site mesh traffic with native WireGuard subnets and NETMAP shadow network support.
|
||||
|
||||
**Documentation:** [https://theta42.github.io/theta-suite/jump-host/](https://theta42.github.io/theta-suite/jump-host/)
|
||||
|
||||
## Access Flow
|
||||
|
||||
**Direct (WinSCP/SFTP-friendly):**
|
||||
|
||||
```
|
||||
ssh alice_-_web01@jump.example.com # -> host slug 'web01' / 'host_web01'
|
||||
```bash
|
||||
ssh alice_-_web01@jump.example.com # -> target host slug 'web01'
|
||||
sftp -P 2222 alice_-_web01@jump.example.com # SFTP passes through unchanged
|
||||
```
|
||||
|
||||
The username grammar is `{uid}_-_{target}`. `target` is a directory host slug
|
||||
(with or without the `host_` prefix), a bare hostname, or an IP.
|
||||
The username grammar is `{uid}_-_{target}`. `target` is a directory host slug or hostname.
|
||||
|
||||
**Interactive picker:**
|
||||
**Interactive host picker:**
|
||||
|
||||
```
|
||||
```bash
|
||||
ssh alice@jump.example.com
|
||||
```
|
||||
|
||||
Plain login shows a TUI list of the hosts you can reach; pick one and you're
|
||||
bridged straight in.
|
||||
Plain login displays a TUI list of target hosts assigned to the local site (`SITE_SLUG`) that the user is authorized to reach.
|
||||
|
||||
## How it works
|
||||
## How it Works
|
||||
|
||||
1. **Inbound auth** — LDAP. Public key (matched against your `sshPublicKey`, the
|
||||
jump host's own injected key excluded) or password (LDAP bind; the
|
||||
`ssh.passwordAuth` policy can restrict passwords to local clients or disable
|
||||
them — keys-only is recommended for a public host).
|
||||
2. **Authorization** — the hosts you may reach are the union of your LDAP groups
|
||||
× the SSO directory (`/api/discovery/resources?group=<cn>`). No directory
|
||||
entry, no access.
|
||||
3. **Key injection** — on first use the jump host appends its own public key to
|
||||
your `sshPublicKey` in LDAP (comment-marked), then connects downstream **as
|
||||
you** using its private key. Downstream hosts already serve keys from LDAP
|
||||
via [ldap-client](https://github.com/theta42/ldap-client)'s
|
||||
`AuthorizedKeysCommand`, so nothing downstream needs changing.
|
||||
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
|
||||
downstream sshd. Every session is audited.
|
||||
1. **Inbound Auth** — OpenLDAP authentication via public key matching (`sshPublicKey`) or LDAP password bind.
|
||||
2. **Authorization** — Calls Theta Directory's access API (`GET /api/discovery/access/:uid`) to evaluate LDAP group memberships and site-filtered host entitlement.
|
||||
3. **Key Injection** — Appends its gateway public key to user `sshPublicKey` in LDAP and connects downstream as the user.
|
||||
4. **Bridge & Audit** — Slices shell/SFTP subsystem to downstream sshd with session audit logging.
|
||||
|
||||
## Requirements
|
||||
## Deployment
|
||||
|
||||
- The SSO Manager (OpenLDAP directory + `/api/discovery`).
|
||||
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
|
||||
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
|
||||
user entries (see the ACL note in `secrets.js.example`).
|
||||
- An SSO API token (`sso_…`) for the directory queries.
|
||||
Theta Gateway is deployed exclusively via Docker Compose as an integrated service within **Theta Suite** — it is not installed or run on its own:
|
||||
|
||||
## Install
|
||||
|
||||
### Unified theta-env stack (recommended)
|
||||
|
||||
Enable it in `theta-env/setup.env` (`CFG_JUMP_HOST_ENABLED=true`) and re-run
|
||||
`./setup.sh`. The stack wires the LDAP bind account, the write-ACL, the API
|
||||
token, and a directory entry automatically.
|
||||
|
||||
### Standalone Docker
|
||||
|
||||
```
|
||||
cp secrets.js.example config/jump-secrets.js # then edit it
|
||||
docker compose up -d --build
|
||||
```bash
|
||||
git clone --recursive https://github.com/theta42/theta-suite.git
|
||||
cd theta-suite
|
||||
cp setup.env.example setup.env # set CFG_DOMAIN to your domain
|
||||
./setup.sh # generates config, builds, and starts Theta Suite
|
||||
```
|
||||
|
||||
### Bare metal
|
||||
Enable it via `CFG_JUMP_HOST_ENABLED=true` in `setup.env` and re-run
|
||||
`./setup.sh`. The stack wires the LDAP bind account (write access to the
|
||||
`sshPublicKey` attribute), the write-ACL, the SSO API token, and a directory
|
||||
entry automatically.
|
||||
|
||||
```
|
||||
curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash
|
||||
sudo $EDITOR /etc/jump-host/secrets.js # fill in LDAP + SSO
|
||||
sudo systemctl restart jump-host
|
||||
```
|
||||
|
||||
Installs to `/opt/theta42/jump-host`; idempotent (re-run to update).
|
||||
See the main [Theta Suite README](https://github.com/theta42/theta-suite) for full details on multi-site configuration, WireGuard mesh routing, and network setup.
|
||||
|
||||
## Ports
|
||||
|
||||
@@ -92,8 +65,8 @@ The default SSH port is **2222** so the service needs no privilege. To listen on
|
||||
## Web UI / API
|
||||
|
||||
`https://jump.example.com/` (behind the proxy) — built on the same
|
||||
Express + EJS + Bootstrap stack as the [SSO Manager](https://theta42.github.io/sso-manager-node/)
|
||||
and [Proxy](https://theta42.github.io/proxy/), so it looks and behaves like the
|
||||
Express + EJS + Bootstrap stack as [Theta Directory](https://theta42.github.io/theta-suite/sso/)
|
||||
and [Theta Proxy](https://theta42.github.io/theta-suite/proxy/), so it looks and behaves like the
|
||||
rest of the stack. Login is **OIDC against the SSO** (the "Log in with SSO"
|
||||
button) plus a **local anti-lockout admin** that works even if the SSO is
|
||||
unreachable. Admin access requires membership in `auth.adminGroups` (default
|
||||
@@ -110,6 +83,22 @@ Config layers via [@simpleworkjs/conf](https://www.npmjs.com/package/@simplework
|
||||
`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env.
|
||||
See `secrets.js.example` for every key.
|
||||
|
||||
## Secrets
|
||||
|
||||
At boot, [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
|
||||
deep-merges `secret/jump-host/conf` from **OpenBao** over the file-loaded
|
||||
config. The jump host's OIDC `clientSecret` is captured at require time
|
||||
(inside `createOidcClient` during `require('../models')`), so `bin/www` runs
|
||||
`bao-conf.init()` **before** `require('../models')`. Fail-soft: if OpenBao is
|
||||
unreachable, boot continues from `CONF_SECRETS`. The jump host authenticates to
|
||||
OpenBao with the scoped `VAULT_TOKEN` (env, policy `jump-host` — read only
|
||||
`secret/jump-host/conf`), never the root token.
|
||||
|
||||
The `config/jump-secrets.js` file is an operator-edit seed artifact
|
||||
(gitignored); the bootstrap writes the generated API token + OAuth client
|
||||
into OpenBao, which is authoritative. For the full architecture see
|
||||
theta-suite's **[Secrets docs](https://theta42.github.io/theta-suite/secrets.html)**.
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
|
||||
+11
-3
@@ -12,9 +12,17 @@ if [[ -f /config/jump-secrets.js ]]; then
|
||||
info "Loaded config from /config/jump-secrets.js"
|
||||
fi
|
||||
|
||||
# Redis for audit/metrics/session storage (app connects to 127.0.0.1:6379).
|
||||
info "Starting redis..."
|
||||
redis-server --daemonize yes --save '' --appendonly no
|
||||
# Redis for audit/metrics/session AND api-token storage (app connects to
|
||||
# 127.0.0.1:6379). Persisted (AOF + periodic RDB) to /data, which the
|
||||
# deployment should mount as a volume -- without this, every container
|
||||
# recreation silently wiped every session, in-flight OAuth login, and any
|
||||
# admin-created API token, which is especially bad for the last one since a
|
||||
# PAT is meant to be a stable, long-lived credential, not session state.
|
||||
REDIS_DATA_DIR="${REDIS_DATA_DIR:-/data}"
|
||||
mkdir -p "$REDIS_DATA_DIR"
|
||||
info "Starting redis (AOF persisted to $REDIS_DATA_DIR)..."
|
||||
redis-server --daemonize yes --dir "$REDIS_DATA_DIR" --appendonly yes \
|
||||
--appendfilename appendonly.aof --save 900 1 --save 300 10 --save 60 10000
|
||||
|
||||
# Wait for redis to answer before starting the app.
|
||||
for _ in $(seq 1 20); do
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
title: Jump Host
|
||||
description: An SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics.
|
||||
description: An SSH jump host for the theta42 stack — directory-driven host bridging with audit and metrics; LDAP + SSO Manager by default, or fully standalone.
|
||||
url: "https://theta42.github.io"
|
||||
baseurl: "/jump-host"
|
||||
logo: /assets/img/theta42.svg
|
||||
|
||||
+29
-5
@@ -41,11 +41,13 @@ Every attempt — success or failure, with method and reason — is audited.
|
||||
## 2. Access & target resolution
|
||||
|
||||
The hosts a user may reach are computed from the directory, not a local list:
|
||||
|
||||
1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`).
|
||||
2. For each group, the SSO's
|
||||
`GET /api/discovery/resources?group=<cn>` (authenticated with an API token),
|
||||
unioned and filtered to `kind: host`.
|
||||
the jump host calls the SSO's `GET /api/discovery/access/:uid` (authenticated
|
||||
with an API token) once per user; the SSO evaluates the user's LDAP group
|
||||
memberships server-side and returns the full access projection in one
|
||||
response, already filtered to `kind: host`. (The jump host also has an
|
||||
admin-only `allHosts()` path, used for the unfiltered catalog listing, which
|
||||
does call `GET /api/discovery/resources?group=<cn>` per group — but that's
|
||||
not the per-user authorization path.)
|
||||
|
||||
Each host's dial address is `metadata.ip` (or the hostname from
|
||||
`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached
|
||||
@@ -112,6 +114,28 @@ Audit events and counters live in redis. Each event captures: user, auth method,
|
||||
mode (grammar/picker), target slug/address/port, channel type, client IP,
|
||||
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
|
||||
|
||||
## Standalone mode
|
||||
|
||||
Everything above describes the default backend. Set `standalone.enabled: true`
|
||||
and two modules become conditional facades, swapping their entire
|
||||
implementation at `require` time based on that flag — nothing else in the
|
||||
codebase (`ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, the
|
||||
web UI) changes or even knows which mode it's running in:
|
||||
|
||||
- **`models/user_ldap.js`** — LDAP client, or `models/user_file.js` (an
|
||||
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)-backed
|
||||
store implementing the same `getUser` / `getGroups` / `checkPassword` /
|
||||
`addSshKey` interface).
|
||||
- **`utils/access.js`** — LDAP groups + SSO `/api/discovery`, or
|
||||
`utils/hosts_file.js` (same ORM package, same `accessibleHosts()` interface).
|
||||
In standalone mode there's no group-based authorization: every stored host
|
||||
is accessible to every stored user.
|
||||
|
||||
The ORM is Sequelize underneath, defaulting to a local SQLite file but
|
||||
accepting any Sequelize-supported dialect via `conf.orm`. See
|
||||
[Installation](installation.html#standalone-mode) for config and how to add
|
||||
users/hosts (there's no admin UI for standalone data yet).
|
||||
|
||||
## Where it sits in the stack
|
||||
|
||||
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
|
||||
|
||||
+15
-3
@@ -66,14 +66,26 @@ directory access allows — it doubles as "what can I reach from here?"
|
||||
## What you can reach
|
||||
|
||||
The set of hosts is computed per login: your LDAP group memberships intersected
|
||||
with the SSO directory's hosts (via the `host_<name>_access` groups the
|
||||
directory auto-creates for each machine). To get access to a new host, an admin
|
||||
adds you to that host's access group in the SSO — nothing on the jump host
|
||||
with the SSO directory's **catalog** hosts (via the `host_<name>_access` groups
|
||||
the directory auto-creates for each machine). To get access to a new host, an
|
||||
admin adds you to that host's access group in the SSO — nothing on the jump host
|
||||
changes.
|
||||
|
||||
Targets that don't resolve to a host you're allowed to reach are refused (and
|
||||
audited). Raw IPs that aren't a known directory host are denied by default.
|
||||
|
||||
**Only catalog hosts are jump targets.** A machine that the SSO merely
|
||||
*discovered* — a Proxmox guest, a UniFi client — is not a jump target until an
|
||||
admin promotes it into the directory catalog. The jump host applies the same
|
||||
rule the SSO's own Directory listing does: a resource carrying
|
||||
`discovery_sources` but never promoted is excluded, while hand-created hosts and
|
||||
promoted ones are included. Previously the filter treated a missing `managed`
|
||||
flag as permission, so unpromoted discovery results showed up in the picker.
|
||||
|
||||
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
|
||||
> every registered host is reachable by every registered user — there's no
|
||||
> group-based restriction to ask an admin about.
|
||||
|
||||
## Authentication
|
||||
|
||||
The jump host authenticates **you** against the directory:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
+18
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: Home
|
||||
description: An SSH jump host for the theta42 stack — one public host, LDAP login, and directory-driven access to every downstream machine you're entitled to.
|
||||
description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to; LDAP by default, or fully standalone.
|
||||
---
|
||||
|
||||
# Jump Host
|
||||
@@ -21,6 +21,21 @@ Part of the theta42 self-hosted identity stack, alongside
|
||||
[Proxy](https://theta42.github.io/proxy/), composable with one command via
|
||||
[theta-env](https://theta42.github.io/theta-env/).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
|
||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
|
||||
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
|
||||
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
|
||||
|
||||
*(click any screenshot to view full size)*
|
||||
|
||||
Don't want to run LDAP or the SSO Manager? **Standalone mode** stores users
|
||||
and hosts in a local SQL database instead (SQLite by default, any
|
||||
Sequelize-supported dialect if you want something else) — same SSH front door,
|
||||
key injection, and audit trail. See
|
||||
[Installation](installation.html#standalone-mode) to get started.
|
||||
|
||||
## Two ways to connect
|
||||
|
||||
**Direct (WinSCP/SFTP-friendly):**
|
||||
@@ -79,6 +94,8 @@ This jump host answers both from your directory:
|
||||
audit log, per-user/per-host counters
|
||||
- **Full audit trail** — who, target, method, result, bytes, duration, and the
|
||||
downstream host-key fingerprint
|
||||
- **Standalone mode** — no LDAP, no SSO Manager; users and hosts live in a
|
||||
local SQL database (Sequelize, any dialect — SQLite by default)
|
||||
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
|
||||
installer, or bundled in theta-env
|
||||
|
||||
|
||||
+46
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: Installation
|
||||
description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus the required LDAP write-ACL and port-22 options.
|
||||
description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus standalone mode (no LDAP/SSO), the LDAP write-ACL, and port-22 options.
|
||||
---
|
||||
|
||||
# Installation
|
||||
@@ -10,6 +10,51 @@ Three ways to run the jump host, in increasing manual effort. All read their
|
||||
config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf)
|
||||
(`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env).
|
||||
|
||||
## Standalone mode (no LDAP/SSO) {#standalone-mode}
|
||||
|
||||
Skip LDAP and the SSO Manager entirely. Not to be confused with "Standalone
|
||||
Docker" below, which is still LDAP + SSO, just run outside theta-env. Set in your secrets/config:
|
||||
|
||||
```js
|
||||
standalone: { enabled: true },
|
||||
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
|
||||
```
|
||||
|
||||
`orm` is passed straight to Sequelize, so any supported dialect works — SQLite
|
||||
is just the zero-dependency default. Everything downstream of auth (bridging,
|
||||
key injection, the web UI, audit) is unchanged.
|
||||
|
||||
There's no admin UI for standalone users/hosts yet, so add them directly with
|
||||
the ORM models:
|
||||
|
||||
```js
|
||||
const StandaloneUser = require('./models/standalone_user');
|
||||
const StandaloneHost = require('./models/standalone_host');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
await StandaloneUser.create({
|
||||
uid: 'alice',
|
||||
passwordHash: await bcrypt.hash('a real password', 10),
|
||||
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_web01',
|
||||
displayName: 'web01',
|
||||
kind: 'host',
|
||||
metadata: { ip: '10.0.0.5', sshPort: 22 },
|
||||
});
|
||||
```
|
||||
|
||||
Every host in the standalone inventory is reachable by every standalone user —
|
||||
there's no group-based authorization yet (`groups` on `StandaloneUser` is
|
||||
accepted for interface parity with the LDAP path, not enforced).
|
||||
|
||||
The rest of this page (requirements, the LDAP write-ACL, the three install
|
||||
paths) describes the default LDAP + SSO mode — skip it if you're running
|
||||
standalone.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
|
||||
|
||||
+16
-1
@@ -4,12 +4,19 @@ const express = require('express');
|
||||
const compression = require('compression');
|
||||
|
||||
require('./models'); // wire model-redis + register models
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('./utils/build_info');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', require('path').join(__dirname, 'views'));
|
||||
|
||||
// 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 render router's `values` object.
|
||||
app.locals.ui = require('./utils/ui');
|
||||
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({extended: false}));
|
||||
@@ -36,7 +43,15 @@ app.use((err, req, res, next) => {
|
||||
if(req.path.startsWith('/api/')){
|
||||
return res.status(status).json({name: err.name || 'Error', message: err.message || 'Error'});
|
||||
}
|
||||
res.status(status).send(err.message || 'Error');
|
||||
// Browser navigation gets the HTML error page (shared with SSO).
|
||||
res.status(status).render('error', {
|
||||
title: conf.environment !== 'production' ? 'dev' : '',
|
||||
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||
name: conf.name,
|
||||
logo: conf.logo,
|
||||
...buildInfo,
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
+38
-23
@@ -9,32 +9,47 @@ const http = require('http');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { Server } = require('socket.io');
|
||||
|
||||
require('../models');
|
||||
// @simpleworkjs/conf loads ./config/jump-secrets.js synchronously, then
|
||||
// @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from OpenBao over
|
||||
// it. The OIDC clientSecret is captured at require time inside models (via
|
||||
// createOidcClient), so the fetch MUST resolve before require('../models').
|
||||
// Fail-soft: if OpenBao is unreachable, init() leaves conf as the file-loaded
|
||||
// fallback and boot continues from ./config/jump-secrets.js.
|
||||
require('@simpleworkjs/bao-conf').init({ path: 'jump-host', conf }).then(async () => {
|
||||
require('../models');
|
||||
const { bootstrapWireguard } = require('../services/wg_bootstrap');
|
||||
await bootstrapWireguard();
|
||||
const { startMdnsAnnounce } = require('../services/mdns_announce');
|
||||
await startMdnsAnnounce();
|
||||
|
||||
const app = require('../app');
|
||||
const middleware = require('../middleware/auth');
|
||||
const sshServer = require('../services/ssh_server');
|
||||
const app = require('../app');
|
||||
const middleware = require('../middleware/auth');
|
||||
const sshServer = require('../services/ssh_server');
|
||||
|
||||
const webPort = (conf.web && conf.web.port) || 3002;
|
||||
const server = http.createServer(app);
|
||||
const webPort = (conf.web && conf.web.port) || 3002;
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
||||
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
||||
// working exactly as it does in the sibling apps.
|
||||
const io = new Server(server);
|
||||
io.use(middleware.authIO);
|
||||
app.io = io;
|
||||
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
||||
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
||||
// working exactly as it does in the sibling apps.
|
||||
const io = new Server(server);
|
||||
io.use(middleware.authIO);
|
||||
app.io = io;
|
||||
|
||||
server.listen(webPort, () => {
|
||||
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
||||
});
|
||||
server.listen(webPort, () => {
|
||||
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
||||
});
|
||||
|
||||
sshServer.start();
|
||||
sshServer.start();
|
||||
|
||||
function shutdown() {
|
||||
console.log('[jump-host] shutting down');
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
function shutdown() {
|
||||
console.log('[jump-host] shutting down');
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}).catch(err => {
|
||||
console.error('boot failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+38
-2
@@ -6,7 +6,7 @@
|
||||
// values (LDAP creds, SSO API token) belong in the secrets file.
|
||||
|
||||
module.exports = {
|
||||
name: 'Jump Host',
|
||||
name: 'Jump',
|
||||
logo: '/static/img/theta42.svg',
|
||||
|
||||
// LDAP directory the users live in (same directory the SSO manages).
|
||||
@@ -80,7 +80,12 @@ module.exports = {
|
||||
|
||||
auth: {
|
||||
// OIDC group memberships that grant web UI/API admin access.
|
||||
adminGroups: ['app_sso_admin'],
|
||||
// app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
|
||||
adminGroups: ['app_sso_admin', 'app_super_admin'],
|
||||
// OIDC group memberships that grant jump admin access (the audit page
|
||||
// and its data), without granting other admin-only rights. Full admins
|
||||
// (adminGroups/adminUsers) always have jump admin access too.
|
||||
jumpAdminGroups: ['app_jump_admin'],
|
||||
// Local anti-lockout admin: the first name here is bootstrapped as a
|
||||
// redis-backed user on first boot (password from localAdminPass, or a
|
||||
// random one printed to the log once). Lets you in even with OIDC down.
|
||||
@@ -98,6 +103,37 @@ module.exports = {
|
||||
maxEvents: 50000,
|
||||
},
|
||||
|
||||
// Standalone mode: run without LDAP or SSO Manager. When enabled, user
|
||||
// authentication and host discovery use @simpleworkjs/orm-backed stores
|
||||
// (Sequelize, defaulting to SQLite) instead of the directory services.
|
||||
standalone: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
// ORM config for standalone mode. Passed through to Sequelize — any dialect
|
||||
// works. Defaults to SQLite for zero-dependency local dev.
|
||||
orm: {
|
||||
dialect: 'sqlite',
|
||||
storage: './data/standalone.sqlite',
|
||||
logging: false,
|
||||
},
|
||||
|
||||
// Orchestrator-only keys (ignored by the app, read by theta-env).
|
||||
stack: {},
|
||||
|
||||
// WireGuard mesh configuration.
|
||||
// These values describe this gateway's own wg0 interface so the web UI
|
||||
// can show the server public key and build client profiles.
|
||||
// Override via environment: app_wireguard__serverPublicKey, etc.
|
||||
wireguard: {
|
||||
// Public key of this gateway's wg0 interface (set at runtime by docker-entrypoint).
|
||||
serverPublicKey: '',
|
||||
// "host:port" that WireGuard clients connect to, e.g. "gw.theta42.com:51820".
|
||||
serverEndpoint: '',
|
||||
// DNS server to push to clients, e.g. "10.1.0.1" or leave empty for none.
|
||||
dns: '',
|
||||
// Base of the IP pool for peer assignment: first two octets.
|
||||
// Peers are assigned 10.100.0.2, 10.100.0.3, …, 10.100.255.254.
|
||||
poolBase: '10.100.0',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,4 +4,12 @@ module.exports = {
|
||||
ssh: {
|
||||
hostKeyPath: './data/keys',
|
||||
},
|
||||
standalone: {
|
||||
enabled: true,
|
||||
},
|
||||
orm: {
|
||||
dialect: 'sqlite',
|
||||
storage: './data/standalone.sqlite',
|
||||
logging: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,11 +5,30 @@
|
||||
// callback. The token carries the group snapshot captured at login.
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { Auth } = require('../models/auth');
|
||||
const { Auth } = require('../models');
|
||||
|
||||
async function auth(req, res, next){
|
||||
try{
|
||||
req.token = await Auth.checkToken(req.header('auth-token'));
|
||||
// API-only token: `Authorization: Bearer jmp_<id>_<secret>`. Carries no
|
||||
// group claims (see models/api_token.js), so it authenticates as its
|
||||
// creator but never passes requireAdmin below.
|
||||
const authz = req.header('authorization') || '';
|
||||
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
||||
const t = await Auth.checkApiToken(authz.slice(7));
|
||||
req.token = {
|
||||
user: {username: t.created_by},
|
||||
created_by: t.created_by,
|
||||
groupsArray: () => [],
|
||||
check: () => true,
|
||||
is_valid: true,
|
||||
};
|
||||
req.user = req.token.user;
|
||||
req.groups = [];
|
||||
return next();
|
||||
}
|
||||
|
||||
const tokStr = req.header('auth-token') || req.query.token;
|
||||
req.token = await Auth.checkToken(tokStr);
|
||||
req.user = req.token.user;
|
||||
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
||||
return next();
|
||||
@@ -38,6 +57,25 @@ async function requireAdmin(req, res, next){
|
||||
next(error);
|
||||
}
|
||||
|
||||
// Jump admin = access to the audit page/data. A narrower grant than full
|
||||
// jump-host admin: full admins (isAdmin) always qualify, plus anyone in
|
||||
// conf.auth.jumpAdminGroups (e.g. a dedicated app_jump_admin LDAP group) can
|
||||
// be granted audit access without also getting other admin-only rights.
|
||||
function isJumpAdmin(req){
|
||||
if(isAdmin(req)) return true;
|
||||
const jumpAdminGroups = (conf.auth && conf.auth.jumpAdminGroups) || [];
|
||||
return (req.groups || []).some(g => jumpAdminGroups.includes(g));
|
||||
}
|
||||
|
||||
async function requireJumpAdmin(req, res, next){
|
||||
if(isJumpAdmin(req)) return next();
|
||||
const error = new Error('Forbidden');
|
||||
error.name = 'Forbidden';
|
||||
error.status = 403;
|
||||
error.message = 'Jump admin access required.';
|
||||
next(error);
|
||||
}
|
||||
|
||||
// Socket.IO handshake auth (app-base.js connects with the session token).
|
||||
async function authIO(socket, next){
|
||||
try{
|
||||
@@ -51,4 +89,4 @@ async function authIO(socket, next){
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { auth, requireAdmin, authIO, isAdmin };
|
||||
module.exports = { auth, requireAdmin, authIO, isAdmin, isJumpAdmin, requireJumpAdmin };
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Self-service personal access token (PAT) for the jump host's own API.
|
||||
// Format: jmp_<id>_<secret>
|
||||
// id — 24-char hex, stored plaintext as the record key (O(1) lookup)
|
||||
// secret — 48-char hex, stored only as a bcrypt hash (isPrivate); shown ONCE
|
||||
//
|
||||
// Authenticated via `Authorization: Bearer jmp_...`. Mirrors proxy's
|
||||
// models/api_token.js — see that file for the fuller design notes. jump-host
|
||||
// has no per-user group snapshot the way proxy/sso do (its authz is a single
|
||||
// admin/non-admin bit off conf.auth.adminGroups/adminUsers), so a token
|
||||
// authenticates as its creator only; the auth middleware re-derives
|
||||
// admin-ness from that user's current groups, same as a live session.
|
||||
//
|
||||
// No `static _ttl`: records persist (lifetime is the optional expires_at field).
|
||||
|
||||
const PREFIX = 'jmp_';
|
||||
const randomHex = (bytes) => crypto.randomBytes(bytes).toString('hex');
|
||||
|
||||
class ApiToken extends Table{
|
||||
static _key = 'id';
|
||||
static _keyMap = {
|
||||
'id': {default: function(){ return randomHex(12) }, type: 'string'},
|
||||
'secret_hash': {isRequired: true, type: 'string', isPrivate: true},
|
||||
'name': {isRequired: true, type: 'string', min: 1, max: 255},
|
||||
'description': {default: '', type: 'string'},
|
||||
'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},
|
||||
'expires_at': {default: 0, type: 'number'}, // epoch ms; 0 = never
|
||||
'last_used_on': {default: 0, type: 'number'},
|
||||
'is_valid': {default: true, type: 'boolean'},
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return this.expires_at > 0 && (new Date).getTime() > this.expires_at;
|
||||
}
|
||||
|
||||
static async add(data){
|
||||
const id = randomHex(12);
|
||||
const secret = randomHex(24);
|
||||
data.id = id;
|
||||
data.secret_hash = await bcrypt.hash(secret, 10);
|
||||
const token = await this.create(data);
|
||||
token._raw_token = `${PREFIX}${id}_${secret}`;
|
||||
return token;
|
||||
}
|
||||
|
||||
async rotate(){
|
||||
const secret = randomHex(24);
|
||||
await this.update({ secret_hash: await bcrypt.hash(secret, 10) });
|
||||
return `${PREFIX}${this.id}_${secret}`;
|
||||
}
|
||||
|
||||
// Validate a raw `jmp_<id>_<secret>` string. Throws a generic Error on any
|
||||
// failure so the caller (Auth.checkApiToken) can collapse every case into
|
||||
// one 401 (no existence / wrong-secret / expired leak).
|
||||
static async authenticate(raw){
|
||||
const m = /^jmp_([0-9a-f]{24})_([0-9a-f]{48})$/i.exec(String(raw || ''));
|
||||
if(!m) throw new Error('InvalidApiToken');
|
||||
let token;
|
||||
try{
|
||||
token = await this.get(m[1]);
|
||||
}catch(e){
|
||||
throw new Error('InvalidApiToken');
|
||||
}
|
||||
if(!token) throw new Error('InvalidApiToken');
|
||||
const ok = await bcrypt.compare(m[2], token.secret_hash);
|
||||
if(!ok || !token.is_valid || token.isExpired) throw new Error('InvalidApiToken');
|
||||
// Best-effort: stamp last use. Fire-and-forget so a Redis hiccup never
|
||||
// fails an otherwise-valid request.
|
||||
try{ await token.update({ last_used_on: (new Date).getTime() }); }catch(_){}
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
ApiToken.register();
|
||||
|
||||
module.exports = {ApiToken};
|
||||
@@ -1,118 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('../models');
|
||||
const {User, AuthToken} = Table.models;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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};
|
||||
@@ -1,24 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Short git commit, baked into /app/.build_commit at image build time (see
|
||||
// Dockerfile gitinfo stage) or resolved from git on bare metal.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function resolve() {
|
||||
try {
|
||||
const baked = path.join(__dirname, '../../.build_commit');
|
||||
if (fs.existsSync(baked)) return fs.readFileSync(baked, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
try {
|
||||
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
||||
} catch (_) {}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
let version = 'unknown';
|
||||
try { version = require('../package.json').version; } catch (_) {}
|
||||
|
||||
module.exports = { commit: resolve(), version };
|
||||
+35
-4
@@ -6,6 +6,7 @@
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { setUpTable } = require('model-redis');
|
||||
const { createOidcClient, bootstrapLocalAdmin } = require('@simpleworkjs/oidc-client');
|
||||
|
||||
const Table = setUpTable(conf.redis);
|
||||
|
||||
@@ -31,8 +32,38 @@ async function getRedis() {
|
||||
|
||||
module.exports.getRedis = getRedis;
|
||||
|
||||
// Register models (order matters: User before AuthToken's relation resolves).
|
||||
require('./user_redis');
|
||||
require('./token');
|
||||
require('./oidc_state');
|
||||
// Register models (order matters: User before AuthToken's relation resolves,
|
||||
// and before ApiToken so `require('.')`'s Table is already exporting User).
|
||||
require('./user_redis'); // User (redis-backed local + OIDC JIT)
|
||||
const { ApiToken } = require('./api_token');
|
||||
module.exports.ApiToken = ApiToken;
|
||||
|
||||
// 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. checkApiToken
|
||||
// wraps ApiToken.authenticate, same wiring as proxy's models/index.js.
|
||||
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;
|
||||
|
||||
require('./audit_event');
|
||||
|
||||
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
||||
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
|
||||
|
||||
// Standalone mode: initialize @simpleworkjs/orm for local user/host stores.
|
||||
// The ORM must be loaded before any code calls user_ldap or access — both of
|
||||
// which check conf.standalone.enabled at require time and may delegate to the
|
||||
// ORM-backed wrappers. Model registration is synchronous; table sync is async
|
||||
// but the first query will implicitly wait (Sequelize.sync is in-flight).
|
||||
// Export the promise so integration tests can await it before seeding data.
|
||||
let ormReady = Promise.resolve();
|
||||
if (conf.standalone && conf.standalone.enabled) {
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const ormConf = conf.orm || { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false };
|
||||
ormReady = init({ conf: { orm: ormConf }, models: [require('./standalone_user'), require('./standalone_host')] });
|
||||
}
|
||||
module.exports.ormReady = ormReady;
|
||||
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
// Registry of peer theta-gateway instances this gateway has meshed with —
|
||||
// raw Redis, same pattern as wg_site.js/audit_event.js. Each registration
|
||||
// carries what's needed to configure a local WireGuard peer entry for them:
|
||||
// public key, reachable endpoint, and the mesh IP this gateway assigned them
|
||||
// (MULTI_SITE_SPEC.md's one-octet-per-site addressing, 172.24.<idx>.0/16 +
|
||||
// 10.<idx>.0.0/16, idx 1-254).
|
||||
//
|
||||
// Redis keys:
|
||||
// mesh_gateway:<id> — hash of gateway fields
|
||||
// mesh_gateway_index — sorted set (score = createdAt, value = id)
|
||||
|
||||
const crypto = require('crypto');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { getRedis } = require('./index');
|
||||
|
||||
const MAX_MESH_INDEX = 254;
|
||||
|
||||
const P = () => conf.redis.prefix;
|
||||
const idxKey = () => `${P()}mesh_gateway_index`;
|
||||
const gatewayKey = (id) => `${P()}mesh_gateway:${id}`;
|
||||
|
||||
function serialize(obj) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = String(v == null ? '' : v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deserialize(h) {
|
||||
if (!h || !h.id) return null;
|
||||
return { ...h, meshIndex: Number(h.meshIndex || 0), createdAt: Number(h.createdAt || 0), lastSeenAt: Number(h.lastSeenAt || 0) };
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const redis = await getRedis();
|
||||
const ids = await redis.zRange(idxKey(), 0, -1);
|
||||
const out = [];
|
||||
for (const id of ids) {
|
||||
const g = deserialize(await redis.hGetAll(gatewayKey(id)));
|
||||
if (g) out.push(g);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function findByPublicKey(publicKey) {
|
||||
const all = await list();
|
||||
return all.find((g) => g.publicKey === publicKey) || null;
|
||||
}
|
||||
|
||||
function nextFreeMeshIndex(existing) {
|
||||
const used = new Set(existing.map((g) => g.meshIndex).filter(Boolean));
|
||||
for (let i = 1; i <= MAX_MESH_INDEX; i++) {
|
||||
if (!used.has(i)) return i;
|
||||
}
|
||||
throw new Error(`Mesh index space exhausted (max ${MAX_MESH_INDEX} gateways)`);
|
||||
}
|
||||
|
||||
// Register (or re-register, idempotent by publicKey) a peer gateway.
|
||||
// Re-registering the same public key updates its endpoint/siteSlug but
|
||||
// reuses its existing mesh index -- a gateway that re-registers after a
|
||||
// restart must not get bumped to a new mesh subnet.
|
||||
//
|
||||
// meshIndex is normally auto-assigned (the next free local index) -- correct
|
||||
// when THIS gateway is the one handing out indices (POST /register, for both
|
||||
// the caller and itself via ensureOwnMeshIndex). But the INITIATING side of
|
||||
// a join (POST /join) doesn't get to pick its own index -- the remote
|
||||
// already assigned it and returned it in the response -- so an explicit
|
||||
// meshIndex is accepted to record that exact value instead of whatever this
|
||||
// gateway's own local registry would have auto-picked (which has no reason
|
||||
// to agree with the value actually configured on the live wg0 interface).
|
||||
async function register({ publicKey, endpoint, siteSlug, meshIndex: explicitMeshIndex }) {
|
||||
const redis = await getRedis();
|
||||
const existing = await list();
|
||||
const already = existing.find((g) => g.publicKey === publicKey);
|
||||
|
||||
const now = Date.now();
|
||||
if (already) {
|
||||
const updated = { ...already, endpoint, siteSlug: siteSlug || already.siteSlug, lastSeenAt: now };
|
||||
await redis.hSet(gatewayKey(already.id), serialize(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
const id = crypto.randomBytes(8).toString('hex');
|
||||
const meshIndex = explicitMeshIndex || nextFreeMeshIndex(existing);
|
||||
const gateway = { id, publicKey, endpoint, siteSlug: siteSlug || '', meshIndex, createdAt: now, lastSeenAt: now };
|
||||
await redis.hSet(gatewayKey(id), serialize(gateway));
|
||||
await redis.zAdd(idxKey(), { score: now, value: id });
|
||||
return gateway;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const redis = await getRedis();
|
||||
const gw = deserialize(await redis.hGetAll(gatewayKey(id)));
|
||||
if (!gw) return null;
|
||||
await redis.del(gatewayKey(id));
|
||||
await redis.zRem(idxKey(), id);
|
||||
return gw;
|
||||
}
|
||||
|
||||
module.exports = { list, findByPublicKey, register, remove, MAX_MESH_INDEX };
|
||||
@@ -13,10 +13,34 @@ async function bump({ uid, hostSlug, success }) {
|
||||
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
|
||||
if (!success) ops.push(redis.incr(`${P()}fail`));
|
||||
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
|
||||
if (hostSlug) ops.push(redis.incr(`${P()}host_${hostSlug}`));
|
||||
if (hostSlug) {
|
||||
ops.push(redis.incr(`${P()}host_${hostSlug}`));
|
||||
// Last-attempt timestamp per host, split by outcome -- drives the
|
||||
// dashboard's "Last connection"/"Last failed connection" columns and
|
||||
// row highlighting (see lastForHosts below).
|
||||
ops.push(redis.set(`${P()}host_last_${success ? 'success' : 'fail'}_${hostSlug}`, Date.now()));
|
||||
}
|
||||
await Promise.all(ops);
|
||||
}
|
||||
|
||||
// Per-host last-success/last-fail timestamps for a given list of slugs (e.g.
|
||||
// the hosts a session can reach), for the dashboard's host list.
|
||||
async function lastForHosts(slugs) {
|
||||
const redis = await getRedis();
|
||||
const result = {};
|
||||
await Promise.all((slugs || []).map(async (slug) => {
|
||||
const [lastSuccess, lastFail] = await Promise.all([
|
||||
redis.get(`${P()}host_last_success_${slug}`),
|
||||
redis.get(`${P()}host_last_fail_${slug}`),
|
||||
]);
|
||||
result[slug] = {
|
||||
lastConnected: lastSuccess ? Number(lastSuccess) : null,
|
||||
lastFailed: lastFail ? Number(lastFail) : null,
|
||||
};
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
async function summary() {
|
||||
const redis = await getRedis();
|
||||
const [total, fail] = await Promise.all([
|
||||
@@ -37,4 +61,4 @@ async function summary() {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { bump, summary };
|
||||
module.exports = { bump, summary, lastForHosts };
|
||||
|
||||
@@ -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};
|
||||
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
// ORM model for standalone-mode hosts. Stored in the configured SQL database
|
||||
// (default SQLite) when conf.standalone.enabled is true. The hosts_file.js
|
||||
// wrapper translates between this model and the accessibleHosts() interface
|
||||
// that ssh_server.js expects.
|
||||
|
||||
const { Model, fields } = require('@simpleworkjs/orm');
|
||||
|
||||
// Patch StringField.toSequelize() to pass through primaryKey (same fix as in
|
||||
// standalone_user.js — see that file for details).
|
||||
if (!fields.StringField.prototype.toSequelize.toString().includes('primaryKey')) {
|
||||
const orig = fields.StringField.prototype.toSequelize;
|
||||
fields.StringField.prototype.toSequelize = function () {
|
||||
const def = orig.call(this);
|
||||
if (this.primaryKey) def.primaryKey = true;
|
||||
return def;
|
||||
};
|
||||
}
|
||||
|
||||
class StandaloneHost extends Model {
|
||||
static fields = {
|
||||
slug: { type: 'string', primaryKey: true },
|
||||
displayName: { type: 'string' },
|
||||
kind: { type: 'string', default: 'host' },
|
||||
metadata: { type: 'json', default: {} },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = StandaloneHost;
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
// ORM model for standalone-mode users. Stored in the configured SQL database
|
||||
// (default SQLite) when conf.standalone.enabled is true. The user_file.js
|
||||
// wrapper translates between this model and the LDAP-client interface that
|
||||
// ssh_server.js and key_inject.js expect.
|
||||
|
||||
const { Model, fields } = require('@simpleworkjs/orm');
|
||||
|
||||
// Patch: StringField.toSequelize() and IntegerField.toSequelize() don't pass
|
||||
// through primaryKey / autoIncrement (unlike UUIDField which does). Fix them
|
||||
// so string and int primary keys work.
|
||||
const origStringToSeq = fields.StringField.prototype.toSequelize;
|
||||
fields.StringField.prototype.toSequelize = function () {
|
||||
const def = origStringToSeq.call(this);
|
||||
if (this.primaryKey) def.primaryKey = true;
|
||||
return def;
|
||||
};
|
||||
const origIntToSeq = fields.IntegerField.prototype.toSequelize;
|
||||
fields.IntegerField.prototype.toSequelize = function () {
|
||||
const def = origIntToSeq.call(this);
|
||||
if (this.primaryKey) def.primaryKey = true;
|
||||
if (this.autoIncrement) def.autoIncrement = true;
|
||||
return def;
|
||||
};
|
||||
|
||||
class StandaloneUser extends Model {
|
||||
static fields = {
|
||||
uid: { type: 'string', primaryKey: true },
|
||||
passwordHash: { type: 'string', isPrivate: true },
|
||||
sshPublicKeys: { type: 'json', default: [] },
|
||||
groups: { type: 'json', default: [] },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = StandaloneUser;
|
||||
@@ -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};
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
|
||||
// ORM-backed user store for standalone mode. Implements the same interface as
|
||||
// the @simpleworkjs/ldap client so ssh_server.js and key_inject.js work
|
||||
// unchanged: getUser(uid), getGroups(dn), checkPassword(dn, pw), addSshKey(dn, keyLine).
|
||||
//
|
||||
// Users are stored via the StandaloneUser ORM model (Sequelize, any dialect).
|
||||
// DNs are synthetic: uid=<uid>,ou=people,dc=standalone,dc=local — the real
|
||||
// identity is the uid; the DN exists only for interface compatibility with
|
||||
// callers that thread user.dn through to checkPassword / addSshKey.
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const StandaloneUser = require('./standalone_user');
|
||||
|
||||
const DN_PREFIX = 'uid=';
|
||||
const DN_SUFFIX = ',ou=people,dc=standalone,dc=local';
|
||||
|
||||
function dnFor(uid) {
|
||||
return `${DN_PREFIX}${uid}${DN_SUFFIX}`;
|
||||
}
|
||||
|
||||
function uidFromDn(dn) {
|
||||
if (!dn || typeof dn !== 'string') return null;
|
||||
const m = dn.match(/^uid=([^,]+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
async function getUser(uid) {
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user) return null;
|
||||
return {
|
||||
dn: dnFor(user.uid),
|
||||
uid: user.uid,
|
||||
sshPublicKeys: user.sshPublicKeys || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function getGroups(dn) {
|
||||
const uid = uidFromDn(dn);
|
||||
if (!uid) return [];
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user) return [];
|
||||
return user.groups || [];
|
||||
}
|
||||
|
||||
async function checkPassword(dn, pw) {
|
||||
const uid = uidFromDn(dn);
|
||||
if (!uid) return false;
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user || !user.passwordHash) return false;
|
||||
return bcrypt.compare(pw, user.passwordHash);
|
||||
}
|
||||
|
||||
async function addSshKey(dn, keyLine) {
|
||||
const uid = uidFromDn(dn);
|
||||
if (!uid) return;
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user) return;
|
||||
const keys = [...(user.sshPublicKeys || [])];
|
||||
if (keys.includes(keyLine)) return; // idempotent
|
||||
keys.push(keyLine);
|
||||
await user.update({ sshPublicKeys: keys });
|
||||
}
|
||||
|
||||
module.exports = { getUser, getGroups, checkPassword, addSshKey };
|
||||
+16
-102
@@ -1,109 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
// Thin LDAP helpers — the jump host's entire LDAP surface:
|
||||
// User authentication backend — LDAP in production, ORM-backed file store in
|
||||
// standalone mode. Both export the same interface:
|
||||
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
|
||||
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
|
||||
// checkPassword(dn, pw) -> bool (simple bind as the user)
|
||||
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
|
||||
//
|
||||
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and
|
||||
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check,
|
||||
// TypeOrValueExists treated as success on key add).
|
||||
// getGroups(dn) -> [cn, ...]
|
||||
// checkPassword(dn, pw) -> bool
|
||||
// addSshKey(dn, keyLine) -> void (idempotent)
|
||||
|
||||
const { Client, Change, Attribute } = require('ldapts');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
function ldapConf() {
|
||||
return conf.ldap || {};
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
const c = ldapConf();
|
||||
return new Client({
|
||||
url: c.url,
|
||||
tlsOptions: c.tlsOptions || { rejectUnauthorized: false },
|
||||
if (conf.standalone && conf.standalone.enabled) {
|
||||
// Standalone mode: use the ORM-backed user store.
|
||||
module.exports = require('./user_file');
|
||||
} else {
|
||||
// Production mode: use the LDAP directory.
|
||||
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||
const ldapConf = conf.ldap || {};
|
||||
module.exports = createLdapClient({
|
||||
...ldapConf,
|
||||
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
// Escape a value being interpolated into an LDAP filter (RFC 4515).
|
||||
function escapeFilter(value) {
|
||||
return String(value).replace(/[\\*()\0]/g, (ch) => ({
|
||||
'\\': '\\5c', '*': '\\2a', '(': '\\28', ')': '\\29', '\0': '\\00',
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
async function withClient(fn) {
|
||||
const c = ldapConf();
|
||||
const client = makeClient();
|
||||
try {
|
||||
await client.bind(c.bindDN, c.bindPassword);
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser(uid) {
|
||||
const c = ldapConf();
|
||||
const attr = c.userNameAttribute || 'uid';
|
||||
return withClient(async (client) => {
|
||||
const { searchEntries } = await client.search(c.userBase, {
|
||||
scope: 'sub',
|
||||
filter: `(&(objectClass=posixAccount)(${attr}=${escapeFilter(uid)}))`,
|
||||
attributes: ['dn', attr, 'cn', 'sshPublicKey'],
|
||||
});
|
||||
if (!searchEntries.length) return null;
|
||||
const e = searchEntries[0];
|
||||
let keys = e.sshPublicKey || [];
|
||||
if (!Array.isArray(keys)) keys = [keys];
|
||||
return {
|
||||
dn: e.dn,
|
||||
uid: String(e[attr]),
|
||||
sshPublicKeys: keys.map(String),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getGroups(dn) {
|
||||
const c = ldapConf();
|
||||
return withClient(async (client) => {
|
||||
const { searchEntries } = await client.search(c.groupBase, {
|
||||
scope: 'sub',
|
||||
filter: `(&(objectClass=groupOfNames)(member=${escapeFilter(dn)}))`,
|
||||
attributes: ['cn'],
|
||||
});
|
||||
return searchEntries.map((e) => String(e.cn));
|
||||
});
|
||||
}
|
||||
|
||||
async function checkPassword(dn, password) {
|
||||
if (!password) return false;
|
||||
const client = makeClient();
|
||||
try {
|
||||
await client.bind(dn, password);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
await client.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function addSshKey(dn, keyLine) {
|
||||
return withClient(async (client) => {
|
||||
try {
|
||||
await client.modify(dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({ type: 'sshPublicKey', values: [keyLine] }),
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
// Same de-dup semantics as the SSO's User.addSSHkey.
|
||||
if (error.name === 'TypeOrValueExistsError') return;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { getUser, getGroups, checkPassword, addSshKey, escapeFilter, makeClient };
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
const Table = require('.');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const saltRounds = 10;
|
||||
|
||||
class User extends Table{
|
||||
@@ -86,38 +85,6 @@ class User extends Table{
|
||||
|
||||
User.register();
|
||||
|
||||
(async function(){
|
||||
// The anti-lockout local admin: the first entry in conf.auth.adminUsers
|
||||
// (default 'jumpadmin'). A local login that works even if the SSO/OIDC is
|
||||
// unreachable — the whole point of "OIDC + internal users".
|
||||
var defaultUser = (conf.auth && conf.auth.adminUsers && conf.auth.adminUsers[0]) || 'jumpadmin';
|
||||
// Optional: an orchestrator (e.g. theta-env's setup.sh) can set
|
||||
// auth.localAdminPass in jump-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)
|
||||
}
|
||||
}
|
||||
})();
|
||||
// Anti-lockout local-admin bootstrap moved to @simpleworkjs/oidc-client
|
||||
// (bootstrapLocalAdmin); invoked once from models/index.js after User is
|
||||
// registered. See the package lib/bootstrap.js for the original logic.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
|
||||
// WireGuard peer model — raw Redis (same pattern as audit_event.js).
|
||||
//
|
||||
// Each peer is a client device (phone, laptop, etc.) with a unique keypair and
|
||||
// an assigned IP on the gateway's wg0 interface.
|
||||
//
|
||||
// Redis keys:
|
||||
// wg_peer:<id> — hash of peer fields
|
||||
// wg_peer_index — sorted set (score = createdAt, value = id)
|
||||
// wg_peer_ip_seq — integer counter for next assignable IP
|
||||
//
|
||||
// Assigned IPs are allocated from the gateway's wg pool (default 10.0.0.0/8
|
||||
// range starting at .2 — .1 is the gateway itself). Override via conf.wireguard.
|
||||
//
|
||||
// Fields:
|
||||
// id - 16-char hex
|
||||
// name - human label, e.g. "william-phone"
|
||||
// publicKey - WireGuard public key (X25519 base64)
|
||||
// privateKey - WireGuard private key — PRIVATE, not returned by toPublic()
|
||||
// assignedIP - e.g. "10.0.0.2"
|
||||
// exitSiteId - ID of the wg_site to route through ('' = full tunnel via gw)
|
||||
// createdBy - uid of admin/user who created it
|
||||
// createdAt - unix ms
|
||||
// note - free-text
|
||||
|
||||
const crypto = require('crypto');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { getRedis } = require('./index');
|
||||
const { generateKeypair } = require('../utils/wg_keys');
|
||||
|
||||
const P = () => conf.redis.prefix;
|
||||
const idxKey = () => `${P()}wg_peer_index`;
|
||||
const peerKey = (id) => `${P()}wg_peer:${id}`;
|
||||
const ipSeqKey = () => `${P()}wg_peer_ip_seq`;
|
||||
|
||||
// Start IP allocation at x.x.x.2 (x.x.x.1 is the gateway interface).
|
||||
const WG_POOL_BASE = (conf.wireguard && conf.wireguard.poolBase) || '10.100.0';
|
||||
|
||||
function seqToIP(seq) {
|
||||
// Allocate within /16 pool: 10.100.0.2 – 10.100.255.254
|
||||
const octet3 = Math.floor((seq - 2) / 254);
|
||||
const octet4 = ((seq - 2) % 254) + 1;
|
||||
return `${WG_POOL_BASE.split('.').slice(0, 2).join('.')}.${octet3}.${octet4 + 1}`;
|
||||
}
|
||||
|
||||
function serialize(obj) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v == null ? '' : v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deserialize(h) {
|
||||
if (!h || !h.id) return null;
|
||||
return { ...h, createdAt: Number(h.createdAt || 0) };
|
||||
}
|
||||
|
||||
/** Strip the private key before sending to the client. */
|
||||
function toPublic(peer) {
|
||||
if (!peer) return null;
|
||||
const { privateKey: _priv, ...pub } = peer; // eslint-disable-line no-unused-vars
|
||||
return pub;
|
||||
}
|
||||
|
||||
async function create(data, createdBy) {
|
||||
const redis = await getRedis();
|
||||
const id = crypto.randomBytes(8).toString('hex');
|
||||
const seq = await redis.incr(ipSeqKey());
|
||||
const { privateKey, publicKey } = generateKeypair();
|
||||
const peer = {
|
||||
id,
|
||||
name: data.name || 'unnamed',
|
||||
publicKey,
|
||||
privateKey, // stored server-side; sent once on create / conf download
|
||||
assignedIP: seqToIP(seq),
|
||||
exitSiteId: data.exitSiteId || '',
|
||||
createdBy: createdBy || '',
|
||||
createdAt: Date.now(),
|
||||
note: data.note || '',
|
||||
};
|
||||
await redis.hSet(peerKey(id), serialize(peer));
|
||||
await redis.zAdd(idxKey(), { score: peer.createdAt, value: id });
|
||||
return peer; // includes privateKey — caller decides what to expose
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
const redis = await getRedis();
|
||||
return deserialize(await redis.hGetAll(peerKey(id)));
|
||||
}
|
||||
|
||||
async function update(id, patch) {
|
||||
const redis = await getRedis();
|
||||
const existing = await get(id);
|
||||
if (!existing) throw Object.assign(new Error('Peer not found'), { status: 404 });
|
||||
// Only allow mutable fields to be patched
|
||||
const allowed = ['name', 'exitSiteId', 'note'];
|
||||
const safe = {};
|
||||
for (const k of allowed) if (k in patch) safe[k] = patch[k];
|
||||
const merged = { ...existing, ...safe };
|
||||
await redis.hSet(peerKey(id), serialize(merged));
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const redis = await getRedis();
|
||||
await redis.del(peerKey(id));
|
||||
await redis.zRem(idxKey(), id);
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const redis = await getRedis();
|
||||
const ids = await redis.zRange(idxKey(), 0, -1, { REV: true });
|
||||
const peers = await Promise.all(ids.map(get));
|
||||
return peers.filter(Boolean).map(toPublic);
|
||||
}
|
||||
|
||||
module.exports = { create, get, update, remove, list, toPublic };
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
// WireGuard exit-node / site model — raw Redis (same pattern as audit_event.js).
|
||||
//
|
||||
// Each "site" is a location clients can route through. Admins add/remove sites
|
||||
// dynamically via the Theta Gateway UI.
|
||||
//
|
||||
// Redis keys:
|
||||
// wg_site:<id> — hash of site fields
|
||||
// wg_site_index — sorted set (score = createdAt, value = id)
|
||||
|
||||
const crypto = require('crypto');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { getRedis } = require('./index');
|
||||
|
||||
const P = () => conf.redis.prefix;
|
||||
const idxKey = () => `${P()}wg_site_index`;
|
||||
const siteKey = (id) => `${P()}wg_site:${id}`;
|
||||
|
||||
function serialize(obj) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v == null ? '' : v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deserialize(h) {
|
||||
if (!h || !h.id) return null;
|
||||
return {
|
||||
...h,
|
||||
createdAt: Number(h.createdAt || 0),
|
||||
exitAll: h.exitAll === '1',
|
||||
};
|
||||
}
|
||||
|
||||
async function create(data, createdBy) {
|
||||
const id = crypto.randomBytes(8).toString('hex');
|
||||
const site = {
|
||||
id,
|
||||
name: data.name || 'Unnamed Site',
|
||||
endpoint: data.endpoint || '',
|
||||
publicKey: data.publicKey || '',
|
||||
subnet: data.subnet || '0.0.0.0/0',
|
||||
exitAll: !!data.exitAll,
|
||||
siteId: data.siteId || '',
|
||||
note: data.note || '',
|
||||
createdBy: createdBy || '',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const redis = await getRedis();
|
||||
await redis.hSet(siteKey(id), serialize(site));
|
||||
await redis.zAdd(idxKey(), { score: site.createdAt, value: id });
|
||||
return site;
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
const redis = await getRedis();
|
||||
return deserialize(await redis.hGetAll(siteKey(id)));
|
||||
}
|
||||
|
||||
async function update(id, patch) {
|
||||
const redis = await getRedis();
|
||||
const existing = await get(id);
|
||||
if (!existing) throw Object.assign(new Error('Site not found'), { status: 404 });
|
||||
const merged = { ...existing, ...patch, id }; // id is immutable
|
||||
await redis.hSet(siteKey(id), serialize(merged));
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const redis = await getRedis();
|
||||
await redis.del(siteKey(id));
|
||||
await redis.zRem(idxKey(), id);
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const redis = await getRedis();
|
||||
const ids = await redis.zRange(idxKey(), 0, -1);
|
||||
const sites = await Promise.all(ids.map(get));
|
||||
return sites.filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = { create, get, update, remove, list };
|
||||
Generated
+1344
-152
File diff suppressed because it is too large
Load Diff
+15
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.1.0",
|
||||
"name": "theta-gateway",
|
||||
"version": "2.1.1",
|
||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||
"author": [
|
||||
{
|
||||
@@ -14,26 +14,35 @@
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||
"test": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js test/integration/*.test.js",
|
||||
"test": "NODE_ENV=test node --test --test-force-exit test/unit/access.test.js test/unit/host_keys.test.js test/unit/no_native_dialogs.test.js test/unit/target_match.test.js test/unit/username_grammar.test.js test/unit/wireguard.test.js test/unit/mesh_addressing.test.js",
|
||||
"test:unit": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js",
|
||||
"test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/frontend": "^0.2.6",
|
||||
"@simpleworkjs/ldap": "^1.0.1",
|
||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bonjour-service": "^1.4.4",
|
||||
"bootstrap": "^5.3.8",
|
||||
"compression": "^1.8.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^3.7.1",
|
||||
"ldapts": "^8.1.2",
|
||||
"jquery": "^4.0.0",
|
||||
"ldapts": "^8.1.8",
|
||||
"model-redis": "^1.6.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.2.0",
|
||||
"redis": "^4.7.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^6.1.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"ssh2": "^1.16.0"
|
||||
},
|
||||
|
||||
@@ -3,10 +3,22 @@ nav.navbar{
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
/* Only the active top-nav link is bold + underlined; the username is plain. */
|
||||
.top-nav a.active{
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||
sticky element offset itself below both fixed elements via
|
||||
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||
viewport's true top:0. */
|
||||
--sw-content-offset: 4.5rem;
|
||||
}
|
||||
|
||||
#spa-shell {
|
||||
@@ -18,3 +30,7 @@ body {
|
||||
.card-title{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.group-required{
|
||||
display: none;
|
||||
}
|
||||
|
||||
+15
-3
@@ -11,11 +11,23 @@ app.jump = (function(app){
|
||||
var qs = $.param(query || {});
|
||||
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
|
||||
}
|
||||
return {metrics: metrics, sessions: sessions, audit: audit};
|
||||
function hosts(cb){ app.api.get('user/hosts', cb); }
|
||||
return {metrics: metrics, sessions: sessions, audit: audit, hosts: hosts};
|
||||
})(app);
|
||||
|
||||
// Self-service API token (PAT) management.
|
||||
app.apiToken = (function(app){
|
||||
function list(cb){ app.api.get('api-token/', cb); }
|
||||
function add(args, cb){ app.api.post('api-token/', args, cb); }
|
||||
function update(args, cb){ app.api.put('api-token/' + args.id, args, cb); }
|
||||
function remove(id, cb){ app.api.delete('api-token/' + id, cb); }
|
||||
function rotate(id, cb){ app.api.post('api-token/' + id + '/rotate', {}, cb); }
|
||||
return {list: list, add: add, update: update, remove: remove, rotate: rotate};
|
||||
})(app);
|
||||
|
||||
// Shared render helpers.
|
||||
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
|
||||
app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); };
|
||||
app.jump.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>'
|
||||
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
|
||||
app.jump.result = function(e){ if (e.success) return '<span class="badge bg-success">ok</span>';
|
||||
var title = e.failDetail ? ' title="' + app.jump.esc(e.failDetail) + '"' : '';
|
||||
return '<span class="badge bg-danger"' + title + '>' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
|
||||
|
||||
+282
-107
@@ -1,3 +1,12 @@
|
||||
// Shared client framework for the theta42 apps.
|
||||
//
|
||||
// This file is byte-identical across sso-manager-node, proxy and jump-host —
|
||||
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
|
||||
// the /api/user/me response), never from edits to this file. Edit all three
|
||||
// copies together.
|
||||
//
|
||||
// jQuery 4 safe: no $.isFunction, no $.holdReady.
|
||||
|
||||
var app = {};
|
||||
|
||||
app.pubsub = (function(){
|
||||
@@ -45,7 +54,7 @@ app.pubsub = (function(){
|
||||
app.socket = (function(app){
|
||||
// $.getScript('/socket.io/socket.io.js')
|
||||
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||
|
||||
|
||||
var socket;
|
||||
$(document).ready(function(){
|
||||
socket = io({
|
||||
@@ -75,10 +84,26 @@ app.socket = (function(app){
|
||||
app.api = (function(app){
|
||||
var baseURL = '/api/'
|
||||
|
||||
function post(url, data, callback){
|
||||
if(typeof callback !== 'function') callback = callback2;
|
||||
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||
// (error, data, status) form, or omit it to get a Promise that resolves
|
||||
// with the parsed body and rejects with the error body. get/options return
|
||||
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
|
||||
|
||||
function body(method, url, data, callback){
|
||||
if(typeof callback !== 'function'){
|
||||
return new Promise(function(resolve, reject){
|
||||
$.ajax({
|
||||
type: method,
|
||||
url: baseURL+url,
|
||||
headers: { 'auth-token': app.auth.getToken() },
|
||||
data: JSON.stringify(data),
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
dataType: 'json',
|
||||
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
||||
});
|
||||
}
|
||||
return $.ajax({
|
||||
type: 'POST',
|
||||
type: method,
|
||||
url: baseURL+url,
|
||||
headers:{
|
||||
'auth-token': app.auth.getToken()
|
||||
@@ -87,40 +112,44 @@ app.api = (function(app){
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
complete: function(res, text){
|
||||
callback ? callback(
|
||||
callback(
|
||||
text !== 'success' ? res.statusText : null,
|
||||
JSON.parse(res.responseText),
|
||||
res.status
|
||||
) : function(){}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function post(url, data, callback){
|
||||
return body('POST', url, data, callback);
|
||||
}
|
||||
|
||||
function put(url, data, callback){
|
||||
if(typeof callback !== 'function') callback = callback2;
|
||||
return $.ajax({
|
||||
type: 'PUT',
|
||||
url: baseURL+url,
|
||||
headers:{
|
||||
'auth-token': app.auth.getToken()
|
||||
},
|
||||
data: JSON.stringify(data),
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
complete: function(res, text){
|
||||
callback ? callback(
|
||||
text !== 'success' ? res.statusText : null,
|
||||
JSON.parse(res.responseText),
|
||||
res.status
|
||||
) : function(){}
|
||||
}
|
||||
});
|
||||
return body('PUT', url, data, callback);
|
||||
}
|
||||
|
||||
function remove(url, callback, callback2){
|
||||
if(typeof callback !== 'function') callback = callback2;
|
||||
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||
// the serialized form as the second argument — as (url, data, callback).
|
||||
// No request body is sent either way.
|
||||
function remove(url, data, callback){
|
||||
if(typeof data === 'function'){
|
||||
callback = data;
|
||||
data = undefined;
|
||||
}
|
||||
if(typeof callback !== 'function'){
|
||||
return new Promise(function(resolve, reject){
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: baseURL+url,
|
||||
headers: { 'auth-token': app.auth.getToken() },
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
dataType: 'json',
|
||||
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
||||
});
|
||||
}
|
||||
return $.ajax({
|
||||
type: 'delete',
|
||||
type: 'DELETE',
|
||||
url: baseURL+url,
|
||||
headers:{
|
||||
'auth-token': app.auth.getToken()
|
||||
@@ -128,11 +157,11 @@ app.api = (function(app){
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
complete: function(res, text){
|
||||
callback ? callback(
|
||||
callback(
|
||||
text !== 'success' ? res.statusText : null,
|
||||
JSON.parse(res.responseText),
|
||||
res.status
|
||||
) : function(){}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -179,7 +208,11 @@ app.api = (function(app){
|
||||
})(app)
|
||||
|
||||
app.auth = (function(app){
|
||||
var user = {}
|
||||
// One in-flight/cached GET /api/user/me per page load. Every gating
|
||||
// decision (nav items, per-view forceLogin, group-required elements) reads
|
||||
// this same promise instead of re-fetching.
|
||||
var userPromise = null;
|
||||
|
||||
function setToken(token){
|
||||
localStorage.setItem('APIToken', token);
|
||||
}
|
||||
@@ -188,18 +221,95 @@ app.auth = (function(app){
|
||||
return localStorage.getItem('APIToken');
|
||||
}
|
||||
|
||||
function isLoggedIn(callback){
|
||||
if(getToken()){
|
||||
return app.api.get('user/me', function(error, data){
|
||||
// data now carries effective rights (isAdmin, global, domains).
|
||||
if(!error) app.auth.user = app.auth.perms = data;
|
||||
return callback(error, data);
|
||||
});
|
||||
}else{
|
||||
callback(null, false);
|
||||
async function getUser(){
|
||||
try{
|
||||
return await app.api.get('user/me');
|
||||
}catch(error){
|
||||
if(error && error.status === 401) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Cached current user, or false when there's no token at all. Callers that
|
||||
// need a fresh copy (after a login or a profile change) pass force.
|
||||
function loadUser(force){
|
||||
if(force || !userPromise){
|
||||
userPromise = getToken() ? getUser() : Promise.resolve(null);
|
||||
userPromise = userPromise.then(function(user){
|
||||
app.auth.user = app.auth.perms = user || null;
|
||||
return user;
|
||||
});
|
||||
}
|
||||
return userPromise;
|
||||
}
|
||||
|
||||
// The apps report group membership two ways: sso-manager-node returns LDAP
|
||||
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
|
||||
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
|
||||
// is exposed as a synthetic `admin` group so one gating model covers both.
|
||||
function groupCNs(user){
|
||||
var raw = (user && (user.memberOf || user.groups)) || [];
|
||||
if(!Array.isArray(raw)) raw = [raw];
|
||||
var names = raw.map(function(group){
|
||||
return String(group).split(',')[0].replace(/^cn=/i, '');
|
||||
});
|
||||
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
|
||||
return names;
|
||||
}
|
||||
|
||||
async function memberOf(groupNameToFind, user){
|
||||
user = user || await loadUser();
|
||||
if(!user) return false;
|
||||
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
||||
|
||||
return groupCNs(user).some(function(group){
|
||||
return groupNameToFind.includes(group);
|
||||
});
|
||||
}
|
||||
|
||||
// True when the logged-in user is a global admin (per user/me). Sync — only
|
||||
// meaningful once isLoggedIn/forceLogin has resolved.
|
||||
function isAdmin(){
|
||||
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||
}
|
||||
|
||||
// Dual-mode: returns a Promise resolving to the user (or false), and calls
|
||||
// an optional node-style callback with the same result.
|
||||
function isLoggedIn(callback){
|
||||
var promise = loadUser().then(function(user){
|
||||
return user || false;
|
||||
});
|
||||
|
||||
if(typeof callback === 'function'){
|
||||
promise.then(function(user){
|
||||
callback(null, user);
|
||||
}, function(error){
|
||||
callback(error, false);
|
||||
});
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
function logIn(args, callback){
|
||||
app.api.post('auth/login', args, function(error, data){
|
||||
if(data.login){
|
||||
setToken(data.token);
|
||||
}
|
||||
loadUser(true);
|
||||
callback(error, !!data.token);
|
||||
});
|
||||
}
|
||||
|
||||
// Clears the session only — the caller decides where to go next (the nav's
|
||||
// Log Out button uses ui.logoutRedirect).
|
||||
function logOut(callback){
|
||||
localStorage.removeItem('APIToken');
|
||||
userPromise = null;
|
||||
app.auth.user = app.auth.perms = null;
|
||||
if(typeof callback === 'function') callback();
|
||||
}
|
||||
|
||||
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||
@@ -230,46 +340,68 @@ app.auth = (function(app){
|
||||
return true;
|
||||
}
|
||||
|
||||
// True when the logged-in user is a global admin (per user/me).
|
||||
function isAdmin(){
|
||||
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||
}
|
||||
|
||||
function logIn(args, callback){
|
||||
app.api.post('auth/login', args, function(error, data){
|
||||
if(data.login){
|
||||
setToken(data.token);
|
||||
}
|
||||
callback(error, !!data.token);
|
||||
});
|
||||
}
|
||||
|
||||
function logOut(callback){
|
||||
localStorage.removeItem('APIToken');
|
||||
callback();
|
||||
}
|
||||
|
||||
function forceLogin(){
|
||||
// jQuery 4 removed $.holdReady; rely on the redirect below to keep an
|
||||
// unauthenticated user off the page instead of pausing document ready.
|
||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||
if(error || !isLoggedIn){
|
||||
app.auth.logOut(function(){})
|
||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
||||
}
|
||||
});
|
||||
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
||||
// unauthorised user is kept off the page by a redirect / an error panel
|
||||
// rather than by pausing document ready.
|
||||
//
|
||||
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
||||
// `admin` group covers the OIDC clients' isAdmin flag.
|
||||
async function forceLogin(requiredGroups){
|
||||
var user = await loadUser();
|
||||
|
||||
if(!user){
|
||||
logOut(function(){});
|
||||
location.replace('/login?redirect=' + encodeURIComponent(
|
||||
location.pathname + location.search
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||
location.replace('/onboarding');
|
||||
return false;
|
||||
}
|
||||
|
||||
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||
app.messages.action(
|
||||
`<h1>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
<b>You do not have permission to be here.</b>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
</h1>`,
|
||||
$('#spa-shell'),
|
||||
'danger',
|
||||
);
|
||||
throw new Error("User does not have permission");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// Where to go after a successful login: the ?redirect= query param, or the
|
||||
// legacy /login/<path> suffix form, constrained to a same-origin path. The
|
||||
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
|
||||
// is how the OIDC provider sends an unauthenticated user through login.
|
||||
function logInRedirect(){
|
||||
window.location.href = safeInternalPath(location.href.replace(location.origin+'/login', '') || '/')
|
||||
var params = new URLSearchParams(location.search);
|
||||
var target = params.get('redirect')
|
||||
|| location.href.replace(location.origin + '/login', '')
|
||||
|| '/';
|
||||
window.location.href = safeInternalPath(target);
|
||||
}
|
||||
|
||||
return {
|
||||
getToken: getToken,
|
||||
setToken: setToken,
|
||||
isLoggedIn: isLoggedIn,
|
||||
consumeTokenFragment: consumeTokenFragment,
|
||||
getUser: getUser,
|
||||
loadUser: loadUser,
|
||||
groupCNs: groupCNs,
|
||||
memberOf: memberOf,
|
||||
isAdmin: isAdmin,
|
||||
isLoggedIn: isLoggedIn,
|
||||
safeInternalPath: safeInternalPath,
|
||||
consumeTokenFragment: consumeTokenFragment,
|
||||
user: null,
|
||||
perms: null,
|
||||
logIn: logIn,
|
||||
logOut: logOut,
|
||||
@@ -279,6 +411,11 @@ app.auth = (function(app){
|
||||
|
||||
})(app);
|
||||
|
||||
// Back-compat alias for views that awaited the cached user directly.
|
||||
Object.defineProperty(app.auth, 'asyncUser', {
|
||||
get: function(){ return app.auth.loadUser(); },
|
||||
});
|
||||
|
||||
app.user = (function(app){
|
||||
function list(callback){
|
||||
app.api.get('user/?detail=true', function(error, data){
|
||||
@@ -308,6 +445,8 @@ app.user = (function(app){
|
||||
|
||||
})(app);
|
||||
|
||||
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
|
||||
// these endpoints; the calls are inert elsewhere.
|
||||
app.permission = (function(app){
|
||||
function list(callback){
|
||||
app.api.get('permission/', function(error, data){
|
||||
@@ -381,29 +520,15 @@ app.util = (function(app){
|
||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||
};
|
||||
|
||||
function actionMessage(message, $target, type, callback){
|
||||
message = message || '';
|
||||
$target = $target.closest('div.card').find('.actionMessage');
|
||||
type = type || 'info';
|
||||
callback = callback || function(){};
|
||||
|
||||
if($target.html() === message) return;
|
||||
|
||||
if($target.html()){
|
||||
$target.slideUp('fast', function(){
|
||||
$target.html('')
|
||||
$target.removeClass (function(index, className){
|
||||
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
|
||||
});
|
||||
if(message) return actionMessage(message, $target, type, callback);
|
||||
$target.hide()
|
||||
})
|
||||
}else{
|
||||
if(type) $target.addClass('bg-' + type);
|
||||
message = '<span class="align-middle">' + message + '</span><button class="action-close btn btn-sm btn-outline-dark float-end"><i class="fa-solid fa-xmark"></i></button>'
|
||||
$target.html(message).slideDown('fast');
|
||||
}
|
||||
setTimeout(callback,10)
|
||||
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||
// app.util.escapeHtml and app.messages.action/confirm.
|
||||
function escapeHtml(s){
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
$.fn.serializeObject = function() {
|
||||
@@ -459,14 +584,65 @@ app.util = (function(app){
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
// Scroll a just-added/-edited element into view and flash its
|
||||
// background, so the user's eye lands on the row that changed instead of
|
||||
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||
function revealItem(el){
|
||||
var node = el && el.jquery ? el[0] : el;
|
||||
if (!node) return;
|
||||
if (typeof node.scrollIntoView === 'function') {
|
||||
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
}
|
||||
var prevTransition = node.style.transition;
|
||||
var prevBg = node.style.backgroundColor;
|
||||
node.style.transition = 'background-color 1.5s ease';
|
||||
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||
setTimeout(function(){
|
||||
node.style.backgroundColor = prevBg;
|
||||
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return {
|
||||
downloadFile: downloadFile,
|
||||
getUrlParameter: getUrlParameter,
|
||||
actionMessage: actionMessage
|
||||
escapeHtml: escapeHtml,
|
||||
revealItem: revealItem,
|
||||
}
|
||||
})(app);
|
||||
|
||||
$( document ).ready(function(){
|
||||
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
||||
// user who is in no groups — or who isn't logged in — simply never sees them.
|
||||
app.auth.applyGroupVisibility = function(user){
|
||||
var groups = app.auth.groupCNs(user);
|
||||
if(!groups.length) return;
|
||||
|
||||
var style = document.getElementById('group-required-rules');
|
||||
if(!style){
|
||||
style = document.createElement('style');
|
||||
style.id = 'group-required-rules';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
for(var group of groups){
|
||||
try{
|
||||
style.sheet.insertRule(
|
||||
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||
style.sheet.cssRules.length
|
||||
);
|
||||
}catch(error){
|
||||
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$( document ).ready(async function(){
|
||||
|
||||
// Show content the user's groups entitle them to.
|
||||
app.auth.applyGroupVisibility(await app.auth.loadUser());
|
||||
|
||||
$('div.row').fadeIn('slow'); //show the page
|
||||
|
||||
//panel button's
|
||||
@@ -487,9 +663,9 @@ $( document ).ready(function(){
|
||||
$(this).closest('.card').slideUp('fast');
|
||||
});
|
||||
|
||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
||||
app.util.actionMessage(null, $(this));
|
||||
});
|
||||
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||
// app.messages.js (delegated on document, so it also covers messages
|
||||
// rendered after this ready handler runs).
|
||||
|
||||
setInterval(()=>{
|
||||
$('.momentFromNow').each((idx, el)=>{
|
||||
@@ -520,18 +696,17 @@ function formAJAX(btn){
|
||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||
|
||||
if($form.validate && !$form.validate()){
|
||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger');
|
||||
app.messages.action('Please fix the form errors.', $form, 'danger')
|
||||
return false;
|
||||
}
|
||||
|
||||
app.util.actionMessage(
|
||||
'<div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div>',
|
||||
$form,
|
||||
'info'
|
||||
);
|
||||
|
||||
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||
// render literally instead of as an element.
|
||||
app.messages.action('Saving…', $form, 'info');
|
||||
|
||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||
$form.validateClear();
|
||||
if(!error){
|
||||
$form.trigger("reset");
|
||||
@@ -539,7 +714,7 @@ function formAJAX(btn){
|
||||
}else{
|
||||
console.log('formAJAX res error', error, data)
|
||||
if(data && data.name === 'ObjectValidateError'){
|
||||
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||
}
|
||||
if(data && data.keys){
|
||||
console.log('form key errors', data.keys)
|
||||
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
+21
-3
@@ -4,12 +4,30 @@ const router = require('express').Router();
|
||||
const middleware = require('../middleware/auth');
|
||||
|
||||
// Authentication (local login + OIDC handshake). Unauthenticated by design.
|
||||
router.use('/auth', require('./auth'));
|
||||
router.use('/auth', require('../models').authRouter);
|
||||
|
||||
// Who am I — needs a valid session but no admin gate (drives the login state).
|
||||
router.use('/user', middleware.auth, require('./user'));
|
||||
|
||||
// Jump-host data — admin only (audit log, active sessions, metrics).
|
||||
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
||||
// Self-service API token (PAT) management — any authenticated user, no
|
||||
// admin gate (see routes/api_token.js for why a token can't reach admin routes).
|
||||
router.use('/api-token', middleware.auth, require('./api_token'));
|
||||
|
||||
// WireGuard peer + site management — admin only.
|
||||
router.use('/wireguard', middleware.auth, middleware.requireJumpAdmin, require('./wireguard'));
|
||||
|
||||
// Gateway-to-gateway mesh — mixed auth (register/register-* are called by a
|
||||
// remote gateway with a bearer join token, not an admin session; join-tokens
|
||||
// mint + join are admin-gated). See routes/mesh.js for the per-route gates.
|
||||
// MUST be registered before the '/' mount below: '/' matches every /api/*
|
||||
// path (it's the catch-all for routes/jump.js), so registering it first
|
||||
// would shadow every /api/mesh/* route with admin-session auth before
|
||||
// routes/mesh.js's own per-route gates ever ran -- confirmed live, this
|
||||
// silently 401'd /register's bearer-join-token callers with a
|
||||
// checkApiToken/LoginFailed error instead of ever reaching mesh.js.
|
||||
router.use('/mesh', require('./mesh'));
|
||||
|
||||
// Jump-host data — jump admin only (audit log, active sessions, metrics).
|
||||
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
|
||||
// Self-service API token (PAT) management. Every endpoint is owner-scoped: a
|
||||
// user only sees / mutates tokens where created_by === req.user.username.
|
||||
// Mirrors proxy's routes/api_token.js. Mounted under middleware.auth only
|
||||
// (no requireAdmin) — any authenticated user may mint one, but the token
|
||||
// itself carries no group claims (see models/api_token.js), so it can only
|
||||
// reach non-admin routes (e.g. GET /api/user/hosts), never the admin-gated
|
||||
// ones under routes/jump.js.
|
||||
|
||||
const router = require('express').Router();
|
||||
const {ApiToken} = require('../models');
|
||||
|
||||
function forbidden(){
|
||||
let error = new Error('Forbidden');
|
||||
error.name = 'Forbidden';
|
||||
error.message = 'You do not own this API token.';
|
||||
error.status = 403;
|
||||
return error;
|
||||
}
|
||||
|
||||
// Resolve a token the caller owns. Missing or not-yours both raise 403 (no
|
||||
// existence leak; ids are unguessable random hex anyway).
|
||||
async function getOwned(req, id){
|
||||
let token;
|
||||
try{
|
||||
token = await ApiToken.get(id);
|
||||
}catch(e){
|
||||
throw forbidden();
|
||||
}
|
||||
if(!token || token.created_by !== req.user.username) throw forbidden();
|
||||
return token;
|
||||
}
|
||||
|
||||
router.get('/', async function(req, res, next){
|
||||
try{
|
||||
return res.json({results: await ApiToken.listDetail({created_by: req.user.username})});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async function(req, res, next){
|
||||
try{
|
||||
const days = req.body.expires_in_days !== '' && req.body.expires_in_days !== undefined
|
||||
? Number(req.body.expires_in_days) : 0;
|
||||
|
||||
const token = await ApiToken.add({
|
||||
name: req.body.name,
|
||||
description: req.body.description || '',
|
||||
created_by: req.user.username,
|
||||
expires_at: days > 0 ? (new Date).getTime() + days * 86400000 : 0,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
results: token,
|
||||
token: token._raw_token,
|
||||
message: `API token '${token.name}' created. Save it now — it will not be shown again.`,
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async function(req, res, next){
|
||||
try{
|
||||
return res.json({results: await getOwned(req, req.params.id)});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', async function(req, res, next){
|
||||
try{
|
||||
const token = await getOwned(req, req.params.id);
|
||||
|
||||
const update = {};
|
||||
for(const k of ['name', 'description']){
|
||||
if(req.body[k] !== undefined) update[k] = req.body[k];
|
||||
}
|
||||
if(req.body.expires_in_days !== undefined && req.body.expires_in_days !== ''){
|
||||
const days = Number(req.body.expires_in_days);
|
||||
update.expires_at = days > 0 ? (new Date).getTime() + days * 86400000 : 0;
|
||||
}else if(req.body.expires_at !== undefined){
|
||||
update.expires_at = Number(req.body.expires_at) || 0;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
results: await token.update(update),
|
||||
message: `API token '${token.name}' updated.`,
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async function(req, res, next){
|
||||
try{
|
||||
const token = await getOwned(req, req.params.id);
|
||||
await token.remove();
|
||||
return res.json({id: req.params.id, message: `API token '${token.name}' revoked.`});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/rotate', async function(req, res, next){
|
||||
try{
|
||||
const token = await getOwned(req, req.params.id);
|
||||
const raw = await token.rotate();
|
||||
return res.json({
|
||||
token: raw,
|
||||
message: `API token '${token.name}' rotated. Save it — it will not be shown again.`,
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,212 @@
|
||||
'use strict';
|
||||
|
||||
// Gateway-to-gateway WireGuard mesh — real site-to-site tunnels, not the
|
||||
// roaming-client/exit-node feature in routes/wireguard.js. Two gateways mesh
|
||||
// by one calling the other's POST /api/mesh/register with a join token; both
|
||||
// sides end up with a live wg0 peer entry for the other, addressed per
|
||||
// MULTI_SITE_SPEC.md's one-octet mesh index (172.24.<idx>.0/16,
|
||||
// 10.<idx>.0.0/16, idx 1-254, assigned by whichever gateway is registering
|
||||
// the caller).
|
||||
//
|
||||
// Local interface name is fixed at THETA_MESH_IFACE (default wg-mesh) —
|
||||
// deliberately separate from the roaming-client interface so the two
|
||||
// features never fight over the same wg0.
|
||||
|
||||
const express = require('express');
|
||||
const middleware = require('../middleware/auth');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const meshGateway = require('../models/mesh_gateway');
|
||||
const meshJoinToken = require('../utils/mesh_join_token');
|
||||
const wgIface = require('../utils/wg_iface');
|
||||
const wgKeys = require('../utils/wg_keys');
|
||||
const { meshCidrFor, meshAllowedIpsFor } = require('../utils/mesh_addressing');
|
||||
|
||||
const router = express.Router();
|
||||
const IFACE = process.env.THETA_MESH_IFACE || 'wg-mesh';
|
||||
const MESH_LISTEN_PORT = process.env.THETA_MESH_LISTEN_PORT || 51820;
|
||||
|
||||
async function ensureLocalIdentity() {
|
||||
if (!conf.wireguard) conf.wireguard = {};
|
||||
if (!conf.wireguard.serverPublicKey || !conf.wireguard.serverPrivateKey) {
|
||||
// wg_bootstrap.js normally does this at startup; guard here too so this
|
||||
// route works even if bootstrap hasn't run yet in a given environment.
|
||||
const kp = wgKeys.generateKeypair();
|
||||
conf.wireguard.serverPublicKey = kp.publicKey;
|
||||
conf.wireguard.serverPrivateKey = kp.privateKey;
|
||||
}
|
||||
return conf.wireguard;
|
||||
}
|
||||
|
||||
// Mint a single-use mesh join token — the credential a NEW gateway presents
|
||||
// to register into THIS gateway's mesh.
|
||||
router.post('/join-tokens', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { token, expiresInSeconds } = await meshJoinToken.mint();
|
||||
res.json({ status: 'ok', token, expiresInSeconds });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Called by a REMOTE gateway to register itself into THIS gateway's mesh.
|
||||
// Bearer mesh join token, no admin session (service-to-service, same as
|
||||
// theta-directory's POST /api/site/register-spoke pattern).
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const auth = req.headers.authorization || '';
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!(await meshJoinToken.consume(token))) {
|
||||
return res.status(401).json({ status: 'error', message: 'invalid or already-used mesh join token' });
|
||||
}
|
||||
|
||||
const { publicKey, endpoint, siteSlug } = req.body || {};
|
||||
if (!publicKey || !endpoint) {
|
||||
return res.status(400).json({ status: 'error', message: 'publicKey and endpoint are required' });
|
||||
}
|
||||
|
||||
const self = await ensureLocalIdentity();
|
||||
const peer = await meshGateway.register({ publicKey, endpoint, siteSlug });
|
||||
|
||||
await wgIface.ensureInterface(IFACE);
|
||||
wgIface.setPrivateKey(IFACE, self.serverPrivateKey, MESH_LISTEN_PORT);
|
||||
// This gateway's own mesh index -- assigned to ITSELF the first time
|
||||
// anyone registers with it, since a solo gateway has no index yet.
|
||||
const ownIndex = await ensureOwnMeshIndex();
|
||||
wgIface.setAddress(IFACE, meshCidrFor(ownIndex));
|
||||
wgIface.setPeer(IFACE, {
|
||||
publicKey: peer.publicKey,
|
||||
endpoint: peer.endpoint,
|
||||
allowedIPs: meshAllowedIpsFor(peer.meshIndex),
|
||||
keepalive: 25
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
meshIndex: peer.meshIndex,
|
||||
gateway: {
|
||||
publicKey: conf.wireguard.serverPublicKey,
|
||||
endpoint: conf.wireguard.serverEndpoint || '',
|
||||
meshIndex: ownIndex
|
||||
}
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// This gateway's own mesh index is just "the lowest free index, stable once
|
||||
// picked" -- stored as a synthetic self-entry in the same registry so it
|
||||
// survives restarts the same way peer entries do.
|
||||
async function ensureOwnMeshIndex() {
|
||||
const self = await meshGateway.findByPublicKey(conf.wireguard.serverPublicKey);
|
||||
if (self) return self.meshIndex;
|
||||
const created = await meshGateway.register({
|
||||
publicKey: conf.wireguard.serverPublicKey,
|
||||
endpoint: conf.wireguard.serverEndpoint || '',
|
||||
siteSlug: '(self)'
|
||||
});
|
||||
return created.meshIndex;
|
||||
}
|
||||
|
||||
// Admin-initiated: join THIS gateway into a remote gateway's mesh. Generates
|
||||
// (or reuses) this gateway's identity, brings up the local interface, calls
|
||||
// the remote's /register, and applies the peer it gets back -- so after this
|
||||
// call both sides have a live, working wg0 peer entry for each other.
|
||||
router.post('/join', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { remoteEndpoint, joinToken } = req.body || {};
|
||||
if (!remoteEndpoint || !joinToken) {
|
||||
return res.status(400).json({ status: 'error', message: 'remoteEndpoint and joinToken are required' });
|
||||
}
|
||||
|
||||
const self = await ensureLocalIdentity();
|
||||
await wgIface.ensureInterface(IFACE);
|
||||
wgIface.setPrivateKey(IFACE, self.serverPrivateKey, MESH_LISTEN_PORT);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15000);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(String(remoteEndpoint).replace(/\/+$/, '') + '/api/mesh/register', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + joinToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
publicKey: self.serverPublicKey,
|
||||
endpoint: self.serverEndpoint || '',
|
||||
siteSlug: process.env.SITE_SLUG || ''
|
||||
}),
|
||||
signal: controller.signal
|
||||
});
|
||||
} finally { clearTimeout(timer); }
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = (await resp.text().catch(() => '')).slice(0, 300);
|
||||
return res.status(502).json({ status: 'error', message: 'remote registration failed: HTTP ' + resp.status + ' ' + text });
|
||||
}
|
||||
const data = await resp.json();
|
||||
|
||||
wgIface.setAddress(IFACE, meshCidrFor(data.meshIndex));
|
||||
// Persist OUR OWN identity too, not just the remote peer's -- the
|
||||
// receiving side of /register does this via ensureOwnMeshIndex(), but
|
||||
// the initiating side (here) never did, so GET /api/mesh/self and the
|
||||
// mesh UI's own-entry/"(self)" handling both silently saw nothing on
|
||||
// whichever gateway called /join. register() is upsert-by-publicKey
|
||||
// and reuses an existing entry's index, so this is safe to call even
|
||||
// if a self-entry from a PRIOR /register (as the receiving side of a
|
||||
// different peer) already exists.
|
||||
await meshGateway.register({ publicKey: self.serverPublicKey, endpoint: self.serverEndpoint || '', siteSlug: '(self)', meshIndex: data.meshIndex });
|
||||
await meshGateway.register({ publicKey: data.gateway.publicKey, endpoint: data.gateway.endpoint, siteSlug: '(remote master)' });
|
||||
wgIface.setPeer(IFACE, {
|
||||
publicKey: data.gateway.publicKey,
|
||||
endpoint: data.gateway.endpoint,
|
||||
allowedIPs: meshAllowedIpsFor(data.gateway.meshIndex),
|
||||
keepalive: 25
|
||||
});
|
||||
|
||||
res.json({ status: 'ok', meshIndex: data.meshIndex, peerMeshIndex: data.gateway.meshIndex });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// This gateway's own mesh address, for a LOCAL bootstrap script to discover
|
||||
// (e.g. theta-suite's site-join, running on the same host as this gateway)
|
||||
// without needing full jump-admin session auth -- any valid jmp_ API token
|
||||
// (middleware.auth, no requireJumpAdmin) is enough, same service-to-service
|
||||
// pattern as theta-proxy's prx_ tokens for proxy_client.js. Not a peer
|
||||
// listing, so no admin-only audit/config data is exposed here.
|
||||
router.get('/self', middleware.auth, async (req, res, next) => {
|
||||
try {
|
||||
const self = conf.wireguard || {};
|
||||
let meshIp = null;
|
||||
if (self.serverPublicKey) {
|
||||
const entry = await meshGateway.findByPublicKey(self.serverPublicKey);
|
||||
if (entry) meshIp = meshCidrFor(entry.meshIndex).split('/')[0];
|
||||
}
|
||||
res.json({ status: 'ok', meshIp, joined: !!meshIp, iface: IFACE });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.get('/gateways', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const gateways = await meshGateway.list();
|
||||
res.json({ status: 'ok', gateways, iface: IFACE, kernelWireguard: wgIface.kernelWireguardAvailable() });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Remove a peer gateway: tears down its local WG peer entry + kernel routes
|
||||
// (wgIface.removePeer) and drops it from the registry. Does NOT reach out to
|
||||
// the remote gateway to remove the reciprocal peer entry there -- an admin
|
||||
// on that side needs to do the same. Refuses to remove the self-entry
|
||||
// ("(self)"), since that's this gateway's own identity, not a peer.
|
||||
router.delete('/gateways/:id', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const gateways = await meshGateway.list();
|
||||
const target = gateways.find((g) => g.id === req.params.id);
|
||||
if (!target) return res.status(404).json({ status: 'error', message: 'gateway not found' });
|
||||
if (target.siteSlug === '(self)') {
|
||||
return res.status(400).json({ status: 'error', message: 'cannot remove this gateway\'s own self-entry' });
|
||||
}
|
||||
|
||||
wgIface.removePeer(IFACE, target.publicKey);
|
||||
await meshGateway.remove(target.id);
|
||||
|
||||
res.json({ status: 'ok', removed: { id: target.id, siteSlug: target.siteSlug, meshIndex: target.meshIndex } });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+14
-7
@@ -4,28 +4,33 @@ const path = require('path');
|
||||
const express = require('express');
|
||||
const router = require('express').Router();
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('../models/build_info');
|
||||
const buildInfo = require('../utils/build_info');
|
||||
const registry = require('../services/session_registry');
|
||||
const { safeInternalPath } = require('@simpleworkjs/oidc-client');
|
||||
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||
|
||||
const values = {
|
||||
title: conf.environment !== 'production' ? 'dev' : '',
|
||||
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||
name: conf.name,
|
||||
logo: conf.logo,
|
||||
// The SSH front door's port -- the dashboard's "quick jump" copy buttons
|
||||
// need this to build a real, working `ssh ...` command (the web UI and
|
||||
// SSH front door share a hostname but not a port).
|
||||
sshPort: (conf.ssh && conf.ssh.listenPort) || 22,
|
||||
...buildInfo,
|
||||
};
|
||||
|
||||
// Serve front-end vendor libraries straight from node_modules (same convention
|
||||
// as the sibling apps), and the app's own JS/CSS/img from public/.
|
||||
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'];
|
||||
frontEndModules.forEach(dep => {
|
||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}));
|
||||
mountStaticModules(router, {
|
||||
root: path.join(__dirname, '..'),
|
||||
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat', '@simpleworkjs/frontend'],
|
||||
});
|
||||
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}));
|
||||
|
||||
// Liveness probe — no auth.
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit});
|
||||
res.json({status: 'ok', activeSessions: registry.count(), buildVersion: buildInfo.buildVersion, buildHash: buildInfo.buildHash});
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => res.redirect(302, '/dashboard'));
|
||||
@@ -36,11 +41,13 @@ router.get('/', (req, res) => res.redirect(302, '/dashboard'));
|
||||
// /login when there's no valid session.
|
||||
router.get('/login', (req, res) => res.render('login', {
|
||||
...values,
|
||||
redirect: '/',
|
||||
redirect: safeInternalPath(req.query.redirect || '/'),
|
||||
oidcEnabled: !!(conf.oidc && conf.oidc.enabled),
|
||||
}));
|
||||
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
|
||||
router.get('/sessions', (req, res) => res.render('sessions', {...values}));
|
||||
router.get('/audit', (req, res) => res.render('audit', {...values}));
|
||||
router.get('/wireguard', (req, res) => res.render('wireguard', {...values}));
|
||||
router.get('/mesh', (req, res) => res.render('mesh', {...values}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+30
-1
@@ -4,14 +4,43 @@
|
||||
// browser who it is and whether it's an admin (drives login state + nav).
|
||||
|
||||
const router = require('express').Router();
|
||||
const { isAdmin } = require('../middleware/auth');
|
||||
const { isAdmin, isJumpAdmin } = require('../middleware/auth');
|
||||
const access = require('../utils/access');
|
||||
const metrics = require('../models/metrics');
|
||||
const registry = require('../services/session_registry');
|
||||
|
||||
router.get('/me', (req, res) => {
|
||||
res.json({
|
||||
username: req.user && req.user.username,
|
||||
groups: req.groups || [],
|
||||
isAdmin: isAdmin(req),
|
||||
isJumpAdmin: isJumpAdmin(req),
|
||||
});
|
||||
});
|
||||
|
||||
// The hosts this session can SSH to — every host for an admin, otherwise the
|
||||
// same group-based resolution the SSH front door uses (accessibleHosts),
|
||||
// fed the OIDC session's already-known groups instead of an LDAP lookup.
|
||||
router.get('/hosts', async (req, res, next) => {
|
||||
try {
|
||||
const hosts = isAdmin(req)
|
||||
? await access.allHosts()
|
||||
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
|
||||
|
||||
// Enrich with connection state for the dashboard's host list: whether a
|
||||
// session is live right now (active bridges, session_registry), plus the
|
||||
// last successful/failed connection times (models/metrics).
|
||||
const connectedSlugs = new Set(registry.list().map((s) => s.slug));
|
||||
const last = await metrics.lastForHosts(hosts.map((h) => h.slug));
|
||||
const enriched = hosts.map((h) => ({
|
||||
...h,
|
||||
connected: connectedSlugs.has(h.slug),
|
||||
lastConnected: (last[h.slug] && last[h.slug].lastConnected) || null,
|
||||
lastFailed: (last[h.slug] && last[h.slug].lastFailed) || null,
|
||||
}));
|
||||
|
||||
res.json({ results: enriched });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
|
||||
// WireGuard management API — admin-gated.
|
||||
//
|
||||
// Sites (exit nodes):
|
||||
// GET /api/wireguard/sites list all exit nodes
|
||||
// POST /api/wireguard/sites create exit node
|
||||
// PATCH /api/wireguard/sites/:id update exit node
|
||||
// DELETE /api/wireguard/sites/:id remove exit node
|
||||
//
|
||||
// Peers (client devices):
|
||||
// GET /api/wireguard/peers list all peers (no private keys)
|
||||
// POST /api/wireguard/peers create peer (returns private key ONCE)
|
||||
// PATCH /api/wireguard/peers/:id update name / exit node / note
|
||||
// DELETE /api/wireguard/peers/:id remove peer
|
||||
// GET /api/wireguard/peers/:id/conf download wg0.conf (contains private key)
|
||||
// GET /api/wireguard/peers/:id/qr PNG QR code of the client conf (base64)
|
||||
|
||||
const router = require('express').Router();
|
||||
const QRCode = require('qrcode');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const wgSite = require('../models/wg_site');
|
||||
const wgPeer = require('../models/wg_peer');
|
||||
const { renderClientConf } = require('../utils/wg_conf');
|
||||
|
||||
// Gateway's own WireGuard public key + endpoint come from conf.wireguard.
|
||||
// These are set in docker-compose / theta-env and describe this gateway's
|
||||
// wg0 interface that clients point at.
|
||||
function gwConf() {
|
||||
const wg = conf.wireguard || {};
|
||||
return {
|
||||
publicKey: wg.serverPublicKey || '',
|
||||
endpoint: wg.serverEndpoint || '',
|
||||
dns: wg.dns || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Gateway Info ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/gateway-info', async (req, res) => {
|
||||
res.json(gwConf());
|
||||
});
|
||||
|
||||
// ── Sites ───────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/sites', async (req, res, next) => {
|
||||
try {
|
||||
res.json({ results: await wgSite.list() });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/sites', async (req, res, next) => {
|
||||
try {
|
||||
const { name, endpoint, publicKey, subnet, exitAll, siteId, note } = req.body;
|
||||
if (!name || !endpoint || !publicKey) {
|
||||
return res.status(400).json({ message: 'name, endpoint, and publicKey are required' });
|
||||
}
|
||||
const site = await wgSite.create(
|
||||
{ name, endpoint, publicKey, subnet, exitAll, siteId, note },
|
||||
req.user && req.user.uid
|
||||
);
|
||||
res.status(201).json(site);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.patch('/sites/:id', async (req, res, next) => {
|
||||
try {
|
||||
const site = await wgSite.update(req.params.id, req.body);
|
||||
res.json(site);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/sites/:id', async (req, res, next) => {
|
||||
try {
|
||||
await wgSite.remove(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Peers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/peers', async (req, res, next) => {
|
||||
try {
|
||||
res.json({ results: await wgPeer.list() });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/peers', async (req, res, next) => {
|
||||
try {
|
||||
const { name, exitSiteId, note } = req.body;
|
||||
if (!name) return res.status(400).json({ message: 'name is required' });
|
||||
const peer = await wgPeer.create(
|
||||
{ name, exitSiteId, note },
|
||||
req.user && req.user.uid
|
||||
);
|
||||
// Return the full peer including privateKey — shown ONCE.
|
||||
res.status(201).json(peer);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.patch('/peers/:id', async (req, res, next) => {
|
||||
try {
|
||||
const peer = await wgPeer.update(req.params.id, req.body);
|
||||
res.json(wgPeer.toPublic(peer));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/peers/:id', async (req, res, next) => {
|
||||
try {
|
||||
await wgPeer.remove(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Config / QR ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildConf(id) {
|
||||
const peer = await wgPeer.get(id); // includes privateKey
|
||||
if (!peer) throw Object.assign(new Error('Peer not found'), { status: 404 });
|
||||
const site = peer.exitSiteId ? await wgSite.get(peer.exitSiteId) : null;
|
||||
const { publicKey, endpoint, dns } = gwConf();
|
||||
return renderClientConf({ peer, site, serverPub: publicKey, serverEndpoint: endpoint, dns });
|
||||
}
|
||||
|
||||
router.get('/peers/:id/conf', async (req, res, next) => {
|
||||
try {
|
||||
const confText = await buildConf(req.params.id);
|
||||
const peer = await wgPeer.get(req.params.id);
|
||||
const filename = `${(peer.name || peer.id).replace(/[^a-z0-9_-]/gi, '_')}.conf`;
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(confText);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.get('/peers/:id/qr', async (req, res, next) => {
|
||||
try {
|
||||
const confText = await buildConf(req.params.id);
|
||||
const dataUrl = await QRCode.toDataURL(confText, {
|
||||
errorCorrectionLevel: 'M',
|
||||
width: 400,
|
||||
margin: 2,
|
||||
});
|
||||
res.json({ qr: dataUrl });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Gateway's own public key (unauthenticated — needed to display in the UI).
|
||||
router.get('/gateway-info', (req, res) => {
|
||||
res.json({ ...gwConf() });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -23,7 +23,7 @@ function counter(onBytes) {
|
||||
// Connect the upstream ssh2.Client, retrying once after a short pause if the
|
||||
// first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
|
||||
// first-time key injection).
|
||||
function connectUpstream({ host, port, username, privateKey, onHostKey, uid, justInjected }) {
|
||||
function connectUpstream({ host, port, username, privateKey, cert, onHostKey, uid, justInjected, expectedHostKeyFp }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempted = false;
|
||||
const dial = (allowRetry) => {
|
||||
@@ -42,12 +42,16 @@ function connectUpstream({ host, port, username, privateKey, onHostKey, uid, jus
|
||||
})
|
||||
.connect({
|
||||
host, port, username, privateKey,
|
||||
certificates: cert ? [cert] : undefined,
|
||||
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
|
||||
keepaliveInterval: 15000,
|
||||
hostVerifier: (key) => {
|
||||
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
|
||||
if (onHostKey) onHostKey(fp);
|
||||
return true; // v1: trust-on-use, fingerprint audited. Pinning = follow-up.
|
||||
if (expectedHostKeyFp && expectedHostKeyFp !== fp) {
|
||||
return false;
|
||||
}
|
||||
return true; // v1: trust-on-use if not pinned, fingerprint audited.
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
// mDNS local-discovery announcer (MULTI_SITE_SPEC.md Appendix B). Advertises
|
||||
// this site's local presence so an agent on the same LAN segment (with
|
||||
// prefer_local_directory enabled -- see theta-agent's local_discovery.go)
|
||||
// can skip the relay/WAN path and talk to the local instance directly.
|
||||
//
|
||||
// What gets announced is deliberately just "which public hostnames does
|
||||
// this site front, and at what local IP" -- nothing about identity or
|
||||
// trust. The listening side never weakens certificate validation based on
|
||||
// this; it only ever changes DNS resolution (see the hard rule documented
|
||||
// in theta-agent's local_discovery.go).
|
||||
|
||||
const SERVICE_TYPE = 'theta-suite'; // -> _theta-suite._tcp, matches theta-agent's mdnsServiceName
|
||||
|
||||
function announcedHosts() {
|
||||
return (process.env.THETA_LOCAL_DISCOVERY_HOSTS || '')
|
||||
.split(',')
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
let bonjourInstance = null;
|
||||
let publishedService = null;
|
||||
|
||||
async function startMdnsAnnounce() {
|
||||
const hosts = announcedHosts();
|
||||
if (hosts.length === 0) {
|
||||
console.log('[mdns-announce] THETA_LOCAL_DISCOVERY_HOSTS not set -- nothing to announce, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const { Bonjour } = require('bonjour-service');
|
||||
bonjourInstance = new Bonjour(undefined, (err) => {
|
||||
console.error('[mdns-announce] bonjour-service error:', err.message);
|
||||
});
|
||||
|
||||
publishedService = bonjourInstance.publish({
|
||||
name: `theta-suite-${process.env.SITE_SLUG || 'site'}`,
|
||||
type: SERVICE_TYPE,
|
||||
port: Number(process.env.PORT) || 80,
|
||||
txt: { hosts: hosts.join(','), site: process.env.SITE_SLUG || '' }
|
||||
});
|
||||
|
||||
console.log(`[mdns-announce] announcing on the local network: ${hosts.join(', ')}`);
|
||||
}
|
||||
|
||||
function stopMdnsAnnounce() {
|
||||
if (bonjourInstance) {
|
||||
bonjourInstance.destroy();
|
||||
bonjourInstance = null;
|
||||
publishedService = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startMdnsAnnounce, stopMdnsAnnounce };
|
||||
@@ -129,30 +129,57 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
||||
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
|
||||
|
||||
let justInjected = false;
|
||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||
catch (_) { throw fail('key-inject-failed'); }
|
||||
let cert;
|
||||
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
|
||||
|
||||
try {
|
||||
if (usePki) {
|
||||
const { getSignedCert } = require('../utils/vault_cert');
|
||||
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
|
||||
} else {
|
||||
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
|
||||
}
|
||||
} catch (err) {
|
||||
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
|
||||
throw fail(failType, err.message, host ? host.slug : undefined);
|
||||
}
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await connectUpstream({
|
||||
host: endpoint.address, port: endpoint.port,
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||
uid: state.uid, justInjected, onHostKey,
|
||||
expectedHostKeyFp: host && host.metadata && host.metadata.sshHostKeyFp,
|
||||
});
|
||||
} catch (_) { throw fail('upstream-unreachable'); }
|
||||
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
|
||||
|
||||
return { upstream, host, endpoint };
|
||||
}
|
||||
|
||||
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
|
||||
// detail carries the real underlying error message (e.g. ECONNREFUSED,
|
||||
// ETIMEDOUT, an ssh2 auth-failure string) so audit records aren't reduced to
|
||||
// just the generic reason code -- without it, a network-layer failure and an
|
||||
// SSH auth failure both looked identical in the audit log. hostSlug (when the
|
||||
// target was already resolved to a known host) lets callers attribute the
|
||||
// failure to that host for per-host "last failed connection" tracking.
|
||||
function fail(reason, detail, hostSlug) { const e = new Error(reason); e.reason = reason; e.detail = detail; e.hostSlug = hostSlug; return e; }
|
||||
|
||||
async function runGrammar(session, client, state) {
|
||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
|
||||
|
||||
// Deferred upstream — attach the bridge NOW, resolve/reject after connect.
|
||||
// Register session listeners IMMEDIATELY — before any async work.
|
||||
// The client sends exec/shell requests right after opening the session;
|
||||
// if we await audit.create() first, those requests arrive before the
|
||||
// listeners are registered and ssh2 rejects them with CHANNEL_FAILURE.
|
||||
let resolveUp, rejectUp;
|
||||
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
|
||||
attachSession(session, upstreamPromise, record);
|
||||
const dummyAudit = { patch() {}, finish() {}, event: {} };
|
||||
attachSession(session, upstreamPromise, dummyAudit);
|
||||
|
||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
|
||||
// Wire the real audit record into the already-attached session.
|
||||
dummyAudit.patch = (...a) => record.patch(...a);
|
||||
dummyAudit.finish = (...a) => record.finish(...a);
|
||||
Object.defineProperty(dummyAudit, 'event', { get: () => record.event });
|
||||
|
||||
try {
|
||||
const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
|
||||
@@ -166,25 +193,47 @@ async function runGrammar(session, client, state) {
|
||||
} catch (err) {
|
||||
const reason = err.reason || 'error';
|
||||
rejectUp(new Error(reasonMessage(reason)));
|
||||
await record.finish({ success: false, failReason: reason });
|
||||
await metrics.bump({ uid: state.uid, success: false });
|
||||
await record.finish({ success: false, failReason: reason, failDetail: err.detail });
|
||||
await metrics.bump({ uid: state.uid, hostSlug: err.hostSlug, success: false });
|
||||
}
|
||||
}
|
||||
|
||||
async function runTuiSession(session, client, state) {
|
||||
// Register session listeners IMMEDIATELY, before any await — same fix,
|
||||
// same reason, as runGrammar above. The client sends pty-req and shell
|
||||
// requests right after opening the session; awaiting audit.create() and
|
||||
// accessibleHosts() first (both real round-trips: Redis, then the
|
||||
// directory API) left a window where those requests could arrive before
|
||||
// runTui had attached any listener for them, and ssh2 auto-rejects an
|
||||
// unlistened channel request with CHANNEL_FAILURE — surfacing to the
|
||||
// client as "PTY allocation request failed" / "shell request failed",
|
||||
// with the connection then just sitting there (nothing left to drive it).
|
||||
let resolveHosts, rejectHosts;
|
||||
const hostsPromise = new Promise((res, rej) => { resolveHosts = res; rejectHosts = rej; });
|
||||
// A silent catch so a rejection isn't "unhandled" if the client never
|
||||
// sends a shell request at all (exec-only) — runTui's own .catch() below
|
||||
// still runs independently when it does.
|
||||
hostsPromise.catch(() => {});
|
||||
const tuiPromise = runTui(session, state.uid, hostsPromise);
|
||||
|
||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
|
||||
|
||||
const finishFail = async (reason) => {
|
||||
await record.finish({ success: false, failReason: reason });
|
||||
await metrics.bump({ uid: state.uid, success: false });
|
||||
const finishFail = async (reason, detail, hostSlug) => {
|
||||
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
||||
await metrics.bump({ uid: state.uid, hostSlug, success: false });
|
||||
try { client.end(); } catch (_) {}
|
||||
};
|
||||
|
||||
let hosts;
|
||||
try { hosts = await accessibleHosts(state.user); }
|
||||
catch (_) { return finishFail('directory-unreachable'); }
|
||||
try {
|
||||
hosts = await accessibleHosts(state.user);
|
||||
resolveHosts(hosts);
|
||||
} catch (_) {
|
||||
rejectHosts(new Error('directory-unreachable'));
|
||||
return finishFail('directory-unreachable');
|
||||
}
|
||||
|
||||
const tui = await runTui(session, state.uid, hosts);
|
||||
const tui = await tuiPromise;
|
||||
if (!tui.host) return finishFail('cancelled');
|
||||
state.target = tui.host.slug;
|
||||
|
||||
@@ -192,19 +241,32 @@ async function runTuiSession(session, client, state) {
|
||||
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
|
||||
|
||||
let justInjected = false;
|
||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||
catch (_) { return finishFail('key-inject-failed'); }
|
||||
let cert;
|
||||
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
|
||||
|
||||
try {
|
||||
if (usePki) {
|
||||
const { getSignedCert } = require('../utils/vault_cert');
|
||||
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
|
||||
} else {
|
||||
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
|
||||
}
|
||||
} catch (err) {
|
||||
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
|
||||
return finishFail(failType, err.message, tui.host.slug);
|
||||
}
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await connectUpstream({
|
||||
host: endpoint.address, port: endpoint.port,
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
||||
expectedHostKeyFp: tui.host && tui.host.metadata && tui.host.metadata.sshHostKeyFp,
|
||||
});
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
||||
return finishFail('upstream-unreachable');
|
||||
return finishFail('upstream-unreachable', err.message, tui.host.slug);
|
||||
}
|
||||
|
||||
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
|
||||
@@ -245,7 +307,10 @@ function reasonMessage(reason) {
|
||||
|
||||
// Run the TUI picker over a shell channel; returns { host, channel, ptyInfo }.
|
||||
// host is null if the user quit. exec/subsystem in picker mode are rejected.
|
||||
function runTui(session, uid, hosts) {
|
||||
// Takes a Promise for the accessible-hosts list (not the resolved list)
|
||||
// so the caller can register these listeners before that lookup completes
|
||||
// — see the comment in runTuiSession for why that ordering matters.
|
||||
function runTui(session, uid, hostsPromise) {
|
||||
return new Promise((resolve) => {
|
||||
let ptyInfo = null;
|
||||
let settled = false;
|
||||
@@ -254,9 +319,14 @@ function runTui(session, uid, hosts) {
|
||||
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
|
||||
session.on('shell', (accept) => {
|
||||
const channel = accept();
|
||||
pickHost(channel, uid, hosts).then((host) => {
|
||||
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
|
||||
finish({ host, channel, ptyInfo });
|
||||
hostsPromise.then((hosts) => {
|
||||
pickHost(channel, uid, hosts).then((host) => {
|
||||
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
|
||||
finish({ host, channel, ptyInfo });
|
||||
});
|
||||
}).catch(() => {
|
||||
try { channel.write('\r\n Could not reach the directory.\r\n'); channel.close(); } catch (_) {}
|
||||
finish({ host: null });
|
||||
});
|
||||
});
|
||||
session.on('exec', (accept) => {
|
||||
@@ -280,7 +350,7 @@ function start() {
|
||||
}
|
||||
);
|
||||
|
||||
const port = (conf.ssh && conf.ssh.listenPort) || 2222;
|
||||
const port = (conf.ssh && conf.ssh.listenPort) ?? 2222;
|
||||
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
|
||||
server.listen(port, host, () => {
|
||||
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
|
||||
|
||||
@@ -9,10 +9,29 @@ const ESC = '\x1b';
|
||||
const CLEAR = `${ESC}[2J${ESC}[H`;
|
||||
const HIDE_CUR = `${ESC}[?25l`;
|
||||
const SHOW_CUR = `${ESC}[?25h`;
|
||||
const INV = `${ESC}[7m`;
|
||||
|
||||
// Basic styles
|
||||
const RST = `${ESC}[0m`;
|
||||
const DIM = `${ESC}[2m`;
|
||||
const BOLD = `${ESC}[1m`;
|
||||
const DIM = `${ESC}[2m`;
|
||||
|
||||
// Colors (30-37: standard, 90-97: bright)
|
||||
const RED = `${ESC}[31m`;
|
||||
const BRIGHT_RED = `${ESC}[91m`;
|
||||
const CYAN = `${ESC}[36m`;
|
||||
const BRIGHT_CYAN = `${ESC}[96m`;
|
||||
const GREEN = `${ESC}[32m`;
|
||||
const BRIGHT_GREEN = `${ESC}[92m`;
|
||||
const YELLOW = `${ESC}[33m`;
|
||||
const BRIGHT_YELLOW = `${ESC}[93m`;
|
||||
const MAGENTA = `${ESC}[35m`;
|
||||
const BRIGHT_MAGENTA = `${ESC}[95m`;
|
||||
const BLUE = `${ESC}[34m`;
|
||||
const BRIGHT_BLUE = `${ESC}[94m`;
|
||||
|
||||
// Inverted selection with color
|
||||
const INV_GREEN = `${ESC}[42m${ESC}[30m`; // Green bg, black text
|
||||
const INV = `${ESC}[7m`;
|
||||
|
||||
function pickHost(channel, uid, hosts) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -35,18 +54,43 @@ function pickHost(channel, uid, hosts) {
|
||||
const list = visible();
|
||||
if (selected >= list.length) selected = Math.max(0, list.length - 1);
|
||||
let out = CLEAR + HIDE_CUR;
|
||||
out += `${BOLD} Theta42 Jump — hosts for ${uid}${RST}\r\n`;
|
||||
out += `${DIM} ↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n\r\n`;
|
||||
|
||||
// Header with gradient-style color
|
||||
out += `\r\n ${BOLD}${BRIGHT_CYAN}╔════════════════════════════════════════════════════════╗${RST}\r\n`;
|
||||
out += ` ${BOLD}${BRIGHT_CYAN}║${RST} ${BOLD}${BRIGHT_MAGENTA}Theta42 Jump${RST} ${DIM}·${RST} ${BRIGHT_GREEN}hosts for ${uid}${RST} ${BOLD}${BRIGHT_CYAN}║${RST}\r\n`;
|
||||
out += ` ${BOLD}${BRIGHT_CYAN}╚════════════════════════════════════════════════════════╝${RST}\r\n`;
|
||||
out += `\r\n`;
|
||||
out += ` ${DIM}↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n`;
|
||||
out += `\r\n`;
|
||||
|
||||
if (!list.length) {
|
||||
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
|
||||
out += ` ${YELLOW}⚠${RST} ${DIM}(no match for "${filter}")${RST}\r\n`;
|
||||
} else {
|
||||
list.forEach((h, i) => {
|
||||
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
|
||||
const row = ` ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${ip}` : ''}`;
|
||||
out += (i === selected ? `${INV}> ${h.name} (${h.slug})${ip ? ` ${ip}` : ''}${RST}` : row) + '\r\n';
|
||||
const isProd = h.metadata && h.metadata.isProduction;
|
||||
const envBadge = isProd ? `${BOLD}${RED}PROD${RST} ` : `${DIM}DEV${RST} `;
|
||||
|
||||
if (i === selected) {
|
||||
// Selected row with green inverse background
|
||||
const selRow = `${INV_GREEN} ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${CYAN}${ip}${RST}` : ''} ${envBadge} ${BOLD}${BRIGHT_GREEN}◄ SELECTED ►${RST}${INV_GREEN}${RST}`;
|
||||
out += selRow + '\r\n';
|
||||
} else {
|
||||
// Normal row with subtle coloring
|
||||
const nameColor = i % 2 === 0 ? BRIGHT_CYAN : CYAN;
|
||||
out += ` ${nameColor}${h.name}${RST} ${DIM}(${h.slug})${RST}${ip ? ` ${BLUE}${ip}${RST}` : ''} ${envBadge}\r\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (filter) out += `\r\n ${DIM}filter:${RST} ${filter}`;
|
||||
|
||||
if (filter) {
|
||||
out += `\r\n ${DIM}filter: ${BRIGHT_YELLOW}${filter}${RST}`;
|
||||
}
|
||||
|
||||
// Footer
|
||||
out += `\r\n\r\n ${DIM}────────────────────────────────────────────────────────${RST}\r\n`;
|
||||
out += ` ${DIM}Press${RST} ${BOLD}1-9${RST} ${DIM}to quick-select · ${BOLD}q${RST} ${DIM}to quit${RST}\r\n`;
|
||||
|
||||
channel.write(out);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { getRedis } = require('../models');
|
||||
const { generateKeypair } = require('../utils/wg_keys');
|
||||
const wgSite = require('../models/wg_site');
|
||||
|
||||
async function bootstrapWireguard() {
|
||||
try {
|
||||
const redis = await getRedis();
|
||||
const P = conf.redis.prefix || '';
|
||||
const keypairKey = `${P}wg_gateway_keypair`;
|
||||
|
||||
// 1. Ensure Gateway WireGuard Keypair
|
||||
let keypairData = await redis.hGetAll(keypairKey);
|
||||
if (!keypairData || !keypairData.publicKey) {
|
||||
const kp = generateKeypair();
|
||||
keypairData = {
|
||||
privateKey: kp.privateKey,
|
||||
publicKey: kp.publicKey,
|
||||
createdAt: String(Date.now()),
|
||||
};
|
||||
await redis.hSet(keypairKey, keypairData);
|
||||
console.log('[bootstrap] Generated fresh WireGuard Gateway keypair.');
|
||||
}
|
||||
|
||||
if (!conf.wireguard) conf.wireguard = {};
|
||||
conf.wireguard.serverPublicKey = keypairData.publicKey;
|
||||
conf.wireguard.serverPrivateKey = keypairData.privateKey;
|
||||
|
||||
if (!conf.wireguard.serverEndpoint) {
|
||||
const domain = conf.domain || 'suite.vm42.us';
|
||||
conf.wireguard.serverEndpoint = `${domain}:51820`;
|
||||
}
|
||||
|
||||
// 2. Ensure Default Site Exit Node ("This Site")
|
||||
const existingSites = await wgSite.list().catch(() => []);
|
||||
if (!existingSites || existingSites.length === 0) {
|
||||
const siteName = conf.siteName || process.env.CFG_SITE_NAME || '718it';
|
||||
const defaultSite = await wgSite.create({
|
||||
name: `${siteName} (This Site)`,
|
||||
endpoint: conf.wireguard.serverEndpoint,
|
||||
publicKey: keypairData.publicKey,
|
||||
subnet: '0.0.0.0/0',
|
||||
exitAll: true,
|
||||
siteId: siteName,
|
||||
note: 'Default local site exit node initialized during bootstrap',
|
||||
}, 'bootstrap');
|
||||
console.log(`[bootstrap] Initialized default WireGuard exit node '${defaultSite.name}' (${defaultSite.id}).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[bootstrap] WireGuard bootstrap error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { bootstrapWireguard };
|
||||
@@ -156,6 +156,29 @@ test('shell bridges and echoes', async () => {
|
||||
assert.match(out, /echo:ping/);
|
||||
});
|
||||
|
||||
test('connectUpstream rejects with a specific, non-generic error when the target refuses the connection', async () => {
|
||||
// Regression coverage for ssh_server.js's resolveAndConnect: it used to
|
||||
// discard this error entirely (catch (_) { throw fail('upstream-unreachable') }),
|
||||
// so the audit log recorded the same generic reason for a refused port, a
|
||||
// timeout, or a bad key alike. Now the real message is threaded through as
|
||||
// failDetail, so this must stay meaningful.
|
||||
// Bind a server just to reserve a free port, then close it immediately so
|
||||
// nothing is listening there — guarantees ECONNREFUSED rather than relying
|
||||
// on a hardcoded port number that might be in use.
|
||||
const closedPort = await new Promise((resolve) => {
|
||||
const probe = require('net').createServer();
|
||||
probe.listen(0, '127.0.0.1', () => { const p = probe.address().port; probe.close(() => resolve(p)); });
|
||||
});
|
||||
await assert.rejects(
|
||||
connectUpstream({ host: '127.0.0.1', port: closedPort, username: 'test', privateKey: jumpKey, uid: 'test', justInjected: false }),
|
||||
(err) => {
|
||||
assert.ok(err.message && err.message.length > 0);
|
||||
assert.notStrictEqual(err.message, 'upstream-unreachable');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('sftp subsystem bytes pass through', async () => {
|
||||
const { conn, ready } = connectJump();
|
||||
await ready;
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
'use strict';
|
||||
|
||||
// End-to-end standalone SSH test: a real downstream sshd, the full jump host
|
||||
// SSH server (ssh_server.js), and an SSH client. Authentication and host
|
||||
// discovery use the ORM-backed standalone stores (temp file SQLite).
|
||||
//
|
||||
// Follows the same hermetic pattern as ssh_bridge.test.js but exercises the
|
||||
// full stack: conf → ORM → user_ldap facade → ssh_server → bridge.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { Server, Client, utils } = require('ssh2');
|
||||
const bcrypt = require('bcrypt');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
// ── Conf must be set BEFORE any module that checks conf.standalone.enabled ──
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-standalone-'));
|
||||
const dbPath = path.join(tmpDir, 'test.sqlite');
|
||||
|
||||
conf.standalone = { enabled: true };
|
||||
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
|
||||
conf.ssh = {
|
||||
listenHost: '127.0.0.1',
|
||||
listenPort: 0,
|
||||
hostKeyPath: path.join(tmpDir, 'keys'),
|
||||
passwordAuth: 'all',
|
||||
keyComment: 'jump-host-test',
|
||||
defaultPort: 22,
|
||||
connectTimeoutMs: 5000,
|
||||
maxSessions: 10,
|
||||
};
|
||||
conf.redis = { prefix: 'jump_host_test_standalone_' };
|
||||
conf.audit = { maxEvents: 100 };
|
||||
|
||||
// ── Require models/index FIRST so it initializes the ORM exactly once.
|
||||
// This also registers the standalone models. We await ormReady before
|
||||
// seeding data, then start the SSH server. ──
|
||||
|
||||
const models = require('../../models');
|
||||
const StandaloneUser = require('../../models/standalone_user');
|
||||
const StandaloneHost = require('../../models/standalone_host');
|
||||
|
||||
let downstream, downstreamPort, jump, jumpPort;
|
||||
let testUserKey;
|
||||
|
||||
function startDownstream() {
|
||||
return new Promise((resolve) => {
|
||||
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
|
||||
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
|
||||
client.on('authentication', (ctx) => ctx.accept());
|
||||
client.on('ready', () => {
|
||||
client.on('session', (accept) => {
|
||||
const session = accept();
|
||||
session.on('pty', (a) => a && a());
|
||||
session.on('shell', (a) => {
|
||||
const ch = a();
|
||||
ch.write('downstream-shell-ready\n');
|
||||
ch.on('data', (d) => ch.write('echo:' + d));
|
||||
});
|
||||
session.on('exec', (a, r, info) => {
|
||||
const ch = a();
|
||||
ch.write(`ran:${info.command}`);
|
||||
ch.exit(0);
|
||||
ch.end();
|
||||
});
|
||||
session.on('subsystem', (a, r, info) => {
|
||||
if (info.name !== 'sftp') return r && r();
|
||||
const ch = a();
|
||||
ch.on('data', (d) => ch.write(Buffer.concat([Buffer.from('sftp:'), d])));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
srv.listen(0, '127.0.0.1', () => resolve(srv));
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
// 1. Start downstream.
|
||||
downstream = await startDownstream();
|
||||
downstreamPort = downstream.address().port;
|
||||
|
||||
// 2. Wait for the ORM to finish syncing tables (init was called by models/index
|
||||
// at require time — we just need the tables to exist before seeding).
|
||||
await models.ormReady;
|
||||
|
||||
// 3. Seed test data.
|
||||
const userKeyPair = utils.generateKeyPairSync('ed25519');
|
||||
testUserKey = userKeyPair.private;
|
||||
const userPubKey = utils.parseKey(userKeyPair.private);
|
||||
const userPubLine = `${userPubKey.type} ${userPubKey.getPublicSSH().toString('base64')} testuser@test`;
|
||||
|
||||
const passwordHash = await bcrypt.hash('testpass', 4);
|
||||
|
||||
await StandaloneUser.create({
|
||||
uid: 'testuser',
|
||||
passwordHash,
|
||||
sshPublicKeys: [userPubLine],
|
||||
groups: ['admin'],
|
||||
});
|
||||
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_test',
|
||||
displayName: 'Test Downstream',
|
||||
kind: 'host',
|
||||
metadata: { address: `ssh://127.0.0.1:${downstreamPort}`, ip: '127.0.0.1', sshPort: downstreamPort },
|
||||
});
|
||||
|
||||
// 4. Start the jump host SSH server.
|
||||
const sshServer = require('../../services/ssh_server');
|
||||
jump = sshServer.start();
|
||||
await new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const addr = jump.address();
|
||||
if (addr) { jumpPort = addr.port; resolve(); }
|
||||
else setTimeout(check, 10);
|
||||
};
|
||||
check();
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { downstream && downstream.close(); } catch (_) {}
|
||||
try { jump && jump.close(); } catch (_) {}
|
||||
try { models.redisClient.destroy(); } catch (_) {}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', () => {});
|
||||
|
||||
function connectJump(opts = {}) {
|
||||
const conn = new Client();
|
||||
const connectOpts = {
|
||||
host: '127.0.0.1',
|
||||
port: jumpPort,
|
||||
username: opts.username || 'testuser_-_host_test',
|
||||
...opts,
|
||||
};
|
||||
return {
|
||||
conn,
|
||||
ready: new Promise((res, rej) => {
|
||||
conn.on('ready', res).on('error', rej).connect(connectOpts);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
test('public key auth + grammar mode exec', async () => {
|
||||
const { conn, ready } = connectJump({ privateKey: testUserKey });
|
||||
await ready;
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
conn.exec('hello-world', (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = '';
|
||||
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /ran:hello-world/);
|
||||
});
|
||||
|
||||
test('public key auth + grammar mode shell', async () => {
|
||||
const { conn, ready } = connectJump({ privateKey: testUserKey });
|
||||
await ready;
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
conn.shell((err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = '';
|
||||
stream.on('data', (d) => {
|
||||
buf += d;
|
||||
if (buf.includes('echo:ping')) resolve(buf);
|
||||
});
|
||||
setTimeout(() => stream.write('ping'), 150);
|
||||
setTimeout(() => resolve(buf), 5000);
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /downstream-shell-ready/);
|
||||
assert.match(out, /echo:ping/);
|
||||
});
|
||||
|
||||
test('password auth + grammar mode exec', async () => {
|
||||
const { conn, ready } = connectJump({
|
||||
username: 'testuser_-_host_test',
|
||||
password: 'testpass',
|
||||
});
|
||||
await ready;
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
conn.exec('pw-test', (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = '';
|
||||
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /ran:pw-test/);
|
||||
});
|
||||
|
||||
test('password auth denied with wrong password', async () => {
|
||||
const conn = new Client();
|
||||
const result = await new Promise((resolve) => {
|
||||
conn.on('ready', () => resolve('unexpected-ready'));
|
||||
conn.on('error', () => resolve('auth-failed'));
|
||||
conn.connect({
|
||||
host: '127.0.0.1', port: jumpPort,
|
||||
username: 'testuser_-_host_test',
|
||||
password: 'wrongpass',
|
||||
});
|
||||
});
|
||||
assert.strictEqual(result, 'auth-failed');
|
||||
});
|
||||
|
||||
test('unknown user rejected', async () => {
|
||||
const conn = new Client();
|
||||
const result = await new Promise((resolve) => {
|
||||
conn.on('ready', () => resolve('unexpected-ready'));
|
||||
conn.on('error', () => resolve('auth-failed'));
|
||||
conn.connect({
|
||||
host: '127.0.0.1', port: jumpPort,
|
||||
username: 'nobody_-_host_test',
|
||||
password: 'testpass',
|
||||
});
|
||||
});
|
||||
assert.strictEqual(result, 'auth-failed');
|
||||
});
|
||||
@@ -2,45 +2,39 @@
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { accessibleHosts, clearCache } = require('../../utils/access');
|
||||
const { accessibleHosts, allHosts, clearCache } = require('../../utils/access');
|
||||
|
||||
function stubLdap(groups) {
|
||||
return { getGroups: async () => groups };
|
||||
}
|
||||
|
||||
function stubFetch(byGroup) {
|
||||
function stubFetch(byUid) {
|
||||
return async (url) => {
|
||||
const cn = decodeURIComponent(url.split('group=')[1]);
|
||||
return { ok: true, json: async () => ({ results: byGroup[cn] || [] }) };
|
||||
const uid = url.split('/access/')[1];
|
||||
return { ok: true, json: async () => ({ results: byUid[uid] || [] }) };
|
||||
};
|
||||
}
|
||||
|
||||
test('unions hosts across groups, dedupes, drops non-hosts', async () => {
|
||||
test('drops non-hosts from access projection', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
|
||||
const fetchImpl = stubFetch({
|
||||
host_web01_access: [
|
||||
alice: [
|
||||
{ id: '1', kind: 'host', slug: 'host_web01' },
|
||||
{ id: '2', kind: 'host', slug: 'host_db' },
|
||||
{ id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host
|
||||
],
|
||||
host_db_access: [
|
||||
{ id: '1', kind: 'host', slug: 'host_web01' }, // dupe by id
|
||||
{ id: '2', kind: 'host', slug: 'host_db' },
|
||||
],
|
||||
});
|
||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['host_web01_access', 'host_db_access']) });
|
||||
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||
});
|
||||
|
||||
test('a failing group query does not sink the rest', async () => {
|
||||
test('a failing access query returns empty list without throwing', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.includes('bad')) return { ok: false, status: 500 };
|
||||
return { ok: true, json: async () => ({ results: [{ id: '3', kind: 'host', slug: 'host_ok' }] }) };
|
||||
};
|
||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['bad_access', 'good_access']) });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['3']);
|
||||
const fetchImpl = async () => ({ ok: false, status: 500 });
|
||||
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||
assert.deepStrictEqual(hosts, []);
|
||||
});
|
||||
|
||||
test('caches per uid', async () => {
|
||||
@@ -48,8 +42,61 @@ test('caches per uid', async () => {
|
||||
let calls = 0;
|
||||
const user = { uid: 'cara', dn: 'd' };
|
||||
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
|
||||
const ldap = { getGroups: async () => ['g1'] };
|
||||
await accessibleHosts(user, { fetchImpl, ldap });
|
||||
await accessibleHosts(user, { fetchImpl, ldap });
|
||||
await accessibleHosts(user, { fetchImpl });
|
||||
await accessibleHosts(user, { fetchImpl });
|
||||
assert.strictEqual(calls, 1);
|
||||
});
|
||||
|
||||
test('does not depend on user.groups or ldap.getGroups', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'erin' }; // no dn, no groups
|
||||
const fetchImpl = stubFetch({
|
||||
erin: [{ id: '5', kind: 'host', slug: 'host_web01' }],
|
||||
});
|
||||
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
||||
});
|
||||
|
||||
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
||||
const fetchImpl = async (url) => {
|
||||
assert.ok(!url.includes('group='), 'must not filter by group');
|
||||
assert.ok(url.includes('kind=host'));
|
||||
return { ok: true, json: async () => ({ results: [
|
||||
{ id: '1', kind: 'host', slug: 'host_a' },
|
||||
{ id: '2', kind: 'host', slug: 'host_b' },
|
||||
] }) };
|
||||
};
|
||||
const hosts = await allHosts({ fetchImpl });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||
});
|
||||
|
||||
// Only catalog content is a jump target. Discovery writes `discovery_sources`;
|
||||
// promoting to the catalog sets `managed: true`. An unpromoted Proxmox VM was
|
||||
// reaching the picker because the filter defaulted `managed`-less hosts to true.
|
||||
test('drops auto-discovered hosts that were never promoted', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'frank', dn: 'd' };
|
||||
const fetchImpl = stubFetch({
|
||||
frank: [
|
||||
{ id: '1', kind: 'host', slug: 'host_web01' }, // hand-made: no discovery_sources
|
||||
{ id: '2', kind: 'host', slug: 'vm-101', metadata: { discovery_sources: ['proxmox'] } }, // discovered, unpromoted
|
||||
{ id: '3', kind: 'host', slug: 'vm-102', metadata: { discovery_sources: ['proxmox'], managed: true } }, // promoted
|
||||
{ id: '4', kind: 'host', slug: 'host_db', metadata: { discovery_sources: ['manual'] } }, // manual source counts as catalog
|
||||
{ id: '5', kind: 'host', slug: 'host_off', metadata: { managed: false } }, // explicitly out
|
||||
],
|
||||
});
|
||||
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '3', '4']);
|
||||
});
|
||||
|
||||
test('a bare-array response (envelope drift) returns empty list', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'dave', dn: 'd' };
|
||||
// drift shape: a bare array instead of { results: [...] }. The shared client
|
||||
// throws DirectoryEnvelopeViolation; access.js must catch + continue.
|
||||
const fetchImpl = async () => {
|
||||
return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
||||
};
|
||||
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||
assert.deepStrictEqual(hosts, []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
// Unit tests for the ORM-backed host inventory (utils/hosts_file.js).
|
||||
// Uses a temp file SQLite database — no external services needed.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const StandaloneHost = require('../../models/standalone_host');
|
||||
|
||||
let tmpDir;
|
||||
let hostsFile; // required after ORM init
|
||||
|
||||
before(async () => {
|
||||
// Unique temp DB so this test file doesn't collide with other ORM tests.
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-hostfile-'));
|
||||
const dbPath = path.join(tmpDir, 'test.sqlite');
|
||||
|
||||
conf.standalone = { enabled: true };
|
||||
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
|
||||
|
||||
await init({ conf: { orm: conf.orm }, models: [StandaloneHost] });
|
||||
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_web01',
|
||||
displayName: 'Web Server 01',
|
||||
kind: 'host',
|
||||
metadata: { address: 'ssh://10.0.0.10:22', ip: '10.0.0.10', sshPort: 22 },
|
||||
});
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_db',
|
||||
displayName: 'Database Server',
|
||||
kind: 'host',
|
||||
metadata: { address: 'ssh://10.0.0.20:22', ip: '10.0.0.20', sshPort: 22 },
|
||||
});
|
||||
await StandaloneHost.create({
|
||||
slug: 'app_gitea',
|
||||
displayName: 'Gitea',
|
||||
kind: 'service',
|
||||
metadata: { url: 'https://gitea.internal' },
|
||||
});
|
||||
|
||||
hostsFile = require('../../utils/hosts_file');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
test('accessibleHosts returns all hosts', async () => {
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
assert.strictEqual(hosts.length, 2);
|
||||
const slugs = hosts.map((h) => h.slug).sort();
|
||||
assert.deepStrictEqual(slugs, ['host_db', 'host_web01']);
|
||||
});
|
||||
|
||||
test('accessibleHosts filters to kind=host', async () => {
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
const kinds = [...new Set(hosts.map((h) => h.kind))];
|
||||
assert.deepStrictEqual(kinds, ['host']);
|
||||
});
|
||||
|
||||
test('accessibleHosts returns host resources with expected shape', async () => {
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
const web = hosts.find((h) => h.slug === 'host_web01');
|
||||
assert.ok(web);
|
||||
assert.strictEqual(web.id, 'host_web01');
|
||||
assert.strictEqual(web.displayName, 'Web Server 01');
|
||||
assert.strictEqual(web.metadata.ip, '10.0.0.10');
|
||||
assert.strictEqual(web.metadata.sshPort, 22);
|
||||
});
|
||||
|
||||
test('accessibleHosts returns empty array when no hosts exist', async () => {
|
||||
// Delete all hosts and verify empty result.
|
||||
const all = await StandaloneHost.list();
|
||||
for (const h of all) {
|
||||
await h.delete({ force: true });
|
||||
}
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
assert.deepStrictEqual(hosts, []);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { meshCidrFor, meshAllowedIpsFor, MAX_MESH_INDEX, MIN_MESH_INDEX } = require('../../utils/mesh_addressing');
|
||||
|
||||
test('meshCidrFor renders the .1 address in the site\'s /24', () => {
|
||||
assert.equal(meshCidrFor(1), '172.24.1.1/24');
|
||||
assert.equal(meshCidrFor(254), '172.24.254.1/24');
|
||||
});
|
||||
|
||||
test('meshAllowedIpsFor covers both the mesh /24 and the site\'s 10.x/16', () => {
|
||||
assert.deepEqual(meshAllowedIpsFor(5), ['172.24.5.0/24', '10.5.0.0/16']);
|
||||
});
|
||||
|
||||
test('rejects index 0 and 255 (reserved) and the out-of-range/non-integer cases', () => {
|
||||
assert.throws(() => meshCidrFor(0));
|
||||
assert.throws(() => meshCidrFor(255));
|
||||
assert.throws(() => meshCidrFor(MAX_MESH_INDEX + 1));
|
||||
assert.throws(() => meshCidrFor(MIN_MESH_INDEX - 1));
|
||||
assert.throws(() => meshCidrFor(1.5));
|
||||
assert.throws(() => meshCidrFor('1'));
|
||||
});
|
||||
|
||||
test('MAX_MESH_INDEX matches the documented 254-site ceiling', () => {
|
||||
assert.equal(MAX_MESH_INDEX, 254);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
'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. This app has no such call sites;
|
||||
// keep 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, []);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
// Unit tests for the ORM-backed user store (models/user_file.js).
|
||||
// Uses a temp file SQLite database — no external services needed.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const bcrypt = require('bcrypt');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const StandaloneUser = require('../../models/standalone_user');
|
||||
|
||||
let testPasswordHash;
|
||||
let tmpDir;
|
||||
let userFile; // required after ORM init
|
||||
|
||||
before(async () => {
|
||||
// Unique temp DB so this test file doesn't collide with other ORM tests.
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-userfile-'));
|
||||
const dbPath = path.join(tmpDir, 'test.sqlite');
|
||||
|
||||
conf.standalone = { enabled: true };
|
||||
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
|
||||
|
||||
await init({ conf: { orm: conf.orm }, models: [StandaloneUser] });
|
||||
|
||||
testPasswordHash = await bcrypt.hash('testpass', 4);
|
||||
|
||||
await StandaloneUser.create({
|
||||
uid: 'alice',
|
||||
passwordHash: testPasswordHash,
|
||||
sshPublicKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop'],
|
||||
groups: ['admin', 'developers'],
|
||||
});
|
||||
|
||||
// Now that the ORM is initialized and conf.standalone is set, require the
|
||||
// facade. It checks conf.standalone.enabled at require time.
|
||||
userFile = require('../../models/user_file');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
test('getUser returns user with synthesized dn and keys', async () => {
|
||||
const user = await userFile.getUser('alice');
|
||||
assert.ok(user);
|
||||
assert.strictEqual(user.uid, 'alice');
|
||||
assert.strictEqual(user.dn, 'uid=alice,ou=people,dc=standalone,dc=local');
|
||||
assert.deepStrictEqual(user.sshPublicKeys, ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop']);
|
||||
});
|
||||
|
||||
test('getUser returns null for unknown uid', async () => {
|
||||
const user = await userFile.getUser('nobody');
|
||||
assert.strictEqual(user, null);
|
||||
});
|
||||
|
||||
test('getGroups returns user groups', async () => {
|
||||
const groups = await userFile.getGroups('uid=alice,ou=people,dc=standalone,dc=local');
|
||||
assert.deepStrictEqual(groups, ['admin', 'developers']);
|
||||
});
|
||||
|
||||
test('getGroups returns empty array for unknown dn', async () => {
|
||||
const groups = await userFile.getGroups('uid=nobody,ou=people,dc=standalone,dc=local');
|
||||
assert.deepStrictEqual(groups, []);
|
||||
});
|
||||
|
||||
test('getGroups returns empty array for malformed dn', async () => {
|
||||
const groups = await userFile.getGroups('not-a-dn');
|
||||
assert.deepStrictEqual(groups, []);
|
||||
});
|
||||
|
||||
test('checkPassword returns true for correct password', async () => {
|
||||
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'testpass');
|
||||
assert.strictEqual(ok, true);
|
||||
});
|
||||
|
||||
test('checkPassword returns false for wrong password', async () => {
|
||||
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'wrongpass');
|
||||
assert.strictEqual(ok, false);
|
||||
});
|
||||
|
||||
test('checkPassword returns false for unknown user', async () => {
|
||||
const ok = await userFile.checkPassword('uid=nobody,ou=people,dc=standalone,dc=local', 'testpass');
|
||||
assert.strictEqual(ok, false);
|
||||
});
|
||||
|
||||
test('addSshKey appends a new key', async () => {
|
||||
const newKey = 'ssh-rsa AAAAB3NzaC1yc2E... bob@desktop';
|
||||
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', newKey);
|
||||
|
||||
const user = await StandaloneUser.get('alice');
|
||||
assert.ok(user.sshPublicKeys.includes(newKey));
|
||||
});
|
||||
|
||||
test('addSshKey is idempotent', async () => {
|
||||
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop';
|
||||
const userBefore = await StandaloneUser.get('alice');
|
||||
const countBefore = userBefore.sshPublicKeys.length;
|
||||
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', key);
|
||||
const userAfter = await StandaloneUser.get('alice');
|
||||
assert.strictEqual(userAfter.sshPublicKeys.length, countBefore);
|
||||
});
|
||||
|
||||
test('addSshKey is a no-op for unknown user', async () => {
|
||||
// Should not throw.
|
||||
await userFile.addSshKey('uid=nobody,ou=people,dc=standalone,dc=local', 'ssh-rsa AAA...');
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { generateKeypair } = require('../../utils/wg_keys');
|
||||
const { renderClientConf } = require('../../utils/wg_conf');
|
||||
|
||||
test('generateKeypair returns valid base64 WireGuard keypair', () => {
|
||||
const kp = generateKeypair();
|
||||
assert.ok(kp.privateKey, 'privateKey should exist');
|
||||
assert.ok(kp.publicKey, 'publicKey should exist');
|
||||
assert.notEqual(kp.privateKey, kp.publicKey);
|
||||
// WireGuard raw X25519 base64 keys are 44 characters ending with '='
|
||||
assert.equal(kp.privateKey.length, 44);
|
||||
assert.equal(kp.publicKey.length, 44);
|
||||
});
|
||||
|
||||
test('renderClientConf generates valid wg0 client configuration', () => {
|
||||
const peer = {
|
||||
name: 'test-phone',
|
||||
assignedIP: '10.100.0.5',
|
||||
privateKey: 'c3VwZXJzZWNyZXRwcml2YXRla2V5MTIzNDU2Nzg5MDE=',
|
||||
};
|
||||
const site = {
|
||||
subnet: '192.168.1.0/24',
|
||||
exitAll: false,
|
||||
};
|
||||
const confStr = renderClientConf({
|
||||
peer,
|
||||
site,
|
||||
serverPub: 'c2VydmVycHVibGlja2V5MTIzNDU2Nzg5MDEyMzQ1Njc=',
|
||||
serverEndpoint: 'gw.theta42.com:51820',
|
||||
dns: '1.1.1.1',
|
||||
});
|
||||
|
||||
assert.ok(confStr.includes('[Interface]'));
|
||||
assert.ok(confStr.includes('PrivateKey = c3VwZXJzZWNyZXRwcml2YXRla2V5MTIzNDU2Nzg5MDE='));
|
||||
assert.ok(confStr.includes('Address = 10.100.0.5/32'));
|
||||
assert.ok(confStr.includes('[Peer]'));
|
||||
assert.ok(confStr.includes('PublicKey = c2VydmVycHVibGlja2V5MTIzNDU2Nzg5MDEyMzQ1Njc='));
|
||||
assert.ok(confStr.includes('Endpoint = gw.theta42.com:51820'));
|
||||
});
|
||||
+84
-54
@@ -1,67 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
// Which directory hosts may a user reach, and how do we dial them?
|
||||
//
|
||||
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
||||
// /api/discovery/me only answers for the API token's own user, and /graph
|
||||
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
||||
// directly) with per-group resource lookups:
|
||||
//
|
||||
// 1. LDAP: groups the user's DN is a member of
|
||||
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
|
||||
// 3. union, keep kind === 'host'
|
||||
//
|
||||
// Results are cached per-uid for a short TTL — the TUI picker and the
|
||||
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||
// unit testing.
|
||||
// Host discovery — SSO Manager API in production, ORM-backed inventory in
|
||||
// standalone mode. Both export the same interface:
|
||||
// accessibleHosts(user) -> [host resources]
|
||||
// clearCache(uid?) -> void
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const userLdap = require('../models/user_ldap');
|
||||
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
const cache = new Map(); // uid -> {at, hosts}
|
||||
if (conf.standalone && conf.standalone.enabled) {
|
||||
// Standalone mode: use the ORM-backed host inventory. Every host is
|
||||
// accessible to every user, so allHosts and accessibleHosts coincide.
|
||||
const { accessibleHosts } = require('./hosts_file');
|
||||
module.exports = { accessibleHosts, allHosts: () => accessibleHosts(), clearCache: () => {} };
|
||||
} else {
|
||||
// Production mode: LDAP groups + SSO API (unchanged).
|
||||
|
||||
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||
const sso = conf.sso || {};
|
||||
const url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`;
|
||||
const res = await fetchImpl(url, {
|
||||
headers: { Authorization: `Bearer ${sso.apiToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`);
|
||||
const data = await res.json();
|
||||
return (data && data.results) || [];
|
||||
}
|
||||
// Which directory hosts may a user reach, and how do we dial them?
|
||||
//
|
||||
// We use the SSO's machine-aware /api/discovery/access/:uid endpoint,
|
||||
// which evaluates the user's groups server-side and returns their complete
|
||||
// access projection in one call.
|
||||
//
|
||||
// Results are cached per-uid for a short TTL — the TUI picker and the
|
||||
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||
// unit testing.
|
||||
|
||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
||||
const hit = cache.get(user.uid);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
||||
const userLdap = require('../models/user_ldap');
|
||||
|
||||
const groups = await ldap.getGroups(user.dn);
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
const cache = new Map(); // uid -> {at, hosts}
|
||||
|
||||
const seen = new Map();
|
||||
for (const cn of groups) {
|
||||
let resources;
|
||||
try {
|
||||
resources = await fetchResourcesByGroup(cn, { fetchImpl });
|
||||
} catch (error) {
|
||||
// One bad group must not hide the rest; the SSO being down
|
||||
// surfaces as an empty list + log line, not a crash.
|
||||
console.error(`[access] ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
for (const r of resources) {
|
||||
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
|
||||
}
|
||||
// Build a directory client bound to conf.sso. fetchImpl is injectable so the
|
||||
// unit tests can stub the transport; the shared client validates the
|
||||
// `{ results }` envelope on every call (turns the old bare-array drift into a
|
||||
// thrown error instead of a silent `[]`).
|
||||
function directoryClient({ fetchImpl = fetch } = {}) {
|
||||
const sso = conf.sso || {};
|
||||
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
|
||||
}
|
||||
|
||||
const hosts = [...seen.values()];
|
||||
cache.set(user.uid, { at: Date.now(), hosts });
|
||||
return hosts;
|
||||
}
|
||||
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
||||
}
|
||||
|
||||
function clearCache(uid) {
|
||||
if (uid) cache.delete(uid);
|
||||
else cache.clear();
|
||||
}
|
||||
// Every host in the inventory, unfiltered — for admins (the web UI's own
|
||||
// account is already gated by requireAdmin before this is ever called).
|
||||
//
|
||||
// "In the catalog" is the same predicate the SSO's own Directory listing
|
||||
// applies (sso-manager-node routes/api_directory_admin.js GET /resources):
|
||||
// a resource that was auto-discovered and never promoted is NOT catalog
|
||||
// content and must never be offered as a jump target. Discovery writes
|
||||
// `metadata.discovery_sources`; promoting sets `metadata.managed = true`.
|
||||
// Hosts created by hand carry no discovery_sources at all and stay in.
|
||||
//
|
||||
// The two copies of this rule have already drifted apart once (unpromoted
|
||||
// Proxmox VMs showing up in the picker); if a third consumer needs it,
|
||||
// hoist it into @simpleworkjs/directory-schema rather than copying again.
|
||||
function isCatalogHost(r) {
|
||||
if (!r || r.kind !== 'host') return false;
|
||||
const meta = r.metadata || {};
|
||||
if (meta.managed === true || meta.managed === 'true') return true;
|
||||
if (meta.managed === false || meta.managed === 'false') return false;
|
||||
const sources = meta.discovery_sources || [];
|
||||
const autoDiscovered = sources.length > 0 && !sources.includes('manual');
|
||||
return !autoDiscovered;
|
||||
}
|
||||
|
||||
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
|
||||
async function allHosts({ fetchImpl = fetch } = {}) {
|
||||
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
|
||||
return resources.filter(isCatalogHost);
|
||||
}
|
||||
|
||||
async function accessibleHosts(user, { fetchImpl = fetch } = {}) {
|
||||
const hit = cache.get(user.uid);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||
|
||||
let resources = [];
|
||||
try {
|
||||
resources = await directoryClient({ fetchImpl }).getAccess(user.uid);
|
||||
} catch (error) {
|
||||
console.error(`[access] ${error.message}`);
|
||||
}
|
||||
|
||||
const hosts = resources.filter(isCatalogHost);
|
||||
cache.set(user.uid, { at: Date.now(), hosts });
|
||||
return hosts;
|
||||
}
|
||||
|
||||
function clearCache(uid) {
|
||||
if (uid) cache.delete(uid);
|
||||
else cache.clear();
|
||||
}
|
||||
|
||||
module.exports = { accessibleHosts, allHosts, clearCache, fetchResourcesByGroup };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
|
||||
// shared @simpleworkjs/app-stack. Previously this lived in models/build_info.js
|
||||
// and exported { commit, version }; the shape is now aligned with sso + proxy.
|
||||
//
|
||||
// The baked commit file lives at the jump-host repo root (../../ from here in
|
||||
// utils/), matching the Dockerfile gitinfo stage. cwd is utils/ for the
|
||||
// bare-metal git fallback.
|
||||
|
||||
const path = require('path');
|
||||
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||
const { version } = require('../package.json');
|
||||
|
||||
module.exports = createBuildInfo({
|
||||
version,
|
||||
buildCommitPath: path.join(__dirname, '../../.build_commit'),
|
||||
cwd: __dirname,
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
// ORM-backed host inventory for standalone mode. Implements the same interface
|
||||
// as utils/access.js so ssh_server.js works unchanged: accessibleHosts(user)
|
||||
// returns an array of host resources the user may reach.
|
||||
//
|
||||
// In standalone mode all hosts in the inventory are accessible to every
|
||||
// authenticated user — there is no group-based filtering. The _user parameter
|
||||
// is accepted for interface compatibility but ignored.
|
||||
|
||||
const StandaloneHost = require('../models/standalone_host');
|
||||
|
||||
async function accessibleHosts(_user) {
|
||||
const hosts = await StandaloneHost.list({ where: { kind: 'host' } });
|
||||
// The ORM returns model instances; map to plain objects matching the shape
|
||||
// that target_match.js and tui_picker.js expect.
|
||||
return hosts.map((h) => ({
|
||||
id: h.slug, // slug doubles as the stable id in standalone mode
|
||||
kind: h.kind,
|
||||
slug: h.slug,
|
||||
displayName: h.displayName,
|
||||
metadata: h.metadata || {},
|
||||
}));
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
// No cache in standalone mode — every call reads from the DB.
|
||||
}
|
||||
|
||||
module.exports = { accessibleHosts, clearCache };
|
||||
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
|
||||
// Mesh subnet addressing math (MULTI_SITE_SPEC.md Appendix A): one octet per
|
||||
// site, 172.24.<idx>.0/16 + 10.<idx>.0.0/16, idx 1-254 (0/255 reserved).
|
||||
// Pure/no I/O so it's cheaply unit-testable apart from routes/mesh.js.
|
||||
|
||||
const MESH_SUBNET_PREFIX = '172.24';
|
||||
const MAX_MESH_INDEX = 254;
|
||||
const MIN_MESH_INDEX = 1;
|
||||
|
||||
function meshCidrFor(meshIndex) {
|
||||
assertValidIndex(meshIndex);
|
||||
return `${MESH_SUBNET_PREFIX}.${meshIndex}.1/24`;
|
||||
}
|
||||
|
||||
function meshAllowedIpsFor(meshIndex) {
|
||||
assertValidIndex(meshIndex);
|
||||
return [`${MESH_SUBNET_PREFIX}.${meshIndex}.0/24`, `10.${meshIndex}.0.0/16`];
|
||||
}
|
||||
|
||||
function assertValidIndex(meshIndex) {
|
||||
if (!Number.isInteger(meshIndex) || meshIndex < MIN_MESH_INDEX || meshIndex > MAX_MESH_INDEX) {
|
||||
throw new Error(`mesh index must be an integer in [${MIN_MESH_INDEX}, ${MAX_MESH_INDEX}], got ${meshIndex}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { meshCidrFor, meshAllowedIpsFor, MESH_SUBNET_PREFIX, MAX_MESH_INDEX, MIN_MESH_INDEX };
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
|
||||
// Single-use, short-lived tokens that let a new theta-gateway register into
|
||||
// this one's WireGuard mesh (POST /api/mesh/register). Same shape as
|
||||
// theta-directory's site join keys: minted by an admin, shown once, GETDEL
|
||||
// (Redis) on use so a token can register exactly one gateway, ever.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { getRedis } = require('../models/index');
|
||||
|
||||
const TTL_SECONDS = 15 * 60;
|
||||
|
||||
const key = (token) => `${conf.redis.prefix}mesh_join_token:${token}`;
|
||||
|
||||
async function mint() {
|
||||
const token = 'mjt_' + crypto.randomBytes(24).toString('base64url');
|
||||
const redis = await getRedis();
|
||||
await redis.set(key(token), '1', { EX: TTL_SECONDS });
|
||||
return { token, expiresInSeconds: TTL_SECONDS };
|
||||
}
|
||||
|
||||
async function consume(token) {
|
||||
if (!token) return false;
|
||||
const redis = await getRedis();
|
||||
return (await redis.getDel(key(token))) === '1';
|
||||
}
|
||||
|
||||
module.exports = { mint, consume };
|
||||
@@ -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,44 @@
|
||||
'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/jump-host',
|
||||
licenseUrl: 'https://github.com/theta42/jump-host/blob/master/LICENSE',
|
||||
// No in-app /docs route here — point at the published docs site.
|
||||
docsUrl: 'https://theta42.github.io/jump-host/',
|
||||
docsExternal: true,
|
||||
// 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: null,
|
||||
// Where "Log Out" lands.
|
||||
logoutRedirect: '/login',
|
||||
// Admin-only "a newer release is available" banner, backed by
|
||||
// GET /api/update-check. Apps without that endpoint set false.
|
||||
updateCheck: false,
|
||||
updateLabel: 'the jump host',
|
||||
|
||||
// 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: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
||||
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
|
||||
{href: '/wireguard', icon: 'fa-solid fa-shield-halved', label: 'WireGuard', groups: ['admin', 'app_jump_admin']},
|
||||
{href: '/mesh', icon: 'fa-solid fa-diagram-project', label: 'Mesh', groups: ['admin', 'app_jump_admin']},
|
||||
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin', 'app_jump_admin']},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
/**
|
||||
* Requests a signed SSH certificate from the SSO Manager's OpenBao/Vault proxy.
|
||||
*
|
||||
* @param {string} publicKey - The jump host's public key (e.g. 'ssh-rsa AAAAB3...')
|
||||
* @param {string} targetUid - The username the cert should be valid for
|
||||
* @returns {Promise<string>} - The signed SSH certificate
|
||||
*/
|
||||
async function getSignedCert(publicKey, targetUid) {
|
||||
const sso = conf.sso || {};
|
||||
const pkiConfig = conf.ssh?.pki || {};
|
||||
|
||||
const vaultRole = pkiConfig.role || 'jump-host-role';
|
||||
const endpoint = `${sso.url}/api/vault/ssh/sign/${vaultRole}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sso.apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
public_key: publicKey,
|
||||
valid_principals: targetUid
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => '');
|
||||
throw new Error(`Failed to sign SSH cert (status ${response.status}): ${errText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.data || !data.data.signed_key) {
|
||||
throw new Error('Vault response missing signed_key');
|
||||
}
|
||||
|
||||
return data.data.signed_key;
|
||||
}
|
||||
|
||||
module.exports = { getSignedCert };
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
// Renders a WireGuard client wg0.conf from a peer + site record.
|
||||
//
|
||||
// The generated config is a standard WireGuard config that works with:
|
||||
// - wg-quick (Linux / macOS)
|
||||
// - the official WireGuard iOS / Android apps (via QR code)
|
||||
// - TunnelBear, WireGuard Windows client, etc.
|
||||
|
||||
/**
|
||||
* Build a client wg0.conf string.
|
||||
*
|
||||
* @param {object} peer - WG peer record from wg_peer model
|
||||
* @param {object} site - Exit node record from wg_site model (may be null)
|
||||
* @param {string} serverPub - Gateway server's own public key
|
||||
* @param {string} serverEndpoint - "host:port" for the gateway
|
||||
* @param {string} dns - Optional DNS server to push to client
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderClientConf({ peer, site, serverPub, serverEndpoint, dns }) {
|
||||
const allowedIPs = site
|
||||
? (site.exitAll ? '0.0.0.0/0, ::/0' : site.subnet || '0.0.0.0/0')
|
||||
: '0.0.0.0/0, ::/0'; // no exit site = full tunnel
|
||||
|
||||
const lines = [
|
||||
'[Interface]',
|
||||
`PrivateKey = ${peer.privateKey}`,
|
||||
`Address = ${peer.assignedIP}/32`,
|
||||
];
|
||||
|
||||
if (dns) lines.push(`DNS = ${dns}`);
|
||||
|
||||
lines.push('');
|
||||
lines.push('[Peer]');
|
||||
lines.push(`PublicKey = ${serverPub}`);
|
||||
|
||||
// If client selected an exit site, add preshared key routing hint via
|
||||
// AllowedIPs. Site gateways are peers-of-peers; the server handles routing.
|
||||
if (site) {
|
||||
lines.push(`AllowedIPs = ${allowedIPs}`);
|
||||
} else {
|
||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0');
|
||||
}
|
||||
|
||||
lines.push(`Endpoint = ${serverEndpoint}`);
|
||||
lines.push('PersistentKeepalive = 25');
|
||||
|
||||
if (peer.note) {
|
||||
lines.unshift(`# ${peer.note}`);
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal server-side [Peer] block for wg0.conf (for reference/export).
|
||||
*/
|
||||
function renderServerPeerBlock(peer) {
|
||||
return [
|
||||
`# ${peer.name || peer.id}`,
|
||||
'[Peer]',
|
||||
`PublicKey = ${peer.publicKey}`,
|
||||
`AllowedIPs = ${peer.assignedIP}/32`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = { renderClientConf, renderServerPeerBlock };
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
// Bring up a local WireGuard interface, preferring the in-kernel
|
||||
// implementation and falling back to the userspace `wireguard-go` reference
|
||||
// implementation when the kernel module isn't available (older/hardened
|
||||
// kernels, some container/cloud images, non-Linux). Both paths end with an
|
||||
// identically-named network interface that `wg`/`ip` commands (and the rest
|
||||
// of this module) treat the same way -- callers never need to know which
|
||||
// mode ended up in use.
|
||||
|
||||
const { execFileSync, spawn } = require('child_process');
|
||||
|
||||
const probes = new Map(); // name -> { mode, process (userspace only) }
|
||||
|
||||
function run(cmd, args) {
|
||||
return execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
||||
}
|
||||
|
||||
function tryRun(cmd, args) {
|
||||
try { return { ok: true, out: run(cmd, args) }; }
|
||||
catch (e) { return { ok: false, err: (e.stderr || e.message || '').toString() }; }
|
||||
}
|
||||
|
||||
// One-time, cheap probe: can this kernel create a wireguard-type link at
|
||||
// all? Uses a throwaway interface name so it never collides with a real one.
|
||||
let kernelSupport = null;
|
||||
function kernelWireguardAvailable() {
|
||||
if (kernelSupport !== null) return kernelSupport;
|
||||
const probeName = 'wgprobe' + process.pid;
|
||||
const add = tryRun('ip', ['link', 'add', 'dev', probeName, 'type', 'wireguard']);
|
||||
if (add.ok) {
|
||||
tryRun('ip', ['link', 'del', 'dev', probeName]);
|
||||
kernelSupport = true;
|
||||
} else {
|
||||
kernelSupport = false;
|
||||
}
|
||||
return kernelSupport;
|
||||
}
|
||||
|
||||
function interfaceExists(name) {
|
||||
return tryRun('ip', ['link', 'show', 'dev', name]).ok;
|
||||
}
|
||||
|
||||
async function waitForInterface(name, timeoutMs = 5000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (interfaceExists(name)) return true;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Idempotent: calling this again for an interface that's already up (kernel
|
||||
// or userspace) is a no-op, not an error.
|
||||
async function ensureInterface(name) {
|
||||
if (interfaceExists(name)) {
|
||||
return { mode: probes.get(name) ? probes.get(name).mode : 'kernel' };
|
||||
}
|
||||
|
||||
if (kernelWireguardAvailable()) {
|
||||
const add = tryRun('ip', ['link', 'add', 'dev', name, 'type', 'wireguard']);
|
||||
if (!add.ok && !/File exists/.test(add.err)) {
|
||||
throw new Error(`kernel WireGuard interface creation failed: ${add.err}`);
|
||||
}
|
||||
probes.set(name, { mode: 'kernel' });
|
||||
console.log(`[wg_iface] ${name}: using in-kernel WireGuard`);
|
||||
return { mode: 'kernel' };
|
||||
}
|
||||
|
||||
// Userspace fallback: wireguard-go daemonizes and creates the TUN device
|
||||
// itself; we just wait for it to appear rather than assuming a fixed delay.
|
||||
console.log(`[wg_iface] ${name}: kernel WireGuard unavailable, falling back to wireguard-go (userspace)`);
|
||||
const child = spawn('wireguard-go', [name], { detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
const up = await waitForInterface(name);
|
||||
if (!up) throw new Error(`wireguard-go did not bring up interface '${name}' within timeout`);
|
||||
probes.set(name, { mode: 'userspace', pid: child.pid });
|
||||
return { mode: 'userspace', pid: child.pid };
|
||||
}
|
||||
|
||||
function setPrivateKey(name, privateKeyBase64, listenPort) {
|
||||
// `wg setconf` reads the private key from a file, not argv (argv would leak
|
||||
// it via /proc/<pid>/cmdline to anyone on the host). Pipe it through stdin
|
||||
// via a temp file instead -- see setPeer's note on the same tradeoff.
|
||||
// ListenPort matters: without one, WG binds an ephemeral port, which is
|
||||
// fine for a purely outbound roaming client but useless for a gateway
|
||||
// another gateway needs to dial back into as an Endpoint.
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const tmp = path.join(os.tmpdir(), `wg-${name}-${Date.now()}.conf`);
|
||||
const lines = ['[Interface]', `PrivateKey = ${privateKeyBase64}`];
|
||||
if (listenPort) lines.push(`ListenPort = ${listenPort}`);
|
||||
fs.writeFileSync(tmp, lines.join('\n') + '\n', { mode: 0o600 });
|
||||
try {
|
||||
run('wg', ['setconf', name, tmp]);
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
function setAddress(name, cidr) {
|
||||
// Flush first so re-applying (e.g. after a mesh index reassignment, which
|
||||
// shouldn't normally happen but must not silently stack addresses if it
|
||||
// does) leaves exactly one address, not an accumulating list.
|
||||
tryRun('ip', ['addr', 'flush', 'dev', name]);
|
||||
run('ip', ['addr', 'add', cidr, 'dev', name]);
|
||||
run('ip', ['link', 'set', 'up', 'dev', name]);
|
||||
}
|
||||
|
||||
// Apply (or update) one peer. Safe to call repeatedly for the same peer --
|
||||
// `wg set ... peer <pub>` upserts.
|
||||
//
|
||||
// `wg set ... allowed-ips` ONLY configures WireGuard's own crypto-routing
|
||||
// table (which packets get encrypted/decrypted for this peer) -- it does
|
||||
// NOT add a kernel route for that destination. wg-quick does that as a
|
||||
// separate step; since we drive `wg`/`ip` directly (no wg-quick), we have to
|
||||
// add it ourselves or the tunnel handshakes fine but nothing ever actually
|
||||
// routes through it (confirmed the hard way: a real encrypted handshake
|
||||
// completed between two containers with zero kernel route present, and
|
||||
// ping still showed 100% loss).
|
||||
function setPeer(name, { publicKey, endpoint, allowedIPs, keepalive }) {
|
||||
const ips = allowedIPs || [];
|
||||
const args = ['set', name, 'peer', publicKey, 'allowed-ips', ips.join(',')];
|
||||
if (endpoint) args.push('endpoint', endpoint);
|
||||
if (keepalive) args.push('persistent-keepalive', String(keepalive));
|
||||
run('wg', args);
|
||||
|
||||
for (const cidr of ips) {
|
||||
const add = tryRun('ip', ['route', 'add', cidr, 'dev', name]);
|
||||
// "File exists" happens when the interface's own /24 already covers
|
||||
// this range (added automatically by `ip addr add`) -- fine, not an
|
||||
// error. Anything else should surface.
|
||||
if (!add.ok && !/File exists/.test(add.err)) {
|
||||
throw new Error(`failed to add kernel route ${cidr} via ${name}: ${add.err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removes the peer AND the kernel routes setPeer() added for its
|
||||
// AllowedIPs -- query them BEFORE removing the peer (once gone, `wg` no
|
||||
// longer knows what to clean up, and nothing else tracks these routes,
|
||||
// since they were added by us directly, not by wg-quick).
|
||||
//
|
||||
// Safe to assume none of a peer's AllowedIPs collide with this gateway's
|
||||
// own local address range: mesh indexes are unique per gateway
|
||||
// (models/mesh_gateway.js's nextFreeMeshIndex), so a peer's
|
||||
// 172.24.<peerIndex>.0/24 can never equal our own 172.24.<ownIndex>.0/24.
|
||||
function removePeer(name, publicKey) {
|
||||
const show = tryRun('wg', ['show', name, 'allowed-ips']);
|
||||
let allowedIPs = [];
|
||||
if (show.ok) {
|
||||
const line = show.out.split('\n').find((l) => l.startsWith(publicKey + '\t'));
|
||||
if (line) {
|
||||
allowedIPs = (line.split('\t')[1] || '').split(/\s+/).filter((ip) => ip && ip !== '(none)');
|
||||
}
|
||||
}
|
||||
|
||||
tryRun('wg', ['set', name, 'peer', publicKey, 'remove']);
|
||||
|
||||
for (const cidr of allowedIPs) {
|
||||
tryRun('ip', ['route', 'del', cidr, 'dev', name]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
kernelWireguardAvailable,
|
||||
ensureInterface,
|
||||
setPrivateKey,
|
||||
setAddress,
|
||||
setPeer,
|
||||
removePeer,
|
||||
interfaceExists
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
|
||||
// WireGuard key generation using Node's built-in crypto (X25519).
|
||||
// WireGuard keys ARE X25519 keys in raw base64 — no wg binary needed.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Generate a WireGuard keypair.
|
||||
* @returns {{ privateKey: string, publicKey: string }} — base64 encoded
|
||||
*/
|
||||
function generateKeypair() {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('x25519', {
|
||||
publicKeyEncoding: { type: 'spki', format: 'der' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
||||
});
|
||||
|
||||
// DER-encoded PKCS#8 private key: raw 32-byte X25519 scalar starts at offset 16
|
||||
const rawPriv = privateKey.slice(16, 48);
|
||||
// DER-encoded SPKI public key: raw 32-byte point starts at offset 12
|
||||
const rawPub = publicKey.slice(12, 44);
|
||||
|
||||
return {
|
||||
privateKey: rawPriv.toString('base64'),
|
||||
publicKey: rawPub.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { generateKeypair };
|
||||
+66
-2
@@ -1,5 +1,46 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
<script type="text/javascript">app.auth.forceLogin(['admin', 'app_jump_admin']);</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-active">–</div>
|
||||
<div class="text-muted small text-uppercase">Active sessions</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-total">–</div>
|
||||
<div class="text-muted small text-uppercase">Total connections</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6 text-danger" id="stat-fail">–</div>
|
||||
<div class="text-muted small text-uppercase">Failed</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-users">–</div>
|
||||
<div class="text-muted small text-uppercase">Users seen</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
||||
@@ -29,8 +70,28 @@
|
||||
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function rows(sel, list){
|
||||
var $b = $(sel).empty();
|
||||
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
||||
list.forEach(function(x){
|
||||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||||
});
|
||||
}
|
||||
function loadMetrics(){
|
||||
app.jump.metrics(function(error, data){
|
||||
if(error || !data) return;
|
||||
$('#stat-active').text(data.active);
|
||||
$('#stat-total').text(data.total);
|
||||
$('#stat-fail').text(data.fail);
|
||||
$('#stat-users').text((data.topUsers || []).length);
|
||||
rows('#top-hosts', data.topHosts);
|
||||
rows('#top-users', data.topUsers);
|
||||
});
|
||||
}
|
||||
|
||||
var page = 0;
|
||||
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
|
||||
function applyFilters(){ page = 0; load(); }
|
||||
@@ -57,6 +118,9 @@
|
||||
$('#next').prop('disabled', (page + 1) * size >= total);
|
||||
});
|
||||
}
|
||||
$(document).ready(load);
|
||||
$(document).ready(function(){
|
||||
loadMetrics();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
<%- include('bottom') %>
|
||||
|
||||
+27
-21
@@ -1,24 +1,30 @@
|
||||
</div>
|
||||
</div><!-- end spa-shell -->
|
||||
|
||||
<footer class="py-2 bg-dark text-light mt-4">
|
||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<a href="https://theta42.com" target="_blank">
|
||||
<img width="64" src="/static/img/theta42.svg"/>
|
||||
</a>
|
||||
© <%- (new Date()).getFullYear() %> theta42 ·
|
||||
<a href="https://github.com/theta42/jump-host/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-3">
|
||||
<a href="https://theta42.github.io/jump-host/" target="_blank" class="text-light text-decoration-none">
|
||||
<i class="fa-solid fa-book"></i> Docs
|
||||
</a>
|
||||
<a href="https://github.com/theta42/jump-host" target="_blank" class="text-light text-decoration-none">
|
||||
<i class="fa-brands fa-github"></i> GitHub
|
||||
</a>
|
||||
</span>
|
||||
<span>v<%- version %> (<%- commit %>)</span>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||
app.locals in app.js). Edit all three copies together. -->
|
||||
<footer class="py-2 bg-dark text-light mt-4">
|
||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<a href="https://theta42.com" target="_blank">
|
||||
<img width="64" src="/static/img/theta42.svg"/>
|
||||
</a>
|
||||
© <%- buildYear %> theta42 ·
|
||||
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-3">
|
||||
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||
<i class="fa-solid fa-book"></i> Docs
|
||||
</a>
|
||||
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||
<i class="fa-brands fa-github"></i> GitHub
|
||||
</a>
|
||||
<% if(ui.tosUrl){ %>
|
||||
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
|
||||
<% } %>
|
||||
</span>
|
||||
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+262
-44
@@ -1,64 +1,282 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-active">–</div>
|
||||
<div class="text-muted small text-uppercase">Active sessions</div>
|
||||
</div></div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="fa-solid fa-terminal me-1"></i> Quick Jump</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-2">
|
||||
Skip the picker: <code>ssh <your-username>_-_<host-slug>@<this-jump-host></code>
|
||||
connects straight to a host. Or just <code>ssh <your-username>@<this-jump-host></code>
|
||||
for the interactive picker.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="quick-jump-cmd" readonly>
|
||||
<button class="btn btn-outline-secondary" onclick="copyFieldValue('#quick-jump-cmd')" title="Copy">
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-total">–</div>
|
||||
<div class="text-muted small text-uppercase">Total connections</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6 text-danger" id="stat-fail">–</div>
|
||||
<div class="text-muted small text-uppercase">Failed</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
<div class="display-6" id="stat-users">–</div>
|
||||
<div class="text-muted small text-uppercase">Users seen</div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="fa-solid fa-network-wired me-1"></i> <span id="my-hosts-title">Hosts you can reach</span></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Host</th><th>Slug</th><th class="text-end">Address</th><th>Last connection</th><th>Last failed connection</th><th></th></tr></thead>
|
||||
<tbody id="my-hosts"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
||||
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<p class="text-muted small px-3 pt-3 mb-0">
|
||||
Personal access tokens authenticate as you against this jump host's own API
|
||||
(e.g. <code>GET /api/user/hosts</code>) — not for SSH login. A token carries
|
||||
no group claims, so it can't reach admin-only endpoints.
|
||||
</p>
|
||||
<div class="card-body">
|
||||
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
|
||||
<div id="api-tokens">
|
||||
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow-sm mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="fa-solid fa-key"></i> {{name}}</h6>
|
||||
<small class="text-muted font-monospace">{{id_short}}</small>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
{{#description}}<p>{{description}}</p>{{/description}}
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-3">Token ID</dt>
|
||||
<dd class="col-sm-9"><code>{{id_short}}</code></dd>
|
||||
<dt class="col-sm-3">Created</dt>
|
||||
<dd class="col-sm-9">{{{created_display}}}</dd>
|
||||
<dt class="col-sm-3">Last used</dt>
|
||||
<dd class="col-sm-9">{{{last_used_display}}}</dd>
|
||||
<dt class="col-sm-3">Expires</dt>
|
||||
<dd class="col-sm-9">{{{expires_display}}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
|
||||
<button type="button" onclick="rotateApiToken('{{id}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
|
||||
<button type="button" onclick="revokeApiToken('{{id}}', 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>
|
||||
|
||||
<script type="text/javascript">
|
||||
function rows(sel, list){
|
||||
var $b = $(sel).empty();
|
||||
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
||||
list.forEach(function(x){
|
||||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||||
// The web UI and the SSH front door share a hostname, just not a port.
|
||||
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
|
||||
function sshCommand(target){
|
||||
var uid = app.auth.user && app.auth.user.username;
|
||||
if(!uid) return '';
|
||||
var portFlag = SSH_PORT === 22 ? '' : ' -p ' + SSH_PORT;
|
||||
return 'ssh ' + uid + (target ? '_-_' + target : '') + '@' + location.hostname + portFlag;
|
||||
}
|
||||
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');
|
||||
});
|
||||
}
|
||||
$(document).ready(function(){
|
||||
app.jump.metrics(function(error, data){
|
||||
if(error || !data) return;
|
||||
$('#stat-active').text(data.active);
|
||||
$('#stat-total').text(data.total);
|
||||
$('#stat-fail').text(data.fail);
|
||||
$('#stat-users').text((data.topUsers || []).length);
|
||||
rows('#top-hosts', data.topHosts);
|
||||
rows('#top-users', data.topUsers);
|
||||
|
||||
function hostRows(sel, hosts){
|
||||
var $b = $(sel).empty();
|
||||
if(!hosts || !hosts.length){ $b.append('<tr><td class="text-muted">No hosts reachable.</td></tr>'); return; }
|
||||
hosts.forEach(function(h){
|
||||
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
|
||||
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
// Green: a session to this host is live right now. Yellow: the most
|
||||
// recent attempt to this host failed (and none is currently live).
|
||||
var rowClass = h.connected ? 'table-success'
|
||||
: (h.lastFailed && (!h.lastConnected || h.lastFailed > h.lastConnected)) ? 'table-warning'
|
||||
: '';
|
||||
$b.append('<tr class="' + rowClass + '"><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
|
||||
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
|
||||
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
|
||||
+ '<td class="small">' + (h.lastConnected ? app.jump.fmtTime(h.lastConnected) : '—') + '</td>'
|
||||
+ '<td class="small">' + (h.lastFailed ? app.jump.fmtTime(h.lastFailed) : '—') + '</td>'
|
||||
+ '<td class="text-end">'
|
||||
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
|
||||
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copyFieldValue(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
|
||||
+ '</td></tr>');
|
||||
});
|
||||
}
|
||||
// expires_at/created_on/last_used_on come back as redis-hash strings for
|
||||
// some fields and real numbers for others depending on the model's field
|
||||
// type -- fmtTime already handles both via moment(ms, 'x').
|
||||
function fmtExpiry(token){
|
||||
var exp = Number(token.expires_at);
|
||||
if(!exp) return '<span class="badge text-bg-secondary">never</span>';
|
||||
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
|
||||
return '<span class="badge text-bg-warning">' + moment(exp).fromNow() + '</span>';
|
||||
}
|
||||
|
||||
var tokensById = {};
|
||||
function processToken(token){
|
||||
tokensById[token.id] = token;
|
||||
token.id_short = token.id.slice(0, 12) + '…';
|
||||
token.expires_display = fmtExpiry(token);
|
||||
token.created_display = app.jump.fmtTime(token.created_on);
|
||||
token.last_used_display = token.last_used_on ? app.jump.fmtTime(token.last_used_on) : 'Never';
|
||||
return token;
|
||||
}
|
||||
|
||||
function loadApiTokens(){
|
||||
app.apiToken.list(function(error, data){
|
||||
var tokens = (!error && data && data.results) || [];
|
||||
$.scope.apiTokenCard.empty();
|
||||
tokens.forEach(function(t){ $.scope.apiTokenCard.push(processToken(t)); });
|
||||
$('#api-tokens-empty').toggle(tokens.length === 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Shared "reveal secret once" display -- also used by proxy/sso-manager-node.
|
||||
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.jump.esc(token) + '">'
|
||||
// Reuses the same copy-to-clipboard helper as the Quick Jump card
|
||||
// above (toast feedback -- FontAwesome replaces <i> icons with
|
||||
// inline <svg>, so a checkmark-flash-the-icon approach silently
|
||||
// no-ops; the toast doesn't have that problem).
|
||||
+ '<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.jump.esc(token) + '</code></p>'
|
||||
});
|
||||
}
|
||||
|
||||
function createApiToken(){
|
||||
var $body = app.modal.open({title: 'New API Token', bodyHtml:
|
||||
'<div class="mb-3">'
|
||||
+ '<label class="form-label">Name</label>'
|
||||
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Description</label>'
|
||||
+ '<input type="text" class="form-control" id="new-token-description" placeholder="optional">'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Expires in (days, blank = never)</label>'
|
||||
+ '<input type="number" class="form-control" id="new-token-days" min="1">'
|
||||
+ '</div>',
|
||||
footer: {buttonsHtml: app.modal.footerButtons({onSave: 'submitApiToken()', saveLabel: 'Create'})},
|
||||
});
|
||||
$body.find('#new-token-name').focus();
|
||||
}
|
||||
|
||||
function submitApiToken(){
|
||||
var name = $('#new-token-name').val().trim();
|
||||
if(!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
|
||||
app.apiToken.add({
|
||||
name: name,
|
||||
description: $('#new-token-description').val(),
|
||||
expires_in_days: $('#new-token-days').val(),
|
||||
}, function(error, data){
|
||||
if(error) return app.messages.action((data && data.message) || 'Failed to create token', app.modal.body(), 'danger');
|
||||
// Deliberately no app.modal.close() here -- app.modal is a
|
||||
// singleton, and close() immediately followed by open() (inside
|
||||
// showToken) in the same tick collides with Bootstrap's
|
||||
// hide-transition guard, so the reveal modal silently never
|
||||
// shows. open() alone already overwrites the (already-visible)
|
||||
// modal's content in place.
|
||||
showToken('API Token Created', data.token);
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
|
||||
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.jump.esc(id) + '">'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Name</label>'
|
||||
+ '<input type="text" class="form-control" id="edit-token-name" value="' + app.jump.esc(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.jump.esc(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.jump.esc(t.created_by || '—') + ' on ' + app.jump.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();
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeApiToken(id, btn){
|
||||
var $card = $(btn).closest('.card');
|
||||
var ok = await app.messages.confirm('Revoke this API token? It stops working immediately.', $card, 'danger');
|
||||
if(!ok) return;
|
||||
app.apiToken.remove(id, function(error, data){
|
||||
if(error) return app.messages.action((data && data.message) || 'Failed to revoke token', $card, 'danger');
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
async function rotateApiToken(id, btn){
|
||||
var $card = $(btn).closest('.card');
|
||||
var ok = await app.messages.confirm('Rotate this API token? The old token stops working immediately.', $card, 'warning');
|
||||
if(!ok) return;
|
||||
app.apiToken.rotate(id, function(error, data){
|
||||
if(error) return app.messages.action((data && data.message) || 'Failed to rotate token', $card, 'danger');
|
||||
showToken('API Token Rotated', data.token);
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(async function(){
|
||||
await app.auth.loadUser();
|
||||
if(app.auth.isAdmin()) $('#my-hosts-title').text('My hosts');
|
||||
$('#quick-jump-cmd').val(sshCommand());
|
||||
app.jump.hosts(function(error, data){
|
||||
if(error) return hostRows('#my-hosts', []);
|
||||
hostRows('#my-hosts', data && data.results);
|
||||
});
|
||||
loadApiTokens();
|
||||
});
|
||||
</script>
|
||||
<%- include('bottom') %>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 text-center">
|
||||
<div class="mb-4">
|
||||
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
|
||||
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
|
||||
<p class="text-muted mb-4">
|
||||
<% if (error.status === 404) { %>
|
||||
The page you are looking for doesn't exist or has been moved.
|
||||
<% } else { %>
|
||||
An unexpected error occurred. Please try again later.
|
||||
<% } %>
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
|
||||
<i class="fa-solid fa-house me-2"></i>Return to Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('bottom') %>
|
||||
+13
-9
@@ -4,14 +4,18 @@
|
||||
// If we arrived from the OIDC callback with a token in the URL fragment,
|
||||
// store it and forward on before doing anything else.
|
||||
if(!app.auth.consumeTokenFragment()){
|
||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||
if(isLoggedIn){
|
||||
app.auth.logInRedirect();
|
||||
}else{
|
||||
// Reveal the login card once we know the user is not logged in.
|
||||
document.getElementById('login-card-row').style.display = '';
|
||||
}
|
||||
})
|
||||
// The reveal below touches an element further down this page, so wait
|
||||
// for the DOM — isLoggedIn can answer before the parser gets there.
|
||||
$(document).ready(function(){
|
||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||
if(isLoggedIn){
|
||||
app.auth.logInRedirect();
|
||||
}else{
|
||||
// Reveal the login card once we know the user is not logged in.
|
||||
document.getElementById('login-card-row').style.display = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -86,7 +90,7 @@
|
||||
<hr />
|
||||
<div class="d-grid">
|
||||
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-id-badge"></i> Log in with SSO
|
||||
<i class="fa-solid fa-id-badge"></i> Log in with Jump
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="container mt-4 mb-5">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h3 class="mb-1"><i class="fa-solid fa-diagram-project text-primary me-2"></i>Gateway Mesh</h3>
|
||||
<p class="text-muted small mb-0">Site-to-site WireGuard tunnels between theta-gateway instances. Different from <a href="/wireguard">WireGuard</a>, which manages individual roaming-client peers and exit nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-dark text-light border-secondary">
|
||||
<i class="fa-solid fa-server me-2"></i>This Gateway
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||
<div class="text-muted small fw-semibold">MESH INTERFACE</div>
|
||||
<div class="font-monospace fw-bold text-primary mt-1" id="mesh-iface">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||
<div class="text-muted small fw-semibold">WIREGUARD MODE</div>
|
||||
<div class="fw-bold mt-1" id="mesh-kernel-mode">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||
<div class="text-muted small fw-semibold">MESHED GATEWAYS</div>
|
||||
<div class="fw-bold mt-1" id="mesh-gateway-count">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header"><i class="fa-solid fa-key me-2"></i>Mint a Join Token</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">Give this to a new gateway so it can join this one's mesh (single-use, expires in 15 minutes). It calls this gateway's <code>/api/mesh/register</code> with it.</p>
|
||||
<button class="btn btn-sm btn-success" onclick="mintMeshJoinToken()"><i class="fa-solid fa-plus me-1"></i> Mint Join Token</button>
|
||||
<div id="mesh-join-token-result" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header"><i class="fa-solid fa-right-to-bracket me-2"></i>Join a Remote Gateway's Mesh</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">Have a join token from another gateway? Use it here to mesh THIS gateway into that one.</p>
|
||||
<div class="mb-2">
|
||||
<input type="text" id="mesh-remote-endpoint" class="form-control form-control-sm" placeholder="Remote gateway URL (e.g. https://jump.master.example.com)">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" id="mesh-remote-token" class="form-control form-control-sm font-monospace" placeholder="Join token (mjt_...)">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" onclick="joinRemoteMesh()"><i class="fa-solid fa-link me-1"></i> Join</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header"><i class="fa-solid fa-network-wired me-2"></i>Meshed Gateways</div>
|
||||
<div class="card-body p-0" id="mesh-gateways-wrap">
|
||||
<div class="text-center py-4 text-muted">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('bottom') %>
|
||||
<script type="text/javascript">
|
||||
function esc(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
loadMeshStatus();
|
||||
});
|
||||
|
||||
function loadMeshStatus() {
|
||||
app.api.get('mesh/gateways', function(err, data){
|
||||
if (err || !data) {
|
||||
$('#mesh-iface').text('(unavailable)');
|
||||
$('#mesh-kernel-mode').text('(unavailable)');
|
||||
$('#mesh-gateway-count').text('—');
|
||||
$('#mesh-gateways-wrap').html('<div class="text-center py-4 text-danger">Could not load mesh status: ' + esc((err && err.message) || 'unknown error') + '</div>');
|
||||
return;
|
||||
}
|
||||
$('#mesh-iface').text(data.iface || 'wg-mesh');
|
||||
$('#mesh-kernel-mode').html(data.kernelWireguard
|
||||
? '<span class="badge bg-success"><i class="fa-solid fa-microchip me-1"></i> In-kernel</span>'
|
||||
: '<span class="badge bg-warning text-dark"><i class="fa-solid fa-layer-group me-1"></i> Userspace (wireguard-go)</span>');
|
||||
var gateways = data.gateways || [];
|
||||
$('#mesh-gateway-count').text(gateways.length + ' gateway' + (gateways.length === 1 ? '' : 's'));
|
||||
renderGatewaysTable(gateways);
|
||||
});
|
||||
}
|
||||
|
||||
function renderGatewaysTable(gateways) {
|
||||
var $w = $('#mesh-gateways-wrap');
|
||||
if (!gateways.length) {
|
||||
$w.html('<div class="text-center py-4 text-muted"><i class="fa-solid fa-diagram-project me-2"></i>No meshed gateways yet.<br><small>Mint a join token above and have another gateway join, or join this one into a remote gateway\'s mesh.</small></div>');
|
||||
return;
|
||||
}
|
||||
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
|
||||
+ '<th>Site</th><th>Mesh Index</th><th>Mesh Address</th><th>Endpoint</th><th>Public Key</th><th>Last Seen</th><th class="text-end">Actions</th>'
|
||||
+ '</tr></thead><tbody>';
|
||||
gateways.forEach(function(g){
|
||||
var isSelf = g.siteSlug === '(self)';
|
||||
html += '<tr' + (isSelf ? ' class="table-active"' : '') + '>'
|
||||
+ '<td>' + (isSelf ? '<em>This gateway</em>' : esc(g.siteSlug || '(unlabeled)')) + '</td>'
|
||||
+ '<td><span class="badge bg-primary">' + esc(g.meshIndex) + '</span></td>'
|
||||
+ '<td><code class="small">172.24.' + esc(g.meshIndex) + '.0/24</code></td>'
|
||||
+ '<td><code class="small text-primary">' + esc(g.endpoint || '—') + '</code></td>'
|
||||
+ '<td><code class="small text-truncate d-inline-block" style="max-width:220px;" title="' + esc(g.publicKey) + '">' + esc(g.publicKey) + '</code></td>'
|
||||
+ '<td class="small text-muted">' + (g.lastSeenAt ? new Date(Number(g.lastSeenAt)).toLocaleString() : '—') + '</td>'
|
||||
+ '<td class="text-end">' + (isSelf ? '' :
|
||||
'<button class="btn btn-sm btn-outline-danger" onclick="removeMeshGateway(\'' + esc(g.id) + '\', \'' + esc(g.siteSlug || g.id) + '\')" title="Remove peer"><i class="fa-solid fa-trash"></i></button>')
|
||||
+ '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
$w.html(html);
|
||||
}
|
||||
|
||||
async function removeMeshGateway(id, label) {
|
||||
var ok = await app.messages.confirm('Remove mesh peer "' + label + '"? This tears down the local WireGuard peer + routes. The other side keeps its half until removed there too.', $('#mesh-gateways-wrap'), 'danger');
|
||||
if (!ok) return;
|
||||
app.api.delete('mesh/gateways/' + id, function(err){
|
||||
if (err) return app.messages.toast('Failed to remove gateway: ' + err.message, 'danger');
|
||||
app.messages.toast('Gateway removed', 'success');
|
||||
loadMeshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
function mintMeshJoinToken() {
|
||||
app.api.post('mesh/join-tokens', {}, function(err, data){
|
||||
if (err) return app.messages.toast('Failed to mint join token: ' + err.message, 'danger');
|
||||
$('#mesh-join-token-result').html(
|
||||
'<div class="alert alert-success small mb-0">' +
|
||||
'<strong>Shown once — copy it now:</strong><br>' +
|
||||
'<code class="user-select-all">' + esc(data.token) + '</code>' +
|
||||
'<br><span class="text-muted">Expires in ' + Math.round((data.expiresInSeconds || 0) / 60) + ' minutes.</span>' +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function joinRemoteMesh() {
|
||||
var remoteEndpoint = ($('#mesh-remote-endpoint').val() || '').trim();
|
||||
var joinToken = ($('#mesh-remote-token').val() || '').trim();
|
||||
if (!remoteEndpoint || !joinToken) {
|
||||
return app.messages.toast('Enter the remote gateway URL and a join token', 'warning');
|
||||
}
|
||||
app.api.post('mesh/join', { remoteEndpoint: remoteEndpoint, joinToken: joinToken }, function(err, data){
|
||||
if (err) return app.messages.toast('Join failed: ' + err.message, 'danger');
|
||||
app.messages.toast('Meshed successfully — this gateway is mesh index ' + data.meshIndex + ', peer is index ' + data.peerMeshIndex, 'success');
|
||||
loadMeshStatus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
|
||||
@@ -13,6 +14,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function loadSessions(){
|
||||
|
||||
+105
-24
@@ -4,19 +4,28 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title><%- name %> <%- title %></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
|
||||
via app.locals in app.js). Edit all three copies together. -->
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
|
||||
<!-- CSS are placed here -->
|
||||
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||
|
||||
<link rel='stylesheet' href='/static/css/styles.css' />
|
||||
<!-- Scripts are placed here -->
|
||||
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
||||
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
||||
<script type="text/javascript" src='/static/lib/js/val.js'></script>
|
||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
|
||||
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
|
||||
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
|
||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -28,51 +37,123 @@
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav top-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/dashboard"><i class="fa-solid fa-gauge-high"></i> Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/sessions"><i class="fa-solid fa-plug-circle-bolt"></i> Sessions</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/audit"><i class="fa-solid fa-clipboard-list"></i> Audit</a>
|
||||
<%# Items gated on a group start hidden (.group-required) and are
|
||||
revealed by app-base.js for the groups the user is in. %>
|
||||
<% for(const item of ui.nav){ %>
|
||||
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||
<%- item.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
<div class="form-inline mt-2 mt-md-0">
|
||||
<% if(ui.profileUrl){ %>
|
||||
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</a>
|
||||
<% } else { %>
|
||||
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</span>
|
||||
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" href="/login" style="display: none;">
|
||||
<i class="fas fa-sign-in"></i> Login
|
||||
<% } %>
|
||||
<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-in"></i>
|
||||
Login
|
||||
</a>
|
||||
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href='/login'; })" style="display: none;">
|
||||
<i class="fas fa-sign-out"></i> Log Out
|
||||
|
||||
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
|
||||
<i class="fas fa-sign-out"></i>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<% if(ui.updateCheck){ %>
|
||||
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
||||
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
||||
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
|
||||
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
|
||||
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
|
||||
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
|
||||
<span id="update-banner-text"></span>
|
||||
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
// --sw-content-offset tracks the same height as #spa-shell's margin-top
|
||||
// (fixed navbar, plus the update banner while it's shown), so any
|
||||
// in-page sticky element (e.g. a sticky search/sort bar) can offset
|
||||
// itself below both fixed elements via `top: var(--sw-content-offset)`
|
||||
// instead of colliding with them at the viewport's true top:0.
|
||||
function showUpdateBanner(){
|
||||
let $nav = $('nav.fixed-top');
|
||||
let $banner = $('#update-banner');
|
||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||
let offset = $nav.outerHeight() + $banner.outerHeight();
|
||||
$('#spa-shell').css('margin-top', offset + 'px');
|
||||
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
|
||||
}
|
||||
|
||||
function dismissUpdateBanner(){
|
||||
$('#update-banner').hide();
|
||||
$('#spa-shell').css('margin-top', '');
|
||||
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||
}
|
||||
|
||||
function checkForUpdate(){
|
||||
if(sessionStorage.getItem('update-banner-dismissed')) return;
|
||||
app.api.get('update-check', function(error, info){
|
||||
if(error || !info || !info.updateAvailable) return;
|
||||
$('#update-banner-text').html(
|
||||
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
|
||||
'(running v' + info.currentVersion + ') — ' +
|
||||
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
||||
);
|
||||
showUpdateBanner();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function(){
|
||||
$('.top-nav a').each(function(){
|
||||
var $this = $(this);
|
||||
|
||||
// Set the correct link to active in the top nav bar
|
||||
$('.top-nav a').each(function(index){
|
||||
let $this = $(this);
|
||||
$this.removeClass('active');
|
||||
if($this.attr('href').toLowerCase() === window.location.pathname.toLowerCase()){
|
||||
$this.addClass('active');
|
||||
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
||||
$this.addClass('active')
|
||||
}
|
||||
});
|
||||
app.auth.isLoggedIn(function(error, data){
|
||||
if(data){
|
||||
})
|
||||
|
||||
// Set the correct login/logout button, and reveal the current user's
|
||||
// name once we know who they are. Group-gated nav items are revealed
|
||||
// by app-base.js off the same cached user/me.
|
||||
app.auth.isLoggedIn(function(error, me){
|
||||
if(me){
|
||||
$('#cl-logout-button').show();
|
||||
if(data.username){
|
||||
$('#cl-username-text').text(data.username);
|
||||
let username = me.uid || me.username;
|
||||
if(username){
|
||||
$('#cl-username-text').text(username);
|
||||
$('#cl-username').css('display', '');
|
||||
}
|
||||
|
||||
<% if(ui.updateCheck){ %>
|
||||
if(me.isAdmin) checkForUpdate();
|
||||
<% } %>
|
||||
}else{
|
||||
$('#cl-login-button').show();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="spa-shell" class="container-fluid" style="margin-top: 4.5rem;">
|
||||
|
||||
<!-- Container -->
|
||||
<div id="spa-shell" class="container-fluid">
|
||||
<div class="actionMessage" style="display:none;"></div>
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="container mt-4 mb-5">
|
||||
<!-- Page Header -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h3 class="mb-1"><i class="fa-solid fa-shield-halved text-primary me-2"></i>WireGuard Gateway</h3>
|
||||
<p class="text-muted small mb-0">Manage VPN exit nodes, client peer profiles, QR codes, and routing configurations.</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary btn-sm me-2" onclick="openAddSiteModal()"><i class="fa-solid fa-plus me-1"></i> Add Exit Node</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="openAddPeerModal()"><i class="fa-solid fa-user-plus me-1"></i> Add Client Peer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Gateway Info Card -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-dark text-light border-secondary">
|
||||
<i class="fa-solid fa-server me-2"></i>Gateway Status & Base Configuration
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||
<div class="text-muted small fw-semibold">ENDPOINT ADDRESS</div>
|
||||
<div class="font-monospace fw-bold text-primary mt-1" id="gw-endpoint">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||
<div class="text-muted small fw-semibold">SERVER PUBLIC KEY</div>
|
||||
<div class="font-monospace text-truncate mt-1" id="gw-pubkey" style="max-width: 100%;">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||
<div class="text-muted small fw-semibold">DNS SERVERS</div>
|
||||
<div class="font-monospace mt-1" id="gw-dns">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Peers Table Card -->
|
||||
<div class="card shadow-sm mb-4" id="peers-card">
|
||||
<div class="card-header bg-dark text-light border-secondary d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-laptop me-2"></i>Client Devices & Peer Profiles</span>
|
||||
<button class="btn btn-sm btn-outline-light" onclick="loadPeers()"><i class="fa-solid fa-rotate me-1"></i> Refresh</button>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div id="peers-table-wrap" class="table-responsive">
|
||||
<div class="text-center py-4 text-muted"><i class="fa-solid fa-spinner fa-spin me-2"></i>Loading peers...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exit Nodes Table Card -->
|
||||
<div class="card shadow-sm mb-4" id="sites-card">
|
||||
<div class="card-header bg-dark text-light border-secondary d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-globe me-2"></i>Site Exit Nodes (Home / Remote Subnets)</span>
|
||||
<button class="btn btn-sm btn-outline-light" onclick="loadSites()"><i class="fa-solid fa-rotate me-1"></i> Refresh</button>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div id="sites-table-wrap" class="table-responsive">
|
||||
<div class="text-center py-4 text-muted"><i class="fa-solid fa-spinner fa-spin me-2"></i>Loading exit nodes...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Add / Edit Exit Node -->
|
||||
<div class="modal fade" id="wg-site-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="wg-site-modal-title"><i class="fa-solid fa-globe me-2"></i>Add Exit Node</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="site-edit-id">
|
||||
<div id="site-modal-msg" style="display:none;" class="alert mb-3"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Node / Site Name</label>
|
||||
<input type="text" class="form-control" id="site-name" placeholder="e.g. 718it-home-gateway">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Endpoint IP / Hostname & Port</label>
|
||||
<input type="text" class="form-control font-monospace" id="site-endpoint" placeholder="e.g. 203.0.113.5:51820">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">WireGuard Public Key</label>
|
||||
<input type="text" class="form-control font-monospace" id="site-pubkey" placeholder="Base64 public key">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Routable Subnet (CIDR)</label>
|
||||
<input type="text" class="form-control font-monospace" id="site-subnet" placeholder="e.g. 192.168.1.0/24 or 0.0.0.0/0">
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="site-exitall">
|
||||
<label class="form-check-label small" for="site-exitall">Default Exit Node for Full Internet Traffic</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Optional Notes</label>
|
||||
<input type="text" class="form-control" id="site-note" placeholder="Site location, router hardware, etc.">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitSite()"><span id="site-submit-label">Save Exit Node</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Add Client Peer -->
|
||||
<div class="modal fade" id="wg-peer-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-user-plus me-2"></i>Create Client Peer Profile</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="peer-modal-msg" style="display:none;" class="alert mb-3"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Device / Client Name</label>
|
||||
<input type="text" class="form-control" id="peer-name" placeholder="e.g. william-phone or laptop-work">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Default Exit Routing</label>
|
||||
<select class="form-select" id="peer-exit"></select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Notes</label>
|
||||
<input type="text" class="form-control" id="peer-note" placeholder="Device description">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitPeer()">Create Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Edit Peer -->
|
||||
<div class="modal fade" id="wg-peer-edit-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-pen me-2"></i>Edit Client Peer</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="peer-edit-id">
|
||||
<div id="peer-edit-msg" style="display:none;" class="alert mb-3"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Device / Client Name</label>
|
||||
<input type="text" class="form-control" id="peer-edit-name">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Exit Node Routing</label>
|
||||
<select class="form-select" id="peer-edit-exit"></select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold small">Notes</label>
|
||||
<input type="text" class="form-control" id="peer-edit-note">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="savePeerEdit()">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: QR Code Preview -->
|
||||
<div class="modal fade" id="wg-qr-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content text-center">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="wg-qr-title"><i class="fa-solid fa-qrcode me-2"></i>WireGuard QR Code</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body py-4">
|
||||
<div id="wg-qr-loading" class="py-4 text-muted"><i class="fa-solid fa-spinner fa-spin me-2"></i>Generating QR Code...</div>
|
||||
<img id="wg-qr-img" src="" class="img-fluid rounded border p-2 bg-white shadow-sm" style="display:none; max-width: 260px;" alt="QR Code">
|
||||
<p class="text-muted small mt-3 mb-0">Scan with the mobile WireGuard app or download the configuration file below.</p>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-primary" id="wg-download-conf-btn"><i class="fa-solid fa-download me-1"></i> Download .conf</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var wgSites = [];
|
||||
var wgPeers = [];
|
||||
var siteModal, peerModal, peerEditModal, qrModal;
|
||||
|
||||
$(document).ready(function(){
|
||||
siteModal = new bootstrap.Modal(document.getElementById('wg-site-modal'));
|
||||
peerModal = new bootstrap.Modal(document.getElementById('wg-peer-modal'));
|
||||
peerEditModal = new bootstrap.Modal(document.getElementById('wg-peer-edit-modal'));
|
||||
qrModal = new bootstrap.Modal(document.getElementById('wg-qr-modal'));
|
||||
loadAll();
|
||||
});
|
||||
|
||||
function esc(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function loadAll() {
|
||||
app.api.get('wireguard/gateway-info', function(err, info){
|
||||
if(!err && info) {
|
||||
$('#gw-endpoint').text(info.endpoint || '(not configured)');
|
||||
$('#gw-pubkey').text(info.publicKey || '(not configured)');
|
||||
$('#gw-dns').text(info.dns || '(not configured)');
|
||||
}
|
||||
});
|
||||
loadSites(function(){ loadPeers(); });
|
||||
}
|
||||
|
||||
function loadSites(cb) {
|
||||
app.api.get('wireguard/sites', function(err, data){
|
||||
wgSites = (!err && data && data.results) || [];
|
||||
renderSitesTable();
|
||||
if (cb) cb();
|
||||
});
|
||||
}
|
||||
|
||||
function loadPeers() {
|
||||
app.api.get('wireguard/peers', function(err, data){
|
||||
wgPeers = (!err && data && data.results) || [];
|
||||
renderPeersTable();
|
||||
});
|
||||
}
|
||||
|
||||
function renderSitesTable() {
|
||||
var $w = $('#sites-table-wrap');
|
||||
if (!wgSites.length) {
|
||||
$w.html('<div class="text-center py-4 text-muted"><i class="fa-solid fa-globe me-2"></i>No exit nodes configured.<br><small>Add a site to give clients a routable exit point.</small></div>');
|
||||
return;
|
||||
}
|
||||
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
|
||||
+ '<th>Name</th><th>Endpoint</th><th>Subnet</th><th class="text-end">Actions</th>'
|
||||
+ '</tr></thead><tbody>';
|
||||
wgSites.forEach(function(s){
|
||||
var exitBadge = s.exitAll
|
||||
? '<span class="badge bg-success ms-2">Full Exit</span>'
|
||||
: '<span class="badge bg-info ms-2">Split</span>';
|
||||
html += '<tr>'
|
||||
+ '<td><strong class="text-dark dark-text-light">' + esc(s.name) + '</strong><br><span class="text-muted small">' + (s.siteId ? 'Site ' + esc(s.siteId) : '') + '</span></td>'
|
||||
+ '<td><code class="small text-primary">' + esc(s.endpoint) + '</code></td>'
|
||||
+ '<td><code class="small">' + esc(s.subnet || '—') + '</code>' + exitBadge + '</td>'
|
||||
+ '<td class="text-end">'
|
||||
+ '<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditSiteModal(\'' + esc(s.id) + '\')" title="Edit"><i class="fa-solid fa-pen"></i></button>'
|
||||
+ '<button class="btn btn-sm btn-outline-danger" onclick="deleteSite(\'' + esc(s.id) + '\')" title="Remove"><i class="fa-solid fa-trash"></i></button>'
|
||||
+ '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
$w.html(html);
|
||||
}
|
||||
|
||||
function renderPeersTable() {
|
||||
var $w = $('#peers-table-wrap');
|
||||
if (!wgPeers.length) {
|
||||
$w.html('<div class="text-center py-4 text-muted"><i class="fa-solid fa-laptop me-2"></i>No client peers created yet.<br><small>Create a profile to generate a WireGuard QR code or .conf file.</small></div>');
|
||||
return;
|
||||
}
|
||||
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
|
||||
+ '<th>Device / Peer Name</th><th>Assigned IP</th><th>Exit Node Routing</th><th>Public Key</th><th class="text-end">Actions</th>'
|
||||
+ '</tr></thead><tbody>';
|
||||
wgPeers.forEach(function(p){
|
||||
html += '<tr>'
|
||||
+ '<td><strong class="text-dark dark-text-light">' + esc(p.name) + '</strong>' + (p.note ? '<br><span class="text-muted small">' + esc(p.note) + '</span>' : '') + '</td>'
|
||||
+ '<td><span class="badge bg-primary font-monospace">' + esc(p.assignedIP || 'Auto') + '</span></td>'
|
||||
+ '<td>' + siteLabel(p.exitSiteId) + '</td>'
|
||||
+ '<td><code class="small text-muted" title="' + esc(p.publicKey) + '">' + esc(p.publicKey.slice(0, 16)) + '...</code></td>'
|
||||
+ '<td class="text-end">'
|
||||
+ '<button class="btn btn-sm btn-outline-success me-1" onclick="showQr(\'' + esc(p.id) + '\',\'' + esc(p.name) + '\')" title="QR Code"><i class="fa-solid fa-qrcode"></i></button>'
|
||||
+ '<button class="btn btn-sm btn-outline-info me-1" onclick="downloadConf(\'' + esc(p.id) + '\')" title="Download .conf"><i class="fa-solid fa-download"></i></button>'
|
||||
+ '<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditPeerModal(\'' + esc(p.id) + '\')" title="Edit"><i class="fa-solid fa-pen"></i></button>'
|
||||
+ '<button class="btn btn-sm btn-outline-danger" onclick="deletePeer(\'' + esc(p.id) + '\')" title="Remove"><i class="fa-solid fa-trash"></i></button>'
|
||||
+ '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
$w.html(html);
|
||||
}
|
||||
|
||||
function siteLabel(exitSiteId) {
|
||||
if (!exitSiteId) return '<span class="badge bg-success">Full Tunnel (Gateway)</span>';
|
||||
var site = wgSites.find(function(s){ return s.id === exitSiteId; });
|
||||
return site
|
||||
? '<span class="badge bg-purple text-light" style="background:#7c3aed;"><i class="fa-solid fa-location-dot me-1"></i>' + esc(site.name) + '</span>'
|
||||
: '<span class="badge bg-secondary">Unknown</span>';
|
||||
}
|
||||
|
||||
function populateExitSelect(selectId, selectedId) {
|
||||
var $sel = $('#' + selectId).empty();
|
||||
$sel.append('<option value="">— Full tunnel via gateway (default) —</option>');
|
||||
wgSites.forEach(function(s){
|
||||
var opt = $('<option>').val(s.id).text(s.name + (s.endpoint ? ' (' + s.endpoint + ')' : ''));
|
||||
if (s.id === selectedId) opt.prop('selected', true);
|
||||
$sel.append(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function openAddSiteModal() {
|
||||
$('#site-edit-id').val('');
|
||||
$('#site-name, #site-endpoint, #site-pubkey, #site-subnet, #site-note').val('');
|
||||
$('#site-exitall').prop('checked', false);
|
||||
$('#wg-site-modal-title').html('<i class="fa-solid fa-globe me-2"></i>Add Exit Node');
|
||||
$('#site-submit-label').text('Add Exit Node');
|
||||
$('#site-modal-msg').hide();
|
||||
siteModal.show();
|
||||
}
|
||||
|
||||
function openEditSiteModal(id) {
|
||||
var site = wgSites.find(function(s){ return s.id === id; });
|
||||
if (!site) return;
|
||||
$('#site-edit-id').val(site.id);
|
||||
$('#site-name').val(site.name);
|
||||
$('#site-endpoint').val(site.endpoint);
|
||||
$('#site-pubkey').val(site.publicKey);
|
||||
$('#site-subnet').val(site.subnet);
|
||||
$('#site-note').val(site.note);
|
||||
$('#site-exitall').prop('checked', !!site.exitAll);
|
||||
$('#wg-site-modal-title').html('<i class="fa-solid fa-pen me-2"></i>Edit Exit Node');
|
||||
$('#site-submit-label').text('Save Changes');
|
||||
$('#site-modal-msg').hide();
|
||||
siteModal.show();
|
||||
}
|
||||
|
||||
function submitSite() {
|
||||
var id = $('#site-edit-id').val();
|
||||
var payload = {
|
||||
name: $('#site-name').val().trim(),
|
||||
endpoint: $('#site-endpoint').val().trim(),
|
||||
publicKey: $('#site-pubkey').val().trim(),
|
||||
subnet: $('#site-subnet').val().trim() || '0.0.0.0/0',
|
||||
note: $('#site-note').val().trim(),
|
||||
exitAll: $('#site-exitall').is(':checked'),
|
||||
};
|
||||
if (!payload.name || !payload.endpoint || !payload.publicKey) {
|
||||
return showMsg('#site-modal-msg', 'Name, endpoint, and public key are required.', 'danger');
|
||||
}
|
||||
var path = id ? 'wireguard/sites/' + id : 'wireguard/sites';
|
||||
var action = id ? app.api.patch : app.api.post;
|
||||
action(path, payload, function(err){
|
||||
if (err) return showMsg('#site-modal-msg', err.message || err, 'danger');
|
||||
siteModal.hide();
|
||||
loadSites(function(){ loadPeers(); });
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteSite(id) {
|
||||
var site = wgSites.find(function(s){ return s.id === id; });
|
||||
if (!site) return;
|
||||
var ok = await app.messages.confirm('Remove exit node "' + site.name + '"? Any peers using it will fall back to full tunnel.', $('#sites-card'), 'danger');
|
||||
if (!ok) return;
|
||||
app.api.delete('wireguard/sites/' + id, function(err){
|
||||
if (err) return app.messages.action('Error: ' + (err.message || err), $('#sites-card'), 'danger');
|
||||
loadSites(function(){ loadPeers(); });
|
||||
});
|
||||
}
|
||||
|
||||
function openAddPeerModal() {
|
||||
$('#peer-name, #peer-note').val('');
|
||||
populateExitSelect('peer-exit', '');
|
||||
$('#peer-modal-msg').hide();
|
||||
peerModal.show();
|
||||
}
|
||||
|
||||
function submitPeer() {
|
||||
var payload = {
|
||||
name: $('#peer-name').val().trim(),
|
||||
exitSiteId: $('#peer-exit').val(),
|
||||
note: $('#peer-note').val().trim(),
|
||||
};
|
||||
if (!payload.name) return showMsg('#peer-modal-msg', 'Device name is required.', 'danger');
|
||||
app.api.post('wireguard/peers', payload, function(err, peer){
|
||||
if (err) return showMsg('#peer-modal-msg', err.message || err, 'danger');
|
||||
peerModal.hide();
|
||||
loadPeers();
|
||||
if (peer && peer.id) showQr(peer.id, peer.name);
|
||||
});
|
||||
}
|
||||
|
||||
function openEditPeerModal(id) {
|
||||
var peer = wgPeers.find(function(p){ return p.id === id; });
|
||||
if (!peer) return;
|
||||
$('#peer-edit-id').val(peer.id);
|
||||
$('#peer-edit-name').val(peer.name);
|
||||
$('#peer-edit-note').val(peer.note || '');
|
||||
populateExitSelect('peer-edit-exit', peer.exitSiteId);
|
||||
$('#peer-edit-msg').hide();
|
||||
peerEditModal.show();
|
||||
}
|
||||
|
||||
function savePeerEdit() {
|
||||
var id = $('#peer-edit-id').val();
|
||||
var payload = {
|
||||
name: $('#peer-edit-name').val().trim(),
|
||||
exitSiteId: $('#peer-edit-exit').val(),
|
||||
note: $('#peer-edit-note').val().trim(),
|
||||
};
|
||||
app.api.patch('wireguard/peers/' + id, payload, function(err){
|
||||
if (err) return showMsg('#peer-edit-msg', err.message || err, 'danger');
|
||||
peerEditModal.hide();
|
||||
loadPeers();
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePeer(id) {
|
||||
var peer = wgPeers.find(function(p){ return p.id === id; });
|
||||
if (!peer) return;
|
||||
var ok = await app.messages.confirm('Remove peer "' + peer.name + '"? Their VPN access will be revoked immediately.', $('#peers-card'), 'danger');
|
||||
if (!ok) return;
|
||||
app.api.delete('wireguard/peers/' + id, function(err){
|
||||
if (err) return app.messages.action('Error: ' + (err.message || err), $('#peers-card'), 'danger');
|
||||
loadPeers();
|
||||
});
|
||||
}
|
||||
|
||||
function showQr(peerId, peerName) {
|
||||
$('#wg-qr-title').html('<i class="fa-solid fa-qrcode me-2"></i>' + esc(peerName || 'Peer') + ' Profile');
|
||||
$('#wg-qr-img').hide();
|
||||
$('#wg-qr-loading').show();
|
||||
$('#wg-download-conf-btn').off('click').on('click', function(){ downloadConf(peerId); });
|
||||
qrModal.show();
|
||||
app.api.get('wireguard/peers/' + peerId + '/qr', function(err, data){
|
||||
$('#wg-qr-loading').hide();
|
||||
if (err || !data || !data.qr) return;
|
||||
$('#wg-qr-img').attr('src', data.qr).show();
|
||||
});
|
||||
}
|
||||
|
||||
function downloadConf(peerId) {
|
||||
var token = app.auth.getToken ? app.auth.getToken() : '';
|
||||
var url = '/api/wireguard/peers/' + peerId + '/conf' + (token ? '?token=' + encodeURIComponent(token) : '');
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function showMsg(selector, text, type) {
|
||||
$(selector)
|
||||
.removeClass('alert-success alert-danger alert-warning')
|
||||
.addClass('alert alert-' + (type || 'danger'))
|
||||
.text(text)
|
||||
.show();
|
||||
}
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
@@ -11,7 +11,24 @@
|
||||
module.exports = {
|
||||
name: 'My Org',
|
||||
|
||||
// Standalone mode: run with no LDAP directory and no SSO Manager. When
|
||||
// enabled, user auth and host discovery use the ORM-backed stores below
|
||||
// instead of `ldap` + `sso` (both become unused). See the README's
|
||||
// "Standalone mode" section for how to add users/hosts.
|
||||
standalone: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
// ORM config for standalone mode (Sequelize — any dialect works, not just
|
||||
// sqlite). Ignored unless standalone.enabled is true.
|
||||
orm: {
|
||||
dialect: 'sqlite',
|
||||
storage: './data/standalone.sqlite',
|
||||
logging: false,
|
||||
},
|
||||
|
||||
// The directory the users live in (the SSO Manager's OpenLDAP).
|
||||
// Unused when standalone.enabled is true.
|
||||
//
|
||||
// IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on
|
||||
// the sshPublicKey attribute of user entries — the jump host injects its
|
||||
@@ -36,6 +53,7 @@ module.exports = {
|
||||
|
||||
// SSO Manager directory (inventory) API. apiToken is a personal access
|
||||
// token (sso_<id>_<secret>) of any user that can read /api/discovery/*.
|
||||
// Unused when standalone.enabled is true.
|
||||
sso: {
|
||||
url: 'https://sso.example.com',
|
||||
apiToken: 'sso_CHANGE_ME',
|
||||
|
||||
Reference in New Issue
Block a user