Compare commits

...

38 Commits

Author SHA1 Message Date
wmantly cde45fff1d release(v2.1.1): mesh peer cleanup, GET /api/mesh/self, real mesh-registration bugfixes 2026-08-10 21:22:05 -04:00
wmantly 972f9ace0a fix(mesh): route ordering shadowed /api/mesh/register; initiator side never got a self-entry
Two real bugs found while live-testing the new GET /api/mesh/self
endpoint with two actual jump-host containers (mesh-joined for real,
not mocked):

1. routes/api.js mounted `/` (routes/jump.js, admin-session-gated)
   before `/mesh`. Since router.use('/', ...) matches every /api/*
   path, EVERY /api/mesh/* request -- including /register, which is
   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.
   Confirmed live: a real gateway-to-gateway /join call failed with a
   checkApiToken/LoginFailed error instead of ever reaching /register.
   Reordered so /mesh is mounted first.

2. POST /register (the receiving side of a join) 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 meshIndex the remote assigned (models/mesh_gateway.js's
   register() now accepts an explicit meshIndex instead of always
   auto-picking one from the local registry, which has no reason to
   agree with what's actually configured on the live wg0 interface).

Verified with two real containers joined over a live network: both
sides now report their own correct mesh IP via GET /api/mesh/self,
and both appear correctly in GET /api/mesh/gateways.
2026-08-10 21:17:00 -04:00
wmantly 8c184a7f9a feat(mesh): GET /api/mesh/self for local bootstrap self-IP discovery
A no-inbound spoke's join script (theta-suite's bootstrap/site-join.js)
needs its own gateway's mesh IP to hand to sso-manager-node's
/api/site/join, but the only existing read (GET /api/mesh/gateways)
requires a full jump-admin session -- unusable from an unattended
bootstrap script. Add a narrower read gated only by a valid jmp_ API
token (any self-service token, same as theta-proxy's prx_ tokens for
proxy_client.js), exposing just this gateway's own mesh IP.
2026-08-10 20:53:47 -04:00
wmantly b6efcff25e fix(mesh): peer removal now cleans up its kernel routes
wg_iface.removePeer() previously just did `wg set ... remove` -- the
kernel routes setPeer() adds for a peer's AllowedIPs (since wg itself
only configures crypto-routing, not kernel routes -- see setPeer's own
comment) were never cleaned up, a real TODO flagged in code but never
exercised because nothing removed a mesh peer at all.

- removePeer() now queries the peer's current AllowedIPs (`wg show
  <iface> allowed-ips`) BEFORE removing it -- once gone, wg no longer
  knows what to clean up -- and issues `ip route del` for each.
- New DELETE /api/mesh/gateways/:id (models/mesh_gateway.js gained
  remove()) actually calls removePeer(), so the fix has a real caller;
  previously there was no removal path anywhere in the mesh feature at
  all. Refuses to remove the local "(self)" entry. Does not reach out
  to the remote gateway to remove the reciprocal peer -- that side
  needs the same action taken independently.
- Mesh UI: remove button per non-self peer row, using app.messages.confirm
  (not native confirm() -- caught by this repo's own no-native-dialogs
  test, which failed on first pass and is now green).

Verified for real with a live WireGuard interface in a container: routes
for a peer's AllowedIPs present after setPeer, confirmed gone after
removePeer, while the interface's own local route correctly survives.
2026-08-10 20:18:39 -04:00
wmantly 29029914d2 release(v2.1.0): gateway-to-gateway WireGuard mesh, mDNS announce, mesh UI
Rolls up this pass's mesh work: real site-to-site WireGuard tunnels
(kernel-first, wireguard-go fallback), join-token bootstrap, mDNS
local-discovery announcer, and a UI for all of it. Verified with real
two-container tests (actual encrypted tunnel passing traffic, real
multicast discovery cycle), which caught two real bugs -- see
CHANGELOG.md for detail.
2026-08-10 18:56:50 -04:00
wmantly 111f5d9df7 feat(mesh): UI for the gateway-to-gateway mesh
The mesh API (routes/mesh.js) had zero UI -- minting a join token,
joining a remote gateway, or seeing what's meshed all required calling
the API directly. New Mesh page (nav: Dashboard/Sessions/WireGuard/
Mesh/Audit):

- This Gateway card: interface name, kernel-vs-userspace WireGuard mode
  (wireguard-go fallback), meshed-gateway count.
- Mint a Join Token: calls POST /api/mesh/join-tokens, shows the
  single-use token once.
- Join a Remote Gateway's Mesh: calls POST /api/mesh/join with a remote
  endpoint + token.
- Meshed Gateways table: site, mesh index, mesh subnet, endpoint, public
  key, last seen -- including this gateway's own self-entry.

EJS compile verified; jump-host's existing test suite (34 tests) still
passes. Not yet visually driven in a browser the way sso-manager-node's
modal was (jump-host's OIDC-based admin auth is a heavier lift to stand
up for a one-off check) -- route registration, EJS compilation, and the
API layer underneath are verified; the actual click-through is not.
2026-08-10 18:31:03 -04:00
wmantly fe71ec1bcc feat(mdns): announce this site's local presence for agent local-discovery
The announcer half of AGENT_LOCAL_DISCOVERY_SPEC.md / MULTI_SITE_SPEC.md
Appendix B -- advertises which public hostnames this site fronts (and at
what local IP) via mDNS, so a theta-agent on the same LAN segment with
prefer_local_directory enabled can skip the relay/WAN path.

Opt-in via THETA_LOCAL_DISCOVERY_HOSTS (comma-separated); no-op if unset,
so this changes nothing for an install that doesn't configure it. Only
ever advertises which hostnames map to which local IP -- no identity/
trust information -- consistent with the hard rule on the listening side
(theta-agent) that local-discovery may change DNS resolution but must
never touch certificate validation.

Verified end-to-end against the real theta-agent Go binary: announce,
discover, apply, and clean revert on disappearance all confirmed working
over real multicast between two containers.
2026-08-10 18:03:58 -04:00
wmantly 99276f4ee5 feat(mesh): real gateway-to-gateway WireGuard mesh (site-to-site tunnels)
The existing WireGuard code (models/wg_site.js, routes/wireguard.js) is the
roaming-client/exit-node feature -- individual peer configs an admin hands
out, not gateway-to-gateway mesh peering. This adds the latter, per
MULTI_SITE_SPEC.md §4: two theta-gateway instances mesh by one calling the
other's POST /api/mesh/register with a join token (minted via
POST /api/mesh/join-tokens, admin-gated); both sides end up with a live
wg0 peer for the other, mesh-indexed per Appendix A's addressing
(172.24.<idx>.0/16 + 10.<idx>.0.0/16, idx 1-254).

- utils/wg_iface.js: brings up the local interface, preferring in-kernel
  WireGuard (ip link add type wireguard) and falling back to userspace
  wireguard-go when the kernel module isn't available. Both packages
  added to the Dockerfile.
- utils/mesh_addressing.js: pure addressing math, unit tested
  (test/unit/mesh_addressing.test.js).
- models/mesh_gateway.js: Redis-backed registry of known peer gateways
  (same pattern as wg_site.js), assigns + persists mesh indexes.
- utils/mesh_join_token.js: single-use bootstrap credential, same
  GETDEL-on-Redis pattern already used on the theta-directory side.
- routes/mesh.js: /join-tokens (admin), /register (bearer token, no
  session -- called by a remote gateway), /join (admin, initiates from
  this side), /gateways (admin, list).

Verified with a REAL two-container test (not mocked): two independent
containers, each running this actual code, meshed via a live join-token
handshake, brought up real kernel WireGuard interfaces, and passed ICMP
traffic across the resulting encrypted tunnel end to end (0% packet
loss). That test caught a real bug worth calling out: `wg set ... peer
... allowed-ips` only configures WireGuard's own crypto-routing table --
it does NOT add a kernel route for that destination (wg-quick normally
does this as a separate step; we don't use wg-quick). A real encrypted
handshake completed between the two containers with the route missing,
and ping still showed 100% loss until setPeer() was fixed to add the
corresponding `ip route add <allowed-ip> dev <iface>` itself.
2026-08-10 17:23:19 -04:00
wmantly 02767cac47 release(v2.0.1): rebrand docs to Theta Gateway, remove standalone install paths
README no longer offers Standalone Docker / Bare metal install instructions,
which contradicted the Deployment section's own "exclusively via Docker
Compose within Theta Suite" claim. Links to sso-manager-node/theta-env's old
per-repo GitHub Pages sites now point at the unified theta-suite docs site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 18:51:29 -04:00
wmantly da03cdd666 docs(changelog): add v2.0.0 release notes (#44) 2026-08-09 00:14:54 -04:00
wmantly 03dd903e07 feat(wireguard): bootstrap server keypair and default site exit node, fix UI confirmation actionMessage and query token auth (#43) 2026-08-09 00:08:55 -04:00
wmantly 43349579e1 feat(wireguard): WireGuard peer manager UI — QR codes, .conf download, per-client exit node selection (#42)
* feat(wireguard): WireGuard peer manager UI with QR, .conf download, and per-client exit node selection

- models/wg_peer.js     — Redis-backed peer store with auto IP allocation (10.100.0.x)
- models/wg_site.js     — Redis-backed exit node store (admin-managed sites)
- utils/wg_keys.js      — X25519 keypair gen via Node crypto (no wg binary needed)
- utils/wg_conf.js      — client wg0.conf renderer
- routes/wireguard.js   — REST API: CRUD sites/peers, GET /conf, GET /qr (QRCode PNG)
- views/wireguard.ejs   — full dark-mode UI: exit node table, peer table, QR modal,
                           .conf download, exit node picker per client
- conf/base.js          — conf.wireguard block (serverPublicKey, serverEndpoint, dns, poolBase)
- Nav: WireGuard link added (admin-gated)
- No wg binary dep in Node process — key gen is pure JS X25519

* fix(test): update test script to run unit tests without native bcrypt binary dependency in CI

* fix(ui): replace native browser alert/confirm with app.messages in WireGuard view
2026-08-08 23:19:14 -04:00
wmantly 2a7a7c01da bump: version 2.0.0 (theta-gateway) (#41) 2026-08-08 21:33:12 -04:00
wmantly 2a159428dd docs: update README to reflect Theta Gateway branding and Docker-only Theta Suite deployment (#40) 2026-08-08 21:21:45 -04:00
wmantly 5a8dbaee0d Merge pull request #39 from theta42/docs/correct-access-model-description
docs: correct host-access authorization model description
2026-08-06 21:32:08 -04:00
wmantly 24a6a718e0 docs: correct host-access authorization model description
README.md and docs/architecture.md described authorization as a client-side
loop over each of a user's LDAP groups, calling the SSO's
GET /api/discovery/resources?group=<cn> once per group. The actual code
(utils/access.js, accessibleHosts()) makes a single call to the SSO's
GET /api/discovery/access/:uid, which resolves the user's groups
server-side and returns the full access projection in one response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113gCdnfSCuZr6xvPDxTo3D
2026-08-06 21:27:31 -04:00
wmantly 57d0600fc0 Merge pull request #38 from theta42/fix/catalog-only-jump-targets
fix: only catalog hosts are jump targets (v1.19.0)
2026-08-05 18:55:07 -04:00
wmantly fedbe81690 fix: only catalog hosts are jump targets (v1.19.0)
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m4s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m3s
Pull Request Tests / Test Summary (push) Failing after 4s
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.

Replaced with isCatalogHost(), mirroring the rule the SSO Directory's own
listing applies: a resource carrying discovery_sources but never promoted
is excluded; hand-created hosts and promoted ones are included; an
explicit managed:false is always excluded.

The two copies of this rule have now drifted apart once. If a third
consumer needs it, hoist it into @simpleworkjs/directory-schema rather
than copying again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:41:50 -04:00
wmantly a7b3416619 Merge pull request #37 from theta42/fix/version-1.18.0
chore: sync package.json to 1.18.0
2026-08-04 16:52:48 -04:00
wmantly f357c89ac7 chore: sync package.json + lockfile to v1.18.0 tag
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m5s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m3s
Pull Request Tests / Test Summary (push) Failing after 4s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:51:36 -04:00
wmantly b9415dcb17 Merge pull request #36 from theta42/release/v1.18.0
feat: error page + navbar active styling (v1.18.0)
2026-08-04 15:09:37 -04:00
wmantly 65ba1b16e3 feat: error page + navbar active styling (v1.18.0)
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m2s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m9s
Pull Request Tests / Test Summary (push) Failing after 4s
- Add SSO-style error page (views/error.ejs) and render it for browser
  navigation in the error handler (API still returns JSON).
- Navbar: username not underlined; only the active nav link is bold+underlined.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:26:48 -04:00
wmantly 8c4ec67282 Merge pull request #35 from theta42/fix/jump-target-filter-v1.17.2
fix(jump-host): Filter SSH connection targets to managed hosts only v1.17.2
2026-08-03 13:57:38 -04:00
wmantly 36e7dbf8aa fix(jump-host): Filter SSH connection targets to managed hosts only v1.17.2
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m4s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m3s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 13:57:11 -04:00
wmantly c56bfe21e5 Merge pull request #34 from theta42/fix/bump-version-1.17.1
chore: bump package.json version to 1.17.1
2026-08-03 02:35:37 -04:00
wmantly d533a94718 chore: bump package.json version to 1.17.1 2026-08-03 02:35:26 -04:00
wmantly fe18393d4e Merge pull request #33 from theta42/feature/v1.17.1-docs-restoration
docs: restore jump-host documentation and deployment guide
2026-08-03 02:19:02 -04:00
wmantly e41e7ff9e1 docs: restore complete jump-host documentation site and DEPLOYMENT.md 2026-08-03 02:18:56 -04:00
wmantly 1100872152 Merge pull request #32 from theta42/feat-machine-identity
feat: use machine identity for access queries
2026-08-02 18:50:45 -04:00
wmantly 16ab12a61c feat: use machine identity for access queries
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m16s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m11s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 18:49:00 -04:00
wmantly 0f6be51c35 feat: downstream host key pinning (#1) 2026-08-02 18:19:35 -04:00
wmantly 463111dfd6 fix: remove DEPLOYMENT.md from Docker build context 2026-08-02 11:52:55 -04:00
wmantly 4874544955 chore: release v1.16.0 2026-08-02 01:54:00 -04:00
wmantly 256476268f feat: implement OpenBao PKI certificate authentication 2026-08-02 01:54:00 -04:00
wmantly a57d579b0e docs: remove standalone deployment and docs folder 2026-08-02 00:51:30 -04:00
wmantly 49bf0fa1d3 chore: release v1.15.0 2026-08-02 00:16:18 -04:00
wmantly 1b4764e328 feat: Rename SSO Manager to Jump in UI 2026-08-02 00:16:18 -04:00
wmantly db3333e26d v1.14.1: bump @simpleworkjs/bao-conf to 1.0.1
bao-conf 1.0.0's init() threw when VAULT_TOKEN was unset, crashing boot
(.catch -> process.exit(1)) in any deployment without an OpenBao sidecar
(standalone Docker, bare metal). 1.0.1 makes init() fail-soft on a
missing token (warn + continue from CONF_SECRETS). The theta-env stack
is unaffected (it always sets a scoped VAULT_TOKEN).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:47:51 -04:00
43 changed files with 2527 additions and 208 deletions
+56
View File
@@ -1,9 +1,65 @@
## 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
+2 -1
View File
@@ -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/
+32 -108
View File
@@ -1,130 +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 — 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 backends, same SSH front door and audit trail: the default mode
authenticates against the shared LDAP directory and authorizes from the
[SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory graph;
**standalone mode** (below) runs with no LDAP or SSO at all, storing users and
hosts in a local SQL database instead.
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.
## Two ways to connect
**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.
## Standalone mode
## Deployment
Run without LDAP or the SSO Manager at all. Set `standalone.enabled: true` and
the jump host stores users and hosts itself, via
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)
(Sequelize under the hood — defaults to a local SQLite file, but any
Sequelize-supported dialect works via `conf.orm`):
Theta Gateway is deployed exclusively via Docker Compose as an integrated service within **Theta Suite** — it is not installed or run on its own:
```js
standalone: { enabled: true },
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
```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
```
Everything else — the SSH front door, key injection, bridging, the web UI and
audit trail — is unchanged; only where users/hosts live and how passwords are
checked differs. There's no admin UI for standalone users/hosts yet — add them
with the ORM models directly:
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.
```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 },
});
```
In standalone mode every stored host is reachable by every stored user — there
is no group-based authorization (the `groups` field on `StandaloneUser` is
accepted for interface parity but not yet enforced).
## Requirements
*(default LDAP + SSO mode — see [Standalone mode](#standalone-mode) to skip
all of this)*
- 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.
## 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
```
### Bare metal
```
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
@@ -141,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
@@ -173,7 +97,7 @@ OpenBao with the scoped `VAULT_TOKEN` (env, policy `jump-host` — read only
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-env's **[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
theta-suite's **[Secrets docs](https://theta42.github.io/theta-suite/secrets.html)**.
## Development
+7 -5
View File
@@ -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
+11 -3
View File
@@ -66,14 +66,22 @@ 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+11 -1
View File
@@ -4,6 +4,8 @@ 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();
@@ -41,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;
+5 -1
View File
@@ -15,8 +15,12 @@ const { Server } = require('socket.io');
// 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(() => {
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');
+17 -1
View File
@@ -6,7 +6,7 @@
// values (LDAP creds, SSO API token) belong in the secrets file.
module.exports = {
name: 'SSO Manager',
name: 'Jump',
logo: '/static/img/theta42.svg',
// LDAP directory the users live in (same directory the SSO manages).
@@ -120,4 +120,20 @@ module.exports = {
// 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',
},
};
+2 -1
View File
@@ -27,7 +27,8 @@ async function auth(req, res, next){
return next();
}
req.token = await Auth.checkToken(req.header('auth-token'));
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();
+103
View File
@@ -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 };
+119
View File
@@ -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 };
+84
View File
@@ -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 };
+367 -7
View File
@@ -1,12 +1,12 @@
{
"name": "t42-jump-host",
"version": "1.11.0",
"name": "theta-gateway",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-jump-host",
"version": "1.11.0",
"name": "theta-gateway",
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
@@ -19,6 +19,7 @@
"@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",
@@ -30,6 +31,7 @@
"model-redis": "^1.6.0",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"qrcode": "^1.5.4",
"redis": "^6.1.0",
"socket.io": "^4.8.3",
"ssh2": "^1.16.0"
@@ -62,6 +64,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"license": "MIT"
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -158,9 +166,9 @@
}
},
"node_modules/@simpleworkjs/bao-conf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.0.tgz",
"integrity": "sha512-HxB2ohFuDKbwTfNh5dXCot0dd6qoP+3Ebz1xKH0eOhKNVNMjHU1p7XZv2VTe+VnHn+DV22Eh8lAgWxnX/pkXUw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
@@ -318,6 +326,30 @@
"node": ">= 0.6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -475,6 +507,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/bonjour-service": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz",
"integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"multicast-dns": "^7.2.5"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -587,6 +629,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -621,6 +672,17 @@
"node": ">=18"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -630,6 +692,24 @@
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -772,6 +852,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -814,6 +903,24 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
"license": "MIT",
"dependencies": {
"@leichtgewicht/ip-codec": "^2.0.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/dottie": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz",
@@ -856,6 +963,12 @@
"node": ">=0.10.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -1086,6 +1199,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -1135,6 +1254,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1183,6 +1315,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1417,6 +1558,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -1504,6 +1654,18 @@
"node": ">=20"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1661,6 +1823,19 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multicast-dns": {
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
"license": "MIT",
"dependencies": {
"dns-packet": "^5.2.2",
"thunky": "^1.0.2"
},
"bin": {
"multicast-dns": "cli.js"
}
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
@@ -1894,6 +2069,42 @@
"wrappy": "1"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1903,6 +2114,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -1938,6 +2158,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -2005,6 +2234,23 @@
"once": "^1.3.1"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -2107,6 +2353,21 @@
"node": ">= 20.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/retry-as-promised": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
@@ -2293,6 +2554,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -2581,6 +2848,32 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -2653,6 +2946,12 @@
"node": ">=6"
}
},
"node_modules/thunky": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2873,6 +3172,12 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wkx": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz",
@@ -2882,6 +3187,20 @@
"@types/node": "*"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -2909,6 +3228,12 @@
}
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -2917,6 +3242,41 @@
"engines": {
"node": ">=18"
}
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "t42-jump-host",
"version": "1.14.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,7 +14,7 @@
"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"
},
@@ -29,6 +29,7 @@
"@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",
@@ -40,6 +41,7 @@
"model-redis": "^1.6.0",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"qrcode": "^1.5.4",
"redis": "^6.1.0",
"socket.io": "^4.8.3",
"ssh2": "^1.16.0"
+6
View File
@@ -3,6 +3,12 @@ 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;
+14
View File
@@ -13,6 +13,20 @@ router.use('/user', middleware.auth, require('./user'));
// 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'));
+212
View File
@@ -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;
+2
View File
@@ -47,5 +47,7 @@ router.get('/login', (req, res) => res.render('login', {
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;
+153
View File
@@ -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;
+6 -2
View File
@@ -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.
},
});
};
+56
View File
@@ -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 };
+32 -6
View File
@@ -129,15 +129,28 @@ 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 (err) { throw fail('key-inject-failed', err.message, host ? host.slug : undefined); }
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 (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
@@ -228,15 +241,28 @@ 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 (err) { return finishFail('key-inject-failed', err.message, tui.host.slug); }
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 (err) {
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
+56
View File
@@ -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 };
+42 -35
View File
@@ -8,39 +8,33 @@ 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,23 +42,19 @@ 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('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
test('does not depend on user.groups or ldap.getGroups', async () => {
clearCache();
let ldapCalled = false;
const user = { uid: 'erin', groups: ['host_web01_access'] };
const user = { uid: 'erin' }; // no dn, no groups
const fetchImpl = stubFetch({
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
erin: [{ id: '5', kind: 'host', slug: 'host_web01' }],
});
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
const hosts = await accessibleHosts(user, { fetchImpl });
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
assert.strictEqual(ldapCalled, false);
});
test('allHosts fetches the whole host inventory with no group filter', async () => {
@@ -80,16 +70,33 @@ test('allHosts fetches the whole host inventory with no group filter', async ()
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
});
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
// 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, so a
// good group alongside still yields its hosts.
const fetchImpl = async (url) => {
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
// 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, ldap: stubLdap(['drift_access', 'good_access']) });
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
const hosts = await accessibleHosts(user, { fetchImpl });
assert.deepStrictEqual(hosts, []);
});
+27
View File
@@ -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);
});
+42
View File
@@ -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'));
});
+34 -32
View File
@@ -17,14 +17,9 @@ if (conf.standalone && conf.standalone.enabled) {
// 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'
// 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
@@ -51,37 +46,44 @@ if (conf.standalone && conf.standalone.enabled) {
// Every host in the inventory, unfiltered — for admins (the web UI's own
// account is already gated by requireAdmin before this is ever called).
async function allHosts({ fetchImpl = fetch } = {}) {
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
return resources.filter(r => r.kind === 'host');
//
// "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;
}
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
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;
// The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we
// look them up; the web UI already has the session's OIDC groups claim
// and passes it directly, skipping a redundant LDAP round-trip.
const groups = user.groups || await ldap.getGroups(user.dn);
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);
}
let resources = [];
try {
resources = await directoryClient({ fetchImpl }).getAccess(user.uid);
} catch (error) {
console.error(`[access] ${error.message}`);
}
const hosts = [...seen.values()];
const hosts = resources.filter(isCatalogHost);
cache.set(user.uid, { at: Date.now(), hosts });
return hosts;
}
+27
View File
@@ -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 };
+29
View File
@@ -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 };
+2
View File
@@ -37,6 +37,8 @@ module.exports = {
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']},
],
};
+44
View File
@@ -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 };
+68
View File
@@ -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 };
+174
View File
@@ -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
};
+29
View File
@@ -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 };
+25
View File
@@ -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') %>
+1 -1
View File
@@ -90,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>
<% } %>
+167
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
$(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 -1
View File
@@ -49,7 +49,7 @@
</ul>
<div class="form-inline mt-2 mt-md-0">
<% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
<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 { %>
+457
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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') %>