Compare commits

..

27 Commits

Author SHA1 Message Date
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
51 changed files with 3253 additions and 188 deletions
+28
View File
@@ -1,3 +1,31 @@
## 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
+83
View File
@@ -0,0 +1,83 @@
# Deployment
Three ways to run the jump host, in increasing manual effort.
## 1. Unified theta-env stack
Set in `theta-env/setup.env`:
```
CFG_JUMP_HOST_ENABLED=true
CFG_JUMP_HOST=jump.example.com
JUMP_SSH_PORT=2222
```
Re-run `./setup.sh`. It builds the submodule, writes `config/jump-secrets.js`,
mints the SSO API token, grants the `sshPublicKey` write-ACL to the shared
`cn=ldapclient` bind account, registers `jump.example.com` in the proxy, and
seeds a directory entry.
Expose SSH: forward the public host's `:22` (or `:2222`) to the container's
published `JUMP_SSH_PORT`.
## 2. Standalone Docker
```
cp secrets.js.example config/jump-secrets.js
$EDITOR config/jump-secrets.js # LDAP bind (+ sshPublicKey write ACL), SSO url + token
docker compose up -d --build
```
Host keys persist in the `jump-data` volume. The web UI is on `:3002`; front it
with your own TLS/proxy.
## 3. Bare metal
```
curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash
sudo $EDITOR /etc/jump-host/secrets.js
sudo systemctl restart jump-host
journalctl -u jump-host -f
```
`ops/install.sh` installs Node 22 + Redis, hard-resets the checkout at
`/opt/theta42/jump-host` to the remote branch, symlinks the systemd unit, and
runs `npm ci`. Idempotent — re-run to update. Overridable via `REPO_DIR=`,
`BRANCH=`, `SECRETS_FILE=`.
## The LDAP write-ACL (required)
The bind account must be able to write the `sshPublicKey` attribute so the jump
host can inject its key. In the bundled OpenLDAP (`slapd.conf` / `olc`):
```
access to attrs=sshPublicKey
by dn.exact="cn=ldapclient,ou=people,dc=example,dc=com" write
by self write
by * read
```
Without it, key injection fails and every bridge attempt is audited
`key-inject-failed`.
## Listening on port 22
Default is 2222 (unprivileged). For 22: set `ssh.listenPort: 22`, and either
- systemd: uncomment `AmbientCapabilities=CAP_NET_BIND_SERVICE` in the unit; or
- Docker: publish `22:22`; or
- firewall: DNAT `22 → 2222`.
## Verifying
```
# from a client whose key is in your LDAP sshPublicKey
ssh -p 2222 youruid@jump.example.com # TUI picker
ssh -p 2222 youruid_-_somehost@jump.example.com
sftp -P 2222 youruid_-_somehost@jump.example.com
curl -s http://localhost:3002/health
```
Watch `journalctl -u jump-host -f` (or `docker logs -f jump-host`) and the
audit log at `/audit` in the web UI.
+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
+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
+24
View File
@@ -0,0 +1,24 @@
# Documentation
This directory is the GitHub Pages documentation site for the Jump Host project.
**Live site:** https://theta42.github.io/jump-host/
## Pages
- `index.md` — overview and quick start
- `connecting.md` — usage: the username grammar, the TUI picker, SFTP/WinSCP
- `architecture.md` — how auth, access resolution, key injection, and bridging work
- `installation.md` — Docker, bare-metal, and theta-env install; the LDAP write-ACL
## Local preview
```bash
gem install jekyll bundler
cd docs && jekyll serve
# http://localhost:4000/jump-host/
```
## Updating
Edit the markdown, push to `master`, and GitHub Pages rebuilds automatically.
+41
View File
@@ -0,0 +1,41 @@
title: Jump Host
description: An SSH jump host for the theta42 stack — directory-driven host bridging with audit and metrics; LDAP + SSO Manager by default, or fully standalone.
url: "https://theta42.github.io"
baseurl: "/jump-host"
logo: /assets/img/theta42.svg
lang: en_US
plugins:
- jekyll-seo-tag
- jekyll-sitemap
github:
repository_url: https://github.com/theta42/jump-host
zip_url: https://github.com/theta42/jump-host/archive/refs/heads/master.zip
tar_url: https://github.com/theta42/jump-host/archive/refs/heads/master.tar.gz
repository_name: theta42/jump-host
nav:
- title: Home
page: /
icon: fa-house
- title: Connecting
page: /connecting.html
icon: fa-terminal
- title: Architecture
page: /architecture.html
icon: fa-sitemap
- title: Installation
page: /installation.html
icon: fa-download
- title: Changelog
url: https://github.com/theta42/jump-host/blob/master/CHANGELOG.md
icon: fa-list
defaults:
- scope:
path: ""
type: "pages"
values:
layout: default
image: /assets/img/theta42.svg
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/favicon.svg' | relative_url }}">
{% seo title=false %}
<title>{% if page.title %}{{ page.title }} &middot; {% endif %}{{ site.title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<div class="container-fluid px-3">
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
{{ site.title }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navMain">
<ul class="navbar-nav">
{% for item in site.nav %}
<li class="nav-item">
{% if item.page %}
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% else %}
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1" style="margin-top: 4.5rem;">
<div class="container-fluid py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card shadow-lg">
<div class="card-body p-4 p-md-5 site-content">
{{ content }}
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="py-3 bg-dark text-light mt-auto">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank" rel="noopener">
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
</a>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-solid fa-list"></i> Changelog
</a>
</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+149
View File
@@ -0,0 +1,149 @@
---
layout: default
title: Architecture
description: How the jump host authenticates users, resolves reachable hosts from the directory, injects per-user keys, and bridges SSH — plus the web UI and audit model.
---
# Architecture
The jump host is a Node.js service (using [`ssh2`](https://github.com/mscdex/ssh2)
as both an SSH **server** and **client**) with two faces: the SSH front door
(default `:2222`) and a web UI/API (`:3002`). It holds no user database of its
own — identity, authorization, and onward credentials all come from the shared
directory.
```
┌────────────────────── jump host ──────────────────────┐
ssh │ ssh2 Server (:2222) │ ssh2 Client
─────┼─▶ 1. authenticate user ──▶ LDAP (sshPublicKey / bind) │ ───────────▶ downstream
user │ 2. resolve target ──▶ SSO /api/discovery │ sshd (as the
│ 3. inject key ──▶ LDAP (add sshPublicKey) │ real user)
│ 4. bridge channels ◀───────────────────────────────▶ │
│ web UI/API (:3002) ──▶ audit + metrics (redis) │
└───────────────────────────────────────────────────────┘
```
## 1. Inbound authentication
When a user connects, the jump host authenticates them against LDAP:
- **Public key** — it looks up the user's `sshPublicKey` values in the directory
and matches the offered key (handling ssh2's probe-then-sign two-phase
publickey auth). The jump host's *own* injected key (identified by its comment
marker) is deliberately excluded from this match — only the jump host may hold
that private key, so accepting it inbound would be a bypass.
- **Password** — an LDAP simple bind as the user's DN. Policy is configurable:
`off` (keys only — recommended for a public host), `local` (passwords only
from loopback/RFC1918 clients, keys-only from the internet), or `all`.
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:
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
briefly per user and shared by both the grammar path and the TUI picker.
Target matching tries, in order: exact slug → `host_`-prefixed slug → display
name → IP → address hostname. A raw IP that isn't an accessible directory host
is refused unless explicitly allowed.
> The directory auto-creates `<slug>_access` / `<slug>_admin` groups for every
> host and service (see the SSO's
> [Directory & Inventory](https://theta42.github.io/sso-manager-node/directory.html)
> docs), which is exactly what this authorization reads.
## 3. Per-user key injection {#per-user-key-injection}
The jump host holds **one** keypair. To connect downstream *as the user*
without asking them for anything, it must present a key the downstream `sshd`
will accept for that user. Downstream hosts (joined via
[ldap-client](https://github.com/theta42/ldap-client)) serve authorized keys
straight from LDAP via `AuthorizedKeysCommand`. So on a user's first connection,
the jump host appends its own public key to that user's `sshPublicKey` attribute
in LDAP — comment-marked so it's recognizable — then connects downstream with
its private key.
- Idempotent: the key is added once; a redis flag skips the LDAP round-trip
afterwards.
- The jump host's bind account therefore needs **write access to the
`sshPublicKey` attribute** on user entries (an OpenLDAP ACL — see the README).
In the bundled theta-env deployment this is handled for you.
- Because the marker key is excluded from inbound auth (step 1), it grants only
the jump host's onward path, never inbound impersonation.
## 4. Bridging
Once the upstream connection is ready, the jump host splices SSH channels
between the two connections:
- **shell / exec** — piped both ways, with window-change and exit-status
forwarded.
- **SFTP subsystem** — the two subsystem channels are raw-piped as opaque bytes;
no SFTP protocol parsing is needed, which is why WinSCP and `sftp` work
unchanged.
- Channel requests that arrive before the upstream is ready are buffered and
replayed, so nothing is dropped during the connect.
- The downstream host key's SHA256 fingerprint is recorded in the audit event
(trust-on-use in v1).
Byte counts per direction are tallied cheaply for the audit record.
## Web UI, API & audit
An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and
look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a
local anti-lockout admin (`auth.adminUsers`), with admin access gated by
`auth.adminGroups`. It exposes:
- `GET /health` — open; `{status, activeSessions, version}`
- `GET /api/sessions` — active sessions
- `GET /api/audit?page=&uid=&target=&status=` — the paged audit log
- `GET /api/metrics` — counters (total, failures, top users/hosts)
Audit events and counters live in redis. Each event captures: user, auth method,
mode (grammar/picker), target slug/address/port, channel type, client IP,
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
## Standalone mode
Everything above describes the default backend. Set `standalone.enabled: true`
and two modules become conditional facades, swapping their entire
implementation at `require` time based on that flag — nothing else in the
codebase (`ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, the
web UI) changes or even knows which mode it's running in:
- **`models/user_ldap.js`** — LDAP client, or `models/user_file.js` (an
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)-backed
store implementing the same `getUser` / `getGroups` / `checkPassword` /
`addSshKey` interface).
- **`utils/access.js`** — LDAP groups + SSO `/api/discovery`, or
`utils/hosts_file.js` (same ORM package, same `accessibleHosts()` interface).
In standalone mode there's no group-based authorization: every stored host
is accessible to every stored user.
The ORM is Sequelize underneath, defaulting to a local SQLite file but
accepting any Sequelize-supported dialect via `conf.orm`. See
[Installation](installation.html#standalone-mode) for config and how to add
users/hosts (there's no admin UI for standalone data yet).
## Where it sits in the stack
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
OpenLDAP directory (users, groups, `sshPublicKey`) and the inventory API this
jump host reads.
- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls the
downstream Linux hosts (SSSD/PAM + `AuthorizedKeysCommand`) that the jump host
connects into.
- **[Proxy](https://theta42.github.io/proxy/)** — fronts the jump host's web UI
under TLS.
- **[theta-env](https://theta42.github.io/theta-env/)** — wires it all together.
+116
View File
@@ -0,0 +1,116 @@
/* theta42 docs site — shares the in-app dark navbar/footer + card look
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
generic Jekyll theme. */
body {
background-color: #f4f5f6;
}
.navbar-brand img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
}
.navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Markdown content typography, scoped to the card body so it doesn't leak
into the nav/footer. */
.site-content h1:first-child {
margin-top: 0;
}
.site-content h1,
.site-content h2,
.site-content h3 {
font-weight: 700;
}
.site-content h2 {
margin-top: 2.5rem;
padding-bottom: .4rem;
border-bottom: 1px solid #e9ecef;
}
.site-content h3 {
margin-top: 1.75rem;
}
.site-content a {
color: #a3671f;
text-decoration-color: rgba(163, 103, 31, .35);
}
.site-content a:hover {
color: #8a5a16;
}
.site-content pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem 1.25rem;
border-radius: .375rem;
overflow-x: auto;
}
.site-content code {
color: #a3671f;
background-color: #f4f0e8;
padding: .15em .4em;
border-radius: .25rem;
font-size: .875em;
}
.site-content pre code {
color: inherit;
background: none;
padding: 0;
}
.site-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
margin: 1.25rem 0;
}
.site-content table th,
.site-content table td {
border: 1px solid #dee2e6;
padding: .5rem .75rem;
text-align: left;
}
.site-content table th {
background-color: #f8f9fa;
}
.site-content blockquote {
border-left: 4px solid #C59341;
padding: .5rem 1rem;
margin: 1.25rem 0;
background-color: #f8f6f1;
color: #495057;
}
.site-content img {
max-width: 100%;
height: auto;
}
/* Screenshot grids in the markdown use width="49%" inline attrs for a
two-up desktop layout -- stack them on narrow screens instead of
squeezing to illegibility. */
@media (max-width: 576px) {
.site-content img[width] {
width: 100% !important;
margin-bottom: .75rem;
}
}
.site-content hr {
margin: 2rem 0;
border-top: 1px solid #e9ecef;
}
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
<!-- Network nodes -->
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
<!-- Connection lines -->
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

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

After

Width:  |  Height:  |  Size: 1.9 KiB

+113
View File
@@ -0,0 +1,113 @@
---
layout: default
title: Connecting
description: How to reach downstream hosts through the jump host — the username grammar, the interactive picker, SFTP/WinSCP, and what access you get.
---
# Connecting
You reach a downstream host two ways: name the target in your username, or log
in plain and pick it from a menu. Either way you authenticate **once**, to the
jump host, with your directory credentials.
## The username grammar
```
{uid}_-_{target}
```
- `{uid}` — your directory username.
- `_-_` — the separator (legal in an SSH username everywhere, including WinSCP).
- `{target}` — the host to reach: a directory **slug** (`host_web01` or just
`web01`), the host's **display name**, its **IP**, or the hostname in its
directory `address`.
```bash
ssh alice_-_web01@jump.example.com # by slug (host_ prefix optional)
ssh alice_-_10.0.0.10@jump.example.com # by IP (must be a host you can reach)
```
If the target matches a host your directory groups grant, you're bridged
straight to its `sshd` — same as if you'd SSH'd directly, but through the
audited jump host.
## SFTP / WinSCP / scp
Because the whole route is encoded in the username, file transfer tools that
only take one connection string work with no extra configuration:
```bash
sftp -P 2222 alice_-_web01@jump.example.com
scp -P 2222 file.txt alice_-_web01@jump.example.com:/tmp/
```
**WinSCP:** set Host name to `jump.example.com`, Port to `2222`, and User name
to `alice_-_web01`. SFTP is bridged as an opaque byte stream, so all operations
(browse, upload, download, rename) work normally.
## The interactive picker
Log in with just your username and you get a TUI list of every host you can
reach:
```bash
ssh alice@jump.example.com
```
- **↑ / ↓** move the selection
- **type** to filter the list incrementally
- **Enter** connect to the highlighted host
- **number keys** jump straight to that row
- **q** or **Ctrl-C** to quit
Pick a host and you're bridged into it. The picker only ever lists hosts your
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 **catalog** hosts (via the `host_<name>_access` groups
the directory auto-creates for each machine). To get access to a new host, an
admin adds you to that host's access group in the SSO — nothing on the jump host
changes.
Targets that don't resolve to a host you're allowed to reach are refused (and
audited). Raw IPs that aren't a known directory host are denied by default.
**Only catalog hosts are jump targets.** A machine that the SSO merely
*discovered* — a Proxmox guest, a UniFi client — is not a jump target until an
admin promotes it into the directory catalog. The jump host applies the same
rule the SSO's own Directory listing does: a resource carrying
`discovery_sources` but never promoted is excluded, while hand-created hosts and
promoted ones are included. Previously the filter treated a missing `managed`
flag as permission, so unpromoted discovery results showed up in the picker.
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
> every registered host is reachable by every registered user — there's no
> group-based restriction to ask an admin about.
## Authentication
The jump host authenticates **you** against the directory:
- **Public key** — matched against your `sshPublicKey` entries in LDAP. Use your
normal SSH key; the client picks it automatically.
- **Password** — your directory password (LDAP bind). Password auth is often
restricted to local networks or disabled entirely on a public jump host
(keys-only) — check with your operator.
You never manage a separate credential for the downstream host: the jump host
handles onward authentication for you (see
[Architecture](architecture.html#per-user-key-injection)).
## First connection to a host
The very first time you reach a given downstream host, the jump host provisions
its access key for you behind the scenes. If that first attempt races the
directory's key-cache refresh you may see a brief
```
jump-host: first-time key propagation, retrying…
```
and it reconnects automatically. Subsequent connections are immediate.
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+121
View File
@@ -0,0 +1,121 @@
---
layout: default
title: Home
description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to; LDAP by default, or fully standalone.
---
# Jump Host
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
stack. Users SSH into **one** public host and land on any downstream host
they're entitled to — authenticated against the shared LDAP directory,
authorized from the [SSO Manager](https://theta42.github.io/sso-manager-node/)'s
inventory graph, and audited end to end.
No per-host accounts, no distributing keys, no VPN. The same people who log in
to your SSO are the people who can reach your machines — and only the machines
their directory groups grant.
Part of the theta42 self-hosted identity stack, alongside
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
[Proxy](https://theta42.github.io/proxy/), composable with one command via
[theta-env](https://theta42.github.io/theta-env/).
## Screenshots
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
*(click any screenshot to view full size)*
Don't want to run LDAP or the SSO Manager? **Standalone mode** stores users
and hosts in a local SQL database instead (SQLite by default, any
Sequelize-supported dialect if you want something else) — same SSH front door,
key injection, and audit trail. See
[Installation](installation.html#standalone-mode) to get started.
## Two ways to connect
**Direct (WinSCP/SFTP-friendly):**
```bash
ssh alice_-_web01@jump.example.com
sftp -P 2222 alice_-_web01@jump.example.com
```
The username grammar is `{uid}_-_{target}``target` is a directory host slug
(with or without the `host_` prefix), a bare hostname, or an IP. One username
string, no interactive step, so it works cleanly in WinSCP and scripts.
**Interactive picker:**
```bash
ssh alice@jump.example.com
```
A plain login shows a TUI list of the hosts you can reach; arrow-key or type to
filter, Enter to connect.
See **[Connecting](connecting.html)** for the full usage guide.
## Why a jump host (and why this one)
A bastion/jump host is the standard way to give SSH access to internal machines
through a single audited entry point. What's usually painful is *authorization*
and *credentials*: who may reach which host, and how the bastion authenticates
onward without you copying keys everywhere.
This jump host answers both from your directory:
- **Authorization is your directory graph.** The hosts you can reach are the
union of your LDAP groups × the SSO's inventory (the `host_<name>_access`
groups the directory already auto-creates). Add someone to a group; they can
reach the host. No bastion-side allow-list to maintain.
- **Onward auth is automatic.** The jump host holds one key and injects its
public half into your `sshPublicKey` on first use, then connects downstream
**as you**. Downstream hosts already serve keys from LDAP (via
[ldap-client](https://github.com/theta42/ldap-client)'s
`AuthorizedKeysCommand`), so nothing downstream needs configuring.
## Features
- **Username-grammar routing** (`uid_-_target`) — straight-through to the host,
SFTP included (WinSCP works)
- **Interactive TUI host picker** on plain login, scoped to your access
- **LDAP inbound auth** — public key or password (keys-only policy recommended
for a public host)
- **Directory-driven access** — reachable hosts come from the SSO inventory, not
a static list
- **Per-user key injection** — no downstream changes, no key distribution
- **Shell, exec, and SFTP** bridging
- **Web UI + HTTP API** for auditing and metrics — active sessions, a searchable
audit log, per-user/per-host counters
- **Full audit trail** — who, target, method, result, bytes, duration, and the
downstream host-key fingerprint
- **Standalone mode** — no LDAP, no SSO Manager; users and hosts live in a
local SQL database (Sequelize, any dialect — SQLite by default)
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
installer, or bundled in theta-env
## Get it
```bash
git clone https://github.com/theta42/jump-host.git
cd jump-host
cp secrets.js.example config/jump-secrets.js # then edit it
docker compose up -d --build
```
That's the standalone quick start. For bare-metal and the bundled theta-env
option, see **[Installation](installation.html)**.
## Related projects
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OpenLDAP
directory + OIDC provider + the inventory graph this jump host reads.
- **[Proxy](https://theta42.github.io/proxy/)** — puts your web apps behind the
same identity; fronts this jump host's web UI.
- **[theta-env](https://theta42.github.io/theta-env/)** — runs the whole stack,
jump host included, with one command.
+155
View File
@@ -0,0 +1,155 @@
---
layout: default
title: Installation
description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus standalone mode (no LDAP/SSO), the LDAP write-ACL, and port-22 options.
---
# Installation
Three ways to run the jump host, in increasing manual effort. All read their
config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf)
(`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env).
## Standalone mode (no LDAP/SSO) {#standalone-mode}
Skip LDAP and the SSO Manager entirely. Not to be confused with "Standalone
Docker" below, which is still LDAP + SSO, just run outside theta-env. Set in your secrets/config:
```js
standalone: { enabled: true },
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
```
`orm` is passed straight to Sequelize, so any supported dialect works — SQLite
is just the zero-dependency default. Everything downstream of auth (bridging,
key injection, the web UI, audit) is unchanged.
There's no admin UI for standalone users/hosts yet, so add them directly with
the ORM models:
```js
const StandaloneUser = require('./models/standalone_user');
const StandaloneHost = require('./models/standalone_host');
const bcrypt = require('bcrypt');
await StandaloneUser.create({
uid: 'alice',
passwordHash: await bcrypt.hash('a real password', 10),
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
groups: [],
});
await StandaloneHost.create({
slug: 'host_web01',
displayName: 'web01',
kind: 'host',
metadata: { ip: '10.0.0.5', sshPort: 22 },
});
```
Every host in the standalone inventory is reachable by every standalone user —
there's no group-based authorization yet (`groups` on `StandaloneUser` is
accepted for interface parity with the LDAP path, not enforced).
The rest of this page (requirements, the LDAP write-ACL, the three install
paths) describes the default LDAP + SSO mode — skip it if you're running
standalone.
## Requirements
- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
directory + `/api/discovery`), v1.3.0 or newer.
- Downstream hosts joined via
[ldap-client](https://github.com/theta42/ldap-client) (SSSD +
`AuthorizedKeysCommand`).
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
user entries (see below).
- An SSO API token (`sso_…`) for the directory queries.
## 1. Unified theta-env stack (recommended)
Enable it in `theta-env/setup.env`:
```bash
CFG_JUMP_HOST_ENABLED=true
CFG_JUMP_HOST=jump.example.com
JUMP_SSH_PORT=2222
```
Re-run `./setup.sh`. The stack builds the submodule (behind the `jump-host`
compose profile), mints the directory API token, writes
`./config/jump-secrets.js`, grants the `sshPublicKey` write-ACL, registers the
jump host in the proxy, and seeds a directory entry. Forward the public host's
`:22` (or `:2222`) to the container's published `JUMP_SSH_PORT`.
## 2. Standalone Docker
```bash
cp secrets.js.example config/jump-secrets.js
$EDITOR config/jump-secrets.js # LDAP bind (+ sshPublicKey write ACL), SSO url + token
docker compose up -d --build
```
Host keys persist in the `jump-data` volume. The web UI is on `:3002`; front it
with your own TLS/proxy.
## 3. Bare metal
```bash
curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash
sudo $EDITOR /etc/jump-host/secrets.js
sudo systemctl restart jump-host
journalctl -u jump-host -f
```
`ops/install.sh` installs Node 22 + Redis, hard-resets the checkout at
`/opt/theta42/jump-host` to the remote branch, symlinks the systemd unit, and
runs `npm ci`. Idempotent — re-run to update. Overridable via `REPO_DIR=`,
`BRANCH=`, `SECRETS_FILE=`.
## The LDAP write-ACL (required)
The jump host injects its public key into each user's `sshPublicKey`, so its
bind account must be able to **write** that attribute. In the bundled OpenLDAP:
```
access to attrs=sshPublicKey
by dn.exact="cn=ldapclient,ou=people,dc=example,dc=com" write
by self write
by * read
```
In the theta-env bundle this is handled for you (the jump host binds as the LDAP
admin). For a hardened standalone deployment, use a dedicated bind account with
exactly this attribute-scoped ACL. Without write access, key injection fails and
every bridge attempt is audited `key-inject-failed`.
## Listening on port 22
The default SSH port is **2222** so the service needs no privilege. To listen on
22, set `ssh.listenPort: 22` and either:
- **systemd:** uncomment `AmbientCapabilities=CAP_NET_BIND_SERVICE` in the unit;
- **Docker:** publish `22:22`; or
- **firewall:** DNAT `22 → 2222`.
## Configuration reference
Every key is documented in
[`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example):
`ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh`
(`listenPort`, `passwordAuth`, `allowRawIPs`, `keyComment`, timeouts,
`maxSessions`), `web.port`, `oidc` (web-UI SSO login), `auth`
(`adminGroups` / `adminUsers` / `localAdminPass`), and `redis`.
## Verifying
```bash
ssh -p 2222 youruid@jump.example.com # TUI picker
ssh -p 2222 youruid_-_somehost@jump.example.com # direct
sftp -P 2222 youruid_-_somehost@jump.example.com # WinSCP path
curl -s http://localhost:3002/health
```
Watch `journalctl -u jump-host -f` (or `docker logs -f jump-host`) and the audit
log at `/audit` in the web UI.
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/proxy/sitemap.xml
+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');
+16
View File
@@ -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();
+85
View File
@@ -0,0 +1,85 @@
'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.
async function register({ publicKey, endpoint, siteSlug }) {
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 = 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;
}
module.exports = { list, findByPublicKey, register, 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 };
+364 -4
View File
@@ -1,12 +1,12 @@
{
"name": "t42-jump-host",
"version": "1.16.1",
"name": "theta-gateway",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-jump-host",
"version": "1.16.1",
"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",
@@ -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.16.1",
"name": "theta-gateway",
"version": "2.1.0",
"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;
+8
View File
@@ -16,4 +16,12 @@ router.use('/api-token', middleware.auth, require('./api_token'));
// Jump-host data — jump admin only (audit log, active sessions, metrics).
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
// 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.
router.use('/mesh', require('./mesh'));
module.exports = router;
+164
View File
@@ -0,0 +1,164 @@
'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));
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); }
});
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); }
});
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;
+5 -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, cert, 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) => {
@@ -48,7 +48,10 @@ function connectUpstream({ host, port, username, privateKey, cert, onHostKey, ui
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 };
+2
View File
@@ -150,6 +150,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
host: endpoint.address, port: endpoint.port,
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); }
@@ -261,6 +262,7 @@ async function runTuiSession(session, client, state) {
host: endpoint.address, port: endpoint.port,
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']},
],
};
+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 };
+156
View File
@@ -0,0 +1,156 @@
'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}`);
}
}
}
// TODO: doesn't clean up the kernel routes setPeer added for this peer's
// AllowedIPs (would need to record or query them first) -- not exercised by
// any caller yet (nothing in this codebase removes a mesh peer today), but
// flagging so whoever adds that doesn't get bitten by stale routes.
function removePeer(name, publicKey) {
tryRun('wg', ['set', name, 'peer', publicKey, 'remove']);
}
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') %>
+154
View File
@@ -0,0 +1,154 @@
<%- 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>'
+ '</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>'
+ '</tr>';
});
html += '</tbody></table>';
$w.html(html);
}
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') %>