Compare commits

..

56 Commits

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

1. routes/api.js mounted `/` (routes/jump.js, admin-session-gated)
   before `/mesh`. Since router.use('/', ...) matches every /api/*
   path, EVERY /api/mesh/* request -- including /register, which is
   authenticated by a bearer mesh join token, not an admin session --
   hit that admin gate first and 401'd before routes/mesh.js ever ran.
   Confirmed live: a real gateway-to-gateway /join call failed with a
   checkApiToken/LoginFailed error instead of ever reaching /register.
   Reordered so /mesh is mounted first.

2. POST /register (the receiving side of a join) persists a `(self)`
   registry entry via ensureOwnMeshIndex(), but POST /join (the
   initiating side) never did -- so GET /api/mesh/self and the mesh
   UI's own-entry handling silently saw nothing on whichever gateway
   called /join. Fixed by registering a self-entry there too, using
   the exact meshIndex the remote assigned (models/mesh_gateway.js's
   register() now accepts an explicit meshIndex instead of always
   auto-picking one from the local registry, which has no reason to
   agree with what's actually configured on the live wg0 interface).

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113gCdnfSCuZr6xvPDxTo3D
2026-08-06 21:27:31 -04:00
wmantly 57d0600fc0 Merge pull request #38 from theta42/fix/catalog-only-jump-targets
fix: only catalog hosts are jump targets (v1.19.0)
2026-08-05 18:55:07 -04:00
wmantly fedbe81690 fix: only catalog hosts are jump targets (v1.19.0)
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m4s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m3s
Pull Request Tests / Test Summary (push) Failing after 4s
isManagedHost() treated a missing metadata.managed flag as permission, so
any host the SSO merely discovered -- an unpromoted Proxmox guest, a UniFi
client -- was offered in the TUI picker and accepted by the username
grammar.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:47:51 -04:00
wmantly 1092031a9f v1.14.0: load secrets from OpenBao at boot via @simpleworkjs/bao-conf
bin/www now runs bao-conf.init({ path: 'jump-host' }) before
require('../models'), so the OIDC clientSecret captured at require time
inside createOidcClient sees the OpenBao-merged config. Authenticates to
OpenBao with a scoped VAULT_TOKEN (policy jump-host), never the root
token; fail-soft to CONF_SECRETS if OpenBao is unreachable.
config/jump-secrets.js becomes an operator-edit seed artifact (OpenBao
authoritative). README gains a Secrets section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:28:46 -04:00
wmantly f386a5f9c3 Add ANSI colors to TUI picker
- Box-drawing header with cyan/magenta/green color treatment
- Per-row coloring with alternating cyan shades
- Environment badges (PROD in red, DEV in dim)
- Green inverse selection with '◄ SELECTED ►' indicator
- Yellow filter text and footer separator
- Title changed to 'SSO Manager'
2026-07-31 14:24:54 -04:00
wmantly 82318da484 Merge pull request #31 from theta42/release/1.11.0
Release 1.11.0: super/jump admin, per-host connection tracking, Audit metrics
2026-07-30 12:05:11 -04:00
wmantly 14784266b3 Release 1.11.0: super/jump admin, per-host connection tracking, Audit metrics 2026-07-30 12:02:25 -04:00
wmantly 362e77f3dd Merge pull request #30 from theta42/feat/super-admin-jump-admin-host-tracking
Add super/jump admin, per-host connection tracking; move stats to Audit
2026-07-30 12:00:30 -04:00
wmantly dfafffe154 Add super/jump admin, per-host connection tracking; move stats to Audit
- app_super_admin (cross-app, also recognized by sso-manager-node/proxy)
  and a new app_jump_admin group are added to conf.auth: super admins are
  full admins here same as app_sso_admin; jump admins get audit page/data
  access without other admin rights (isJumpAdmin/requireJumpAdmin in
  middleware/auth.js, wired into routes/api.js's audit-data gate and the
  /audit page's client-side forceLogin -- previously the page shell
  rendered for any logged-in user, only the data was gated).
- Dashboard: moved the stat boxes and Top hosts/Top users cards to the
  Audit page (audit is now the admin-facing metrics home; dashboard stays
  focused on "hosts I can reach"). Renamed "All hosts" to "My hosts".
- Host list now shows Last connection/Last failed connection columns and
  highlights rows green (live session, from session_registry) or yellow
  (most recent attempt failed) -- backed by new per-host last-success/
  last-fail timestamps in models/metrics.js, populated by ssh_server.js
  (which now attributes grammar/TUI connect failures to the resolved host
  when one was found, not just aggregate counters) and surfaced through
  GET /api/user/hosts (routes/user.js).
2026-07-30 11:58:28 -04:00
wmantly bd4464ed19 Merge pull request #29 from theta42/release/1.10.2
Release 1.10.2: page width standardization, Audit admin-gating
2026-07-30 09:52:05 -04:00
wmantly 061a044a70 Release 1.10.2: page width standardization, Audit admin-gating 2026-07-30 09:50:33 -04:00
wmantly c6a4a3c841 Merge pull request #28 from theta42/feat/page-width-audit-admin-gating
Match page width to sso-manager-node; gate Audit nav to admins
2026-07-29 22:22:12 -04:00
wmantly 0ef451e15d Match page width to sso-manager-node; gate Audit nav to admins
- Dashboard, Sessions, and Audit pages now wrap their content in
  <div class="container mt-4">, matching sso-manager-node/proxy's width
  instead of rendering full-bleed inside the fluid shell.
- Audit's nav entry now carries groups: ['admin'] (utils/ui.js), reusing
  the existing synthetic-admin-group nav-gating convention -- the API
  route was already server-side admin-gated, this closes the last gap by
  hiding the nav link/page for non-admins too.
- app-base.js (byte-identical across the 3 apps): added
  app.util.revealItem() and the --sw-content-offset sticky-positioning
  variable, carried over from the same round of changes in
  sso-manager-node/proxy. Not yet called anywhere in this app -- no
  sticky/reveal use case here yet -- but keeps the shared file in sync.
2026-07-29 22:20:56 -04:00
wmantly 6771904932 Merge pull request #27 from theta42/release/1.10.1
Release 1.10.1: fix API-token reveal modal race
2026-07-28 21:22:18 -04:00
wmantly c002afe043 Release 1.10.1: fix API-token reveal modal race 2026-07-28 21:21:01 -04:00
wmantly 0a2c25ae75 Merge pull request #26 from theta42/fix/apitoken-modal-race
Fix API-token reveal modal silently not showing after create
2026-07-28 21:20:46 -04:00
wmantly a15decb6f7 Fix API-token reveal modal silently not showing after create
app.modal.close() called immediately before showToken()'s app.modal.open()
in the same tick collides with Bootstrap's hide-transition guard on the
singleton modal, so the reveal never appears. open() alone already
overwrites the already-visible modal's content in place. Same root cause
as the OAuth-secret-reveal race fixed in sso-manager-node (v1.8.2) and the
create-token race fixed in proxy (v1.7.0), found while auditing this
exact pattern across all 3 apps this round.
2026-07-28 21:19:28 -04:00
wmantly 599136e4dc Release 1.10.0: unify API-token UI (card grid, Edit modal, description field) (#25) 2026-07-28 20:20:33 -04:00
wmantly a7d5efc764 Unify API-token UI: card grid, Edit modal, description field (#24)
The self-service API-token UI was inconsistent across all 3 apps
(sso-manager-node/proxy used a card grid with Edit/Rotate/Revoke and a
description field; jump-host used a bare table with no Edit action, no
description field anywhere in the UI, and icon-only buttons -- even though
its model and PUT route already fully supported both). jump-host is first
since it needed the least backend work (none -- description and the PUT
handler already existed, just unexposed) and the most view work, proving
the pattern before porting it to proxy/sso-manager-node.

- Card grid (jq-repeat="apiTokenCard") replacing the table, matching
  sso-manager-node's exact template: name + truncated token-id, optional
  description, a <dl> of Token ID/Created/Last used/Expires, and labeled
  Edit/Rotate/Revoke buttons.
- New Edit modal (app.modal, footer shows "Created by X on Y" via the
  token's existing created_by/created_on) -- net-new UI on top of the
  already-existing PUT /:id route.
- Create modal gained a Description field and now uses
  app.modal.footerButtons() for its Cancel/Create pair.
- Standardized status badges on Bootstrap 5's text-bg-* classes.
- Bumped @simpleworkjs/frontend to ^0.2.6 (footer/footerButtons support;
  this app was still on ^0.2.5) and added the missing app.apiToken.update()
  client wrapper (list/add/remove/rotate already existed).

Found and fixed a real bug along the way: the planned "flash a checkmark on
copy" touch (porting sso-manager-node's copyField pattern) silently does
nothing once FontAwesome replaces <i> icons with inline <svg> -- there's no
<i> left to swap classes on. Renamed the existing copySshCommand() (already
used by the Quick Jump feature, toast-based, unaffected by that FA
behavior) to copyFieldValue() and reused it for the token-reveal copy
button instead of introducing a second, broken copy mechanism.

Verified live: card grid renders with truncated token ID; Edit modal shows
real created-by/on data, saves a description change, and the card
refreshes; Create modal's new description field round-trips; secret-reveal
copy button fires the toast correctly for a real (non-programmatic) click.
2026-07-28 20:07:48 -04:00
wmantly 8a76f71edd Release 1.9.0: Quick Jump copy-to-clipboard section (#23) 2026-07-28 18:11:40 -04:00
wmantly e482f52f10 Add a Quick Jump copy-to-clipboard section to the dashboard (#22)
The uid_-_target grammar-mode SSH command was documented in the README but
nowhere in the UI itself -- users had to remember/reconstruct the format by
hand. Adds a "Quick Jump" card with a one-click-copy command for the
interactive-picker form, plus a copy button on every row of "Hosts you can
reach" that copies the exact grammar-mode command for that specific host
(using the logged-in user's own uid, so it's ready to paste and run as-is).

conf.ssh.listenPort is now passed to the dashboard view so the command can
include the right -p flag when the SSH front door isn't on the default port
22 (theta-env, for example, exposes it on 2222).

Verified live: logged in as the local admin user, confirmed the Quick Jump
command and a per-host command both populate correctly and copy to the
clipboard (toast confirmation), and that the per-host command matches the
exact uid_-_target grammar the SSH server's parseUsername expects.
2026-07-28 18:10:17 -04:00
51 changed files with 3056 additions and 344 deletions
+105
View File
@@ -1,9 +1,114 @@
## v2.1.1
- fix: **mesh peer removal now cleans up its kernel routes.** `wg_iface.removePeer()` dropped the WireGuard peer entry but left the `ip route` entries `setPeer()` had added, so a removed peer's subnet stayed routed into a dead tunnel. Fixed by querying `wg show <iface> allowed-ips` before removal and `ip route del`-ing each CIDR. Verified live: routes present after `setPeer`, gone after `removePeer`, this gateway's own local route untouched. Exposed via `DELETE /api/mesh/gateways/:id` + a remove button in the mesh UI.
- feat: **`GET /api/mesh/self`** — this gateway's own mesh IP, gated by any valid self-service API token rather than a full jump-admin session, so an unattended local script (e.g. `theta-suite`'s no-inbound relay bootstrap) can discover it without admin credentials.
- fix: **`/api/mesh/register` was unreachable via HTTP.** `routes/api.js` mounted `/` (admin-session-gated `routes/jump.js`) before `/mesh`; since `router.use('/', ...)` matches every `/api/*` path, every `/api/mesh/*` request — including `/register`, authenticated by a bearer mesh join token, not an admin session — hit that admin gate first and 401'd before `routes/mesh.js` ever ran. A real gateway-to-gateway `/join` call failed with a `checkApiToken`/`LoginFailed` error instead of registering. Found live-testing the new `/self` endpoint with two real containers; fixed by mounting `/mesh` first.
- fix: **the initiating side of a mesh join never recorded its own identity.** `POST /register` (the receiving side) persists a `(self)` registry entry via `ensureOwnMeshIndex()`, but `POST /join` (the initiating side) never did, so `GET /api/mesh/self` and the mesh UI's own-entry handling silently saw nothing on whichever gateway called `/join`. Fixed by registering a self-entry there too, using the exact mesh index the remote assigned (`models/mesh_gateway.js`'s `register()` now accepts an explicit `meshIndex` instead of always auto-picking one from the local registry). Verified with two real meshed containers: both sides now report their own correct mesh IP.
## v2.1.0
- feat: **Gateway-to-gateway WireGuard mesh** (`routes/mesh.js`) — real site-to-site tunnels between theta-gateway instances, distinct from the existing roaming-client/exit-node WireGuard feature. Join-token bootstrap (`POST /api/mesh/join-tokens`, `/register`, `/join`), mesh-index addressing (172.24.\<idx\>.0/16 + 10.\<idx\>.0.0/16, per `theta-suite`'s `docs/MULTI_SITE_SPEC.md`).
- feat: **In-kernel WireGuard with a userspace fallback** (`utils/wg_iface.js`) — prefers `ip link add type wireguard`, falls back to `wireguard-go` when the kernel module isn't available (older/hardened kernels, some container images, non-Linux). Both packages added to the Dockerfile.
- feat: **mDNS local-discovery announcer** (`services/mdns_announce.js`) — advertises which public hostnames this site fronts (opt-in via `THETA_LOCAL_DISCOVERY_HOSTS`) so a `theta-agent` on the same LAN segment can skip the relay/WAN path. Companion piece to `theta-agent`'s discovery listener.
- feat: **Mesh UI** (`/mesh`) — gateway identity (interface, kernel-vs-userspace mode), join-token minting, remote-join form, meshed-gateways table.
- Verified with real two-container tests, not mocks: an actual encrypted WireGuard tunnel passing ICMP traffic end to end (0% loss), and the mDNS announce/discover/apply/revert cycle over real multicast. Two real bugs found and fixed along the way: `wg set ... allowed-ips` doesn't add a kernel route (a real handshake completed with zero routing, `ping` still failed, until `setPeer()` was fixed to add `ip route add` itself); and mDNS's default IPv6 query aborting the entire lookup — discarding an already-valid IPv4 response — when IPv6 isn't available.
## v2.0.1
- docs: **Rebranded to Theta Gateway across the docs.** README title/links updated; removed the "Standalone Docker" and "Bare metal" install paths, which contradicted the Deployment section's own "exclusively via Docker Compose within Theta Suite" claim. Fixed stale links to the old per-repo GitHub Pages sites (`sso-manager-node`, `theta-env`) — now point at the unified `theta42.github.io/theta-suite/` docs site.
## v2.0.0
- feat: **WireGuard Gateway Management UI & API.** Integrated complete WireGuard exit node management (`/wireguard`), client peer creation with instant QR code rendering and `.conf` configuration file downloads.
- feat: **Automatic WireGuard Bootstrap.** Automatically generates an X25519 gateway keypair on initial boot if missing and registers the local default exit node (`718it (This Site)`).
- feat: **Query Token Authentication.** Added `?token=` parameter fallback to `middleware/auth.js` for direct browser `.conf` profile file downloads.
- fix: **UI Confirm Banners.** Added `actionMessage` container placeholders to cards for `app.messages.confirm()` rendering.
## v1.19.1
- docs: README.md and docs/architecture.md described host-access authorization as a client-side loop over each of a user's LDAP groups (`GET /api/discovery/resources?group=<cn>` per group). The actual code (`utils/access.js`, `accessibleHosts()`) makes one call to the SSO's `GET /api/discovery/access/:uid`, which resolves the user's groups server-side. Corrected both.
## v1.19.0
- fix: **only catalog hosts are jump targets.** `isManagedHost` treated a missing `metadata.managed` flag as permission, so any host the SSO merely *discovered* — an unpromoted Proxmox guest, a UniFi client — was offered in the TUI picker and accepted by the username grammar. The filter is now `isCatalogHost`, mirroring the SSO Directory's own rule: a resource carrying `discovery_sources` but never promoted is excluded, while hand-created hosts (no `discovery_sources`) and promoted ones (`managed: true`) are included, and an explicit `managed: false` is always excluded.
- test: regression coverage for all five cases (hand-made, discovered-unpromoted, discovered-promoted, `manual` source, explicitly unmanaged).
- docs: `docs/connecting.md` states that discovery results are not jump targets until promoted into the catalog.
## v1.18.0
- feat: Add SSO-style error page (404/500) for browser navigation instead of a bare text response
- feat: navbar — username no longer underlined; only the active link is bold + underlined
## v1.16.1
- fix: remove missing DEPLOYMENT.md from Docker build context
## v1.16.0
- Added OpenBao PKI SSH Certificate Support
- Fallback to LDAP Key injection
# v1.15.0
- feat: Rename SSO Manager to Jump in UI
# Changelog
All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.14.1] - 2026-08-01
### Fixed
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
jump host exit at boot in any deployment without an OpenBao sidecar
(standalone Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing
token (warn + continue from `CONF_SECRETS`), matching the documented
contract. The theta-env stack is unaffected (it always sets a scoped
`VAULT_TOKEN`).
## [1.14.0] - 2026-08-01
### Changed
- **Secrets now load from OpenBao at boot** via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/jump-host/conf` over the file-loaded config. The jump
host authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy
`jump-host` — read-only on its own path), never the root token. Because the
OIDC `clientSecret` is captured at require time inside `createOidcClient`
(during `require('../models')`), `bin/www` now runs `bao-conf.init()`
**before** `require('../models')`. Fail-soft: if OpenBao is unreachable,
boot continues from `CONF_SECRETS`. The `config/jump-secrets.js` file is now
an operator-edit seed artifact (gitignored); OpenBao is authoritative. See
theta-env's [Secrets docs](https://theta42.github.io/theta-env/secrets/).
- Bumped package version to track the release tag.
## [1.11.0] - 2026-07-30
### Added
- **`app_super_admin` (cross-app) and `app_jump_admin` groups**: super admins are full admins here same as `app_sso_admin`; jump admins get audit page/data access without other admin rights. The Audit page/API is now actually admin-gated server-side (previously the page shell rendered for any logged-in user, only its data was gated).
- **Host list adds Last connection/Last failed connection columns** and highlights rows green (a session is live right now) or yellow (the most recent attempt failed), backed by new per-host last-success/last-fail timestamps in `models/metrics.js`. `services/ssh_server.js` now attributes grammar/TUI connect failures to the resolved host when one was found, not just aggregate counters.
### Changed
- **Dashboard's stat boxes and Top hosts/Top users cards moved to the Audit page** (audit is now the admin-facing metrics home; dashboard stays focused on "hosts I can reach"). "All hosts" renamed to "My hosts".
## [1.10.2] - 2026-07-30
### Changed
- **Dashboard, Sessions, and Audit pages now match sso-manager-node/proxy's page width**, wrapping content in a standard container instead of rendering full-bleed inside the fluid shell.
- **Audit's nav entry is now admin-gated** (`groups: ['admin']` in `utils/ui.js`), reusing the existing synthetic-admin-group nav-gating convention — the API route was already server-side admin-gated; this hides the nav link for non-admins too.
## [1.10.1] - 2026-07-28
### Fixed
- **The API-token reveal modal silently didn't show after creating a token** — `submitApiToken()` called `app.modal.close()` immediately before `showToken()`'s `app.modal.open()` in the same tick, colliding with Bootstrap's hide-transition guard on the singleton modal. Same root cause as the OAuth-secret-reveal race fixed in sso-manager-node (v1.8.2) and the create-token race fixed in proxy (v1.7.0).
## [1.10.0] - 2026-07-28
### Added
- **API-token UI unified with sso-manager-node/proxy**: card grid replacing the bare table, a new Edit modal (footer shows real created-by/on data), and a Description field on both the create and edit flows — the model and API already fully supported all of this, it just wasn't exposed anywhere in the dashboard.
### Changed
- `@simpleworkjs/frontend` bumped to `^0.2.6` (this app was still on `^0.2.5`).
## [1.9.0] - 2026-07-28
### Added
- **"Quick Jump" copy-to-clipboard section on the dashboard** — the `uid_-_target` grammar-mode SSH command was documented in the README but nowhere in the UI. A new card gives a one-click-copy command for interactive-picker mode, and every row in "Hosts you can reach" has its own copy button for the exact grammar-mode command to that host, ready to paste and run as-is (uses the logged-in user's own uid).
## [1.8.2] - 2026-07-28
### Fixed
+2 -1
View File
@@ -17,6 +17,7 @@ FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
redis-server dumb-init ca-certificates \
iproute2 wireguard-tools wireguard-go \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -37,7 +38,7 @@ COPY nodejs/utils ./utils
COPY nodejs/views ./views
COPY nodejs/public ./public
COPY README.md CHANGELOG.md DEPLOYMENT.md /
COPY README.md CHANGELOG.md /
COPY --from=gitinfo /commit.txt ./.build_commit
COPY docker-entrypoint.sh /usr/local/bin/
+47 -107
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
@@ -159,6 +83,22 @@ Config layers via [@simpleworkjs/conf](https://www.npmjs.com/package/@simplework
`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env.
See `secrets.js.example` for every key.
## Secrets
At boot, [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
deep-merges `secret/jump-host/conf` from **OpenBao** over the file-loaded
config. The jump host's OIDC `clientSecret` is captured at require time
(inside `createOidcClient` during `require('../models')`), so `bin/www` runs
`bao-conf.init()` **before** `require('../models')`. Fail-soft: if OpenBao is
unreachable, boot continues from `CONF_SECRETS`. The jump host authenticates to
OpenBao with the scoped `VAULT_TOKEN` (env, policy `jump-host` — read only
`secret/jump-host/conf`), never the root token.
The `config/jump-secrets.js` file is an operator-edit seed artifact
(gitignored); the bootstrap writes the generated API token + OAuth client
into OpenBao, which is authoritative. For the full architecture see
theta-suite's **[Secrets docs](https://theta42.github.io/theta-suite/secrets.html)**.
## Development
```
+7 -5
View File
@@ -41,11 +41,13 @@ Every attempt — success or failure, with method and reason — is audited.
## 2. Access & target resolution
The hosts a user may reach are computed from the directory, not a local list:
1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`).
2. For each group, the SSO's
`GET /api/discovery/resources?group=<cn>` (authenticated with an API token),
unioned and filtered to `kind: host`.
the jump host calls the SSO's `GET /api/discovery/access/:uid` (authenticated
with an API token) once per user; the SSO evaluates the user's LDAP group
memberships server-side and returns the full access projection in one
response, already filtered to `kind: host`. (The jump host also has an
admin-only `allHosts()` path, used for the unfiltered catalog listing, which
does call `GET /api/discovery/resources?group=<cn>` per group — but that's
not the per-user authorization path.)
Each host's dial address is `metadata.ip` (or the hostname from
`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached
+11 -3
View File
@@ -66,14 +66,22 @@ directory access allows — it doubles as "what can I reach from here?"
## What you can reach
The set of hosts is computed per login: your LDAP group memberships intersected
with the SSO directory's hosts (via the `host_<name>_access` groups the
directory auto-creates for each machine). To get access to a new host, an admin
adds you to that host's access group in the SSO — nothing on the jump host
with the SSO directory's **catalog** hosts (via the `host_<name>_access` groups
the directory auto-creates for each machine). To get access to a new host, an
admin adds you to that host's access group in the SSO — nothing on the jump host
changes.
Targets that don't resolve to a host you're allowed to reach are refused (and
audited). Raw IPs that aren't a known directory host are denied by default.
**Only catalog hosts are jump targets.** A machine that the SSO merely
*discovered* — a Proxmox guest, a UniFi client — is not a jump target until an
admin promotes it into the directory catalog. The jump host applies the same
rule the SSO's own Directory listing does: a resource carrying
`discovery_sources` but never promoted is excluded, while hand-created hosts and
promoted ones are included. Previously the filter treated a missing `managed`
flag as permission, so unpromoted discovery results showed up in the picker.
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
> every registered host is reachable by every registered user — there's no
> group-based restriction to ask an admin about.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+11 -1
View File
@@ -4,6 +4,8 @@ const express = require('express');
const compression = require('compression');
require('./models'); // wire model-redis + register models
const conf = require('@simpleworkjs/conf');
const buildInfo = require('./utils/build_info');
const app = express();
@@ -41,7 +43,15 @@ app.use((err, req, res, next) => {
if(req.path.startsWith('/api/')){
return res.status(status).json({name: err.name || 'Error', message: err.message || 'Error'});
}
res.status(status).send(err.message || 'Error');
// Browser navigation gets the HTML error page (shared with SSO).
res.status(status).render('error', {
title: conf.environment !== 'production' ? 'dev' : '',
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
logo: conf.logo,
...buildInfo,
error: err,
});
});
module.exports = app;
+38 -23
View File
@@ -9,32 +9,47 @@ const http = require('http');
const conf = require('@simpleworkjs/conf');
const { Server } = require('socket.io');
require('../models');
// @simpleworkjs/conf loads ./config/jump-secrets.js synchronously, then
// @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from OpenBao over
// it. The OIDC clientSecret is captured at require time inside models (via
// createOidcClient), so the fetch MUST resolve before require('../models').
// Fail-soft: if OpenBao is unreachable, init() leaves conf as the file-loaded
// fallback and boot continues from ./config/jump-secrets.js.
require('@simpleworkjs/bao-conf').init({ path: 'jump-host', conf }).then(async () => {
require('../models');
const { bootstrapWireguard } = require('../services/wg_bootstrap');
await bootstrapWireguard();
const { startMdnsAnnounce } = require('../services/mdns_announce');
await startMdnsAnnounce();
const app = require('../app');
const middleware = require('../middleware/auth');
const sshServer = require('../services/ssh_server');
const app = require('../app');
const middleware = require('../middleware/auth');
const sshServer = require('../services/ssh_server');
const webPort = (conf.web && conf.web.port) || 3002;
const server = http.createServer(app);
const webPort = (conf.web && conf.web.port) || 3002;
const server = http.createServer(app);
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
// We don't push anything yet, but serving /socket.io keeps the shared front-end
// working exactly as it does in the sibling apps.
const io = new Server(server);
io.use(middleware.authIO);
app.io = io;
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
// We don't push anything yet, but serving /socket.io keeps the shared front-end
// working exactly as it does in the sibling apps.
const io = new Server(server);
io.use(middleware.authIO);
app.io = io;
server.listen(webPort, () => {
console.log(`[web] jump-host UI/API on :${server.address().port}`);
});
server.listen(webPort, () => {
console.log(`[web] jump-host UI/API on :${server.address().port}`);
});
sshServer.start();
sshServer.start();
function shutdown() {
console.log('[jump-host] shutting down');
server.close();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
function shutdown() {
console.log('[jump-host] shutting down');
server.close();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}).catch(err => {
console.error('boot failed:', err);
process.exit(1);
});
+23 -2
View File
@@ -6,7 +6,7 @@
// values (LDAP creds, SSO API token) belong in the secrets file.
module.exports = {
name: 'Jump Host',
name: 'Jump',
logo: '/static/img/theta42.svg',
// LDAP directory the users live in (same directory the SSO manages).
@@ -80,7 +80,12 @@ module.exports = {
auth: {
// OIDC group memberships that grant web UI/API admin access.
adminGroups: ['app_sso_admin'],
// app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
adminGroups: ['app_sso_admin', 'app_super_admin'],
// OIDC group memberships that grant jump admin access (the audit page
// and its data), without granting other admin-only rights. Full admins
// (adminGroups/adminUsers) always have jump admin access too.
jumpAdminGroups: ['app_jump_admin'],
// Local anti-lockout admin: the first name here is bootstrapped as a
// redis-backed user on first boot (password from localAdminPass, or a
// random one printed to the log once). Lets you in even with OIDC down.
@@ -115,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',
},
};
+22 -2
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();
@@ -56,6 +57,25 @@ async function requireAdmin(req, res, next){
next(error);
}
// Jump admin = access to the audit page/data. A narrower grant than full
// jump-host admin: full admins (isAdmin) always qualify, plus anyone in
// conf.auth.jumpAdminGroups (e.g. a dedicated app_jump_admin LDAP group) can
// be granted audit access without also getting other admin-only rights.
function isJumpAdmin(req){
if(isAdmin(req)) return true;
const jumpAdminGroups = (conf.auth && conf.auth.jumpAdminGroups) || [];
return (req.groups || []).some(g => jumpAdminGroups.includes(g));
}
async function requireJumpAdmin(req, res, next){
if(isJumpAdmin(req)) return next();
const error = new Error('Forbidden');
error.name = 'Forbidden';
error.status = 403;
error.message = 'Jump admin access required.';
next(error);
}
// Socket.IO handshake auth (app-base.js connects with the session token).
async function authIO(socket, next){
try{
@@ -69,4 +89,4 @@ async function authIO(socket, next){
}
}
module.exports = { auth, requireAdmin, authIO, isAdmin };
module.exports = { auth, requireAdmin, authIO, isAdmin, isJumpAdmin, requireJumpAdmin };
+103
View File
@@ -0,0 +1,103 @@
'use strict';
// Registry of peer theta-gateway instances this gateway has meshed with —
// raw Redis, same pattern as wg_site.js/audit_event.js. Each registration
// carries what's needed to configure a local WireGuard peer entry for them:
// public key, reachable endpoint, and the mesh IP this gateway assigned them
// (MULTI_SITE_SPEC.md's one-octet-per-site addressing, 172.24.<idx>.0/16 +
// 10.<idx>.0.0/16, idx 1-254).
//
// Redis keys:
// mesh_gateway:<id> — hash of gateway fields
// mesh_gateway_index — sorted set (score = createdAt, value = id)
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const { getRedis } = require('./index');
const MAX_MESH_INDEX = 254;
const P = () => conf.redis.prefix;
const idxKey = () => `${P()}mesh_gateway_index`;
const gatewayKey = (id) => `${P()}mesh_gateway:${id}`;
function serialize(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = String(v == null ? '' : v);
}
return out;
}
function deserialize(h) {
if (!h || !h.id) return null;
return { ...h, meshIndex: Number(h.meshIndex || 0), createdAt: Number(h.createdAt || 0), lastSeenAt: Number(h.lastSeenAt || 0) };
}
async function list() {
const redis = await getRedis();
const ids = await redis.zRange(idxKey(), 0, -1);
const out = [];
for (const id of ids) {
const g = deserialize(await redis.hGetAll(gatewayKey(id)));
if (g) out.push(g);
}
return out;
}
async function findByPublicKey(publicKey) {
const all = await list();
return all.find((g) => g.publicKey === publicKey) || null;
}
function nextFreeMeshIndex(existing) {
const used = new Set(existing.map((g) => g.meshIndex).filter(Boolean));
for (let i = 1; i <= MAX_MESH_INDEX; i++) {
if (!used.has(i)) return i;
}
throw new Error(`Mesh index space exhausted (max ${MAX_MESH_INDEX} gateways)`);
}
// Register (or re-register, idempotent by publicKey) a peer gateway.
// Re-registering the same public key updates its endpoint/siteSlug but
// reuses its existing mesh index -- a gateway that re-registers after a
// restart must not get bumped to a new mesh subnet.
//
// meshIndex is normally auto-assigned (the next free local index) -- correct
// when THIS gateway is the one handing out indices (POST /register, for both
// the caller and itself via ensureOwnMeshIndex). But the INITIATING side of
// a join (POST /join) doesn't get to pick its own index -- the remote
// already assigned it and returned it in the response -- so an explicit
// meshIndex is accepted to record that exact value instead of whatever this
// gateway's own local registry would have auto-picked (which has no reason
// to agree with the value actually configured on the live wg0 interface).
async function register({ publicKey, endpoint, siteSlug, meshIndex: explicitMeshIndex }) {
const redis = await getRedis();
const existing = await list();
const already = existing.find((g) => g.publicKey === publicKey);
const now = Date.now();
if (already) {
const updated = { ...already, endpoint, siteSlug: siteSlug || already.siteSlug, lastSeenAt: now };
await redis.hSet(gatewayKey(already.id), serialize(updated));
return updated;
}
const id = crypto.randomBytes(8).toString('hex');
const meshIndex = explicitMeshIndex || nextFreeMeshIndex(existing);
const gateway = { id, publicKey, endpoint, siteSlug: siteSlug || '', meshIndex, createdAt: now, lastSeenAt: now };
await redis.hSet(gatewayKey(id), serialize(gateway));
await redis.zAdd(idxKey(), { score: now, value: id });
return gateway;
}
async function remove(id) {
const redis = await getRedis();
const gw = deserialize(await redis.hGetAll(gatewayKey(id)));
if (!gw) return null;
await redis.del(gatewayKey(id));
await redis.zRem(idxKey(), id);
return gw;
}
module.exports = { list, findByPublicKey, register, remove, MAX_MESH_INDEX };
+26 -2
View File
@@ -13,10 +13,34 @@ async function bump({ uid, hostSlug, success }) {
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
if (!success) ops.push(redis.incr(`${P()}fail`));
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
if (hostSlug) ops.push(redis.incr(`${P()}host_${hostSlug}`));
if (hostSlug) {
ops.push(redis.incr(`${P()}host_${hostSlug}`));
// Last-attempt timestamp per host, split by outcome -- drives the
// dashboard's "Last connection"/"Last failed connection" columns and
// row highlighting (see lastForHosts below).
ops.push(redis.set(`${P()}host_last_${success ? 'success' : 'fail'}_${hostSlug}`, Date.now()));
}
await Promise.all(ops);
}
// Per-host last-success/last-fail timestamps for a given list of slugs (e.g.
// the hosts a session can reach), for the dashboard's host list.
async function lastForHosts(slugs) {
const redis = await getRedis();
const result = {};
await Promise.all((slugs || []).map(async (slug) => {
const [lastSuccess, lastFail] = await Promise.all([
redis.get(`${P()}host_last_success_${slug}`),
redis.get(`${P()}host_last_fail_${slug}`),
]);
result[slug] = {
lastConnected: lastSuccess ? Number(lastSuccess) : null,
lastFailed: lastFail ? Number(lastFail) : null,
};
}));
return result;
}
async function summary() {
const redis = await getRedis();
const [total, fail] = await Promise.all([
@@ -37,4 +61,4 @@ async function summary() {
};
}
module.exports = { bump, summary };
module.exports = { bump, summary, lastForHosts };
+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 };
+381 -8
View File
@@ -1,23 +1,25 @@
{
"name": "t42-jump-host",
"version": "1.5.0",
"name": "theta-gateway",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-jump-host",
"version": "1.5.0",
"name": "theta-gateway",
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
"bonjour-service": "^1.4.4",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^3.1.10",
@@ -29,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"
@@ -61,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",
@@ -156,6 +165,18 @@
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/bao-conf": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/conf": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
@@ -178,9 +199,9 @@
}
},
"node_modules/@simpleworkjs/frontend": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -305,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",
@@ -462,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",
@@ -574,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",
@@ -608,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",
@@ -617,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",
@@ -759,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",
@@ -801,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",
@@ -843,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",
@@ -1073,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",
@@ -1122,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",
@@ -1170,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",
@@ -1404,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",
@@ -1491,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",
@@ -1648,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",
@@ -1881,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",
@@ -1890,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",
@@ -1925,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",
@@ -1992,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",
@@ -2094,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",
@@ -2280,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",
@@ -2568,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",
@@ -2640,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",
@@ -2860,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",
@@ -2869,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",
@@ -2896,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",
@@ -2904,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"
}
}
}
}
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "t42-jump-host",
"version": "1.8.2",
"name": "theta-gateway",
"version": "2.1.1",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
@@ -14,20 +14,22 @@
"scripts": {
"start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www",
"test": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js test/integration/*.test.js",
"test": "NODE_ENV=test node --test --test-force-exit test/unit/access.test.js test/unit/host_keys.test.js test/unit/no_native_dialogs.test.js test/unit/target_match.test.js test/unit/username_grammar.test.js test/unit/wireguard.test.js test/unit/mesh_addressing.test.js",
"test:unit": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js",
"test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
"bonjour-service": "^1.4.4",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^3.1.10",
@@ -39,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"
+12
View File
@@ -3,10 +3,22 @@ nav.navbar{
padding-right: 1em;
}
/* Only the active top-nav link is bold + underlined; the username is plain. */
.top-nav a.active{
font-weight: bold;
text-decoration: underline;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
/* Height of the fixed navbar (plus the update banner, while shown --
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
sticky element offset itself below both fixed elements via
`top: var(--sw-content-offset)` instead of colliding with them at the
viewport's true top:0. */
--sw-content-offset: 4.5rem;
}
#spa-shell {
+2 -1
View File
@@ -19,9 +19,10 @@ app.jump = (function(app){
app.apiToken = (function(app){
function list(cb){ app.api.get('api-token/', cb); }
function add(args, cb){ app.api.post('api-token/', args, cb); }
function update(args, cb){ app.api.put('api-token/' + args.id, args, cb); }
function remove(id, cb){ app.api.delete('api-token/' + id, cb); }
function rotate(id, cb){ app.api.post('api-token/' + id + '/rotate', {}, cb); }
return {list: list, add: add, remove: remove, rotate: rotate};
return {list: list, add: add, update: update, remove: remove, rotate: rotate};
})(app);
// Shared render helpers.
+21
View File
@@ -584,10 +584,31 @@ app.util = (function(app){
document.body.removeChild(element);
}
// Scroll a just-added/-edited element into view and flash its
// background, so the user's eye lands on the row that changed instead of
// it silently appearing/updating somewhere off-screen. Takes a jQuery
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
function revealItem(el){
var node = el && el.jquery ? el[0] : el;
if (!node) return;
if (typeof node.scrollIntoView === 'function') {
node.scrollIntoView({behavior: 'smooth', block: 'center'});
}
var prevTransition = node.style.transition;
var prevBg = node.style.backgroundColor;
node.style.transition = 'background-color 1.5s ease';
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
setTimeout(function(){
node.style.backgroundColor = prevBg;
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
}, 300);
}
return {
downloadFile: downloadFile,
getUrlParameter: getUrlParameter,
escapeHtml: escapeHtml,
revealItem: revealItem,
}
})(app);
+16 -2
View File
@@ -13,7 +13,21 @@ router.use('/user', middleware.auth, require('./user'));
// admin gate (see routes/api_token.js for why a token can't reach admin routes).
router.use('/api-token', middleware.auth, require('./api_token'));
// Jump-host data — admin only (audit log, active sessions, metrics).
router.use('/', middleware.auth, middleware.requireAdmin, 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.
// MUST be registered before the '/' mount below: '/' matches every /api/*
// path (it's the catch-all for routes/jump.js), so registering it first
// would shadow every /api/mesh/* route with admin-session auth before
// routes/mesh.js's own per-route gates ever ran -- confirmed live, this
// silently 401'd /register's bearer-join-token callers with a
// checkApiToken/LoginFailed error instead of ever reaching mesh.js.
router.use('/mesh', require('./mesh'));
// Jump-host data — jump admin only (audit log, active sessions, metrics).
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
module.exports = router;
+212
View File
@@ -0,0 +1,212 @@
'use strict';
// Gateway-to-gateway WireGuard mesh — real site-to-site tunnels, not the
// roaming-client/exit-node feature in routes/wireguard.js. Two gateways mesh
// by one calling the other's POST /api/mesh/register with a join token; both
// sides end up with a live wg0 peer entry for the other, addressed per
// MULTI_SITE_SPEC.md's one-octet mesh index (172.24.<idx>.0/16,
// 10.<idx>.0.0/16, idx 1-254, assigned by whichever gateway is registering
// the caller).
//
// Local interface name is fixed at THETA_MESH_IFACE (default wg-mesh) —
// deliberately separate from the roaming-client interface so the two
// features never fight over the same wg0.
const express = require('express');
const middleware = require('../middleware/auth');
const conf = require('@simpleworkjs/conf');
const meshGateway = require('../models/mesh_gateway');
const meshJoinToken = require('../utils/mesh_join_token');
const wgIface = require('../utils/wg_iface');
const wgKeys = require('../utils/wg_keys');
const { meshCidrFor, meshAllowedIpsFor } = require('../utils/mesh_addressing');
const router = express.Router();
const IFACE = process.env.THETA_MESH_IFACE || 'wg-mesh';
const MESH_LISTEN_PORT = process.env.THETA_MESH_LISTEN_PORT || 51820;
async function ensureLocalIdentity() {
if (!conf.wireguard) conf.wireguard = {};
if (!conf.wireguard.serverPublicKey || !conf.wireguard.serverPrivateKey) {
// wg_bootstrap.js normally does this at startup; guard here too so this
// route works even if bootstrap hasn't run yet in a given environment.
const kp = wgKeys.generateKeypair();
conf.wireguard.serverPublicKey = kp.publicKey;
conf.wireguard.serverPrivateKey = kp.privateKey;
}
return conf.wireguard;
}
// Mint a single-use mesh join token — the credential a NEW gateway presents
// to register into THIS gateway's mesh.
router.post('/join-tokens', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
try {
const { token, expiresInSeconds } = await meshJoinToken.mint();
res.json({ status: 'ok', token, expiresInSeconds });
} catch (e) { next(e); }
});
// Called by a REMOTE gateway to register itself into THIS gateway's mesh.
// Bearer mesh join token, no admin session (service-to-service, same as
// theta-directory's POST /api/site/register-spoke pattern).
router.post('/register', async (req, res, next) => {
try {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
if (!(await meshJoinToken.consume(token))) {
return res.status(401).json({ status: 'error', message: 'invalid or already-used mesh join token' });
}
const { publicKey, endpoint, siteSlug } = req.body || {};
if (!publicKey || !endpoint) {
return res.status(400).json({ status: 'error', message: 'publicKey and endpoint are required' });
}
const self = await ensureLocalIdentity();
const peer = await meshGateway.register({ publicKey, endpoint, siteSlug });
await wgIface.ensureInterface(IFACE);
wgIface.setPrivateKey(IFACE, self.serverPrivateKey, MESH_LISTEN_PORT);
// This gateway's own mesh index -- assigned to ITSELF the first time
// anyone registers with it, since a solo gateway has no index yet.
const ownIndex = await ensureOwnMeshIndex();
wgIface.setAddress(IFACE, meshCidrFor(ownIndex));
wgIface.setPeer(IFACE, {
publicKey: peer.publicKey,
endpoint: peer.endpoint,
allowedIPs: meshAllowedIpsFor(peer.meshIndex),
keepalive: 25
});
res.json({
status: 'ok',
meshIndex: peer.meshIndex,
gateway: {
publicKey: conf.wireguard.serverPublicKey,
endpoint: conf.wireguard.serverEndpoint || '',
meshIndex: ownIndex
}
});
} catch (e) { next(e); }
});
// This gateway's own mesh index is just "the lowest free index, stable once
// picked" -- stored as a synthetic self-entry in the same registry so it
// survives restarts the same way peer entries do.
async function ensureOwnMeshIndex() {
const self = await meshGateway.findByPublicKey(conf.wireguard.serverPublicKey);
if (self) return self.meshIndex;
const created = await meshGateway.register({
publicKey: conf.wireguard.serverPublicKey,
endpoint: conf.wireguard.serverEndpoint || '',
siteSlug: '(self)'
});
return created.meshIndex;
}
// Admin-initiated: join THIS gateway into a remote gateway's mesh. Generates
// (or reuses) this gateway's identity, brings up the local interface, calls
// the remote's /register, and applies the peer it gets back -- so after this
// call both sides have a live, working wg0 peer entry for each other.
router.post('/join', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
try {
const { remoteEndpoint, joinToken } = req.body || {};
if (!remoteEndpoint || !joinToken) {
return res.status(400).json({ status: 'error', message: 'remoteEndpoint and joinToken are required' });
}
const self = await ensureLocalIdentity();
await wgIface.ensureInterface(IFACE);
wgIface.setPrivateKey(IFACE, self.serverPrivateKey, MESH_LISTEN_PORT);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
let resp;
try {
resp = await fetch(String(remoteEndpoint).replace(/\/+$/, '') + '/api/mesh/register', {
method: 'POST',
headers: { Authorization: 'Bearer ' + joinToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
publicKey: self.serverPublicKey,
endpoint: self.serverEndpoint || '',
siteSlug: process.env.SITE_SLUG || ''
}),
signal: controller.signal
});
} finally { clearTimeout(timer); }
if (!resp.ok) {
const text = (await resp.text().catch(() => '')).slice(0, 300);
return res.status(502).json({ status: 'error', message: 'remote registration failed: HTTP ' + resp.status + ' ' + text });
}
const data = await resp.json();
wgIface.setAddress(IFACE, meshCidrFor(data.meshIndex));
// Persist OUR OWN identity too, not just the remote peer's -- the
// receiving side of /register does this via ensureOwnMeshIndex(), but
// the initiating side (here) never did, so GET /api/mesh/self and the
// mesh UI's own-entry/"(self)" handling both silently saw nothing on
// whichever gateway called /join. register() is upsert-by-publicKey
// and reuses an existing entry's index, so this is safe to call even
// if a self-entry from a PRIOR /register (as the receiving side of a
// different peer) already exists.
await meshGateway.register({ publicKey: self.serverPublicKey, endpoint: self.serverEndpoint || '', siteSlug: '(self)', meshIndex: data.meshIndex });
await meshGateway.register({ publicKey: data.gateway.publicKey, endpoint: data.gateway.endpoint, siteSlug: '(remote master)' });
wgIface.setPeer(IFACE, {
publicKey: data.gateway.publicKey,
endpoint: data.gateway.endpoint,
allowedIPs: meshAllowedIpsFor(data.gateway.meshIndex),
keepalive: 25
});
res.json({ status: 'ok', meshIndex: data.meshIndex, peerMeshIndex: data.gateway.meshIndex });
} catch (e) { next(e); }
});
// This gateway's own mesh address, for a LOCAL bootstrap script to discover
// (e.g. theta-suite's site-join, running on the same host as this gateway)
// without needing full jump-admin session auth -- any valid jmp_ API token
// (middleware.auth, no requireJumpAdmin) is enough, same service-to-service
// pattern as theta-proxy's prx_ tokens for proxy_client.js. Not a peer
// listing, so no admin-only audit/config data is exposed here.
router.get('/self', middleware.auth, async (req, res, next) => {
try {
const self = conf.wireguard || {};
let meshIp = null;
if (self.serverPublicKey) {
const entry = await meshGateway.findByPublicKey(self.serverPublicKey);
if (entry) meshIp = meshCidrFor(entry.meshIndex).split('/')[0];
}
res.json({ status: 'ok', meshIp, joined: !!meshIp, iface: IFACE });
} catch (e) { next(e); }
});
router.get('/gateways', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
try {
const gateways = await meshGateway.list();
res.json({ status: 'ok', gateways, iface: IFACE, kernelWireguard: wgIface.kernelWireguardAvailable() });
} catch (e) { next(e); }
});
// Remove a peer gateway: tears down its local WG peer entry + kernel routes
// (wgIface.removePeer) and drops it from the registry. Does NOT reach out to
// the remote gateway to remove the reciprocal peer entry there -- an admin
// on that side needs to do the same. Refuses to remove the self-entry
// ("(self)"), since that's this gateway's own identity, not a peer.
router.delete('/gateways/:id', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
try {
const gateways = await meshGateway.list();
const target = gateways.find((g) => g.id === req.params.id);
if (!target) return res.status(404).json({ status: 'error', message: 'gateway not found' });
if (target.siteSlug === '(self)') {
return res.status(400).json({ status: 'error', message: 'cannot remove this gateway\'s own self-entry' });
}
wgIface.removePeer(IFACE, target.publicKey);
await meshGateway.remove(target.id);
res.json({ status: 'ok', removed: { id: target.id, siteSlug: target.siteSlug, meshIndex: target.meshIndex } });
} catch (e) { next(e); }
});
module.exports = router;
+6
View File
@@ -14,6 +14,10 @@ const values = {
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
logo: conf.logo,
// The SSH front door's port -- the dashboard's "quick jump" copy buttons
// need this to build a real, working `ssh ...` command (the web UI and
// SSH front door share a hostname but not a port).
sshPort: (conf.ssh && conf.ssh.listenPort) || 22,
...buildInfo,
};
@@ -43,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;
+18 -2
View File
@@ -4,14 +4,17 @@
// browser who it is and whether it's an admin (drives login state + nav).
const router = require('express').Router();
const { isAdmin } = require('../middleware/auth');
const { isAdmin, isJumpAdmin } = require('../middleware/auth');
const access = require('../utils/access');
const metrics = require('../models/metrics');
const registry = require('../services/session_registry');
router.get('/me', (req, res) => {
res.json({
username: req.user && req.user.username,
groups: req.groups || [],
isAdmin: isAdmin(req),
isJumpAdmin: isJumpAdmin(req),
});
});
@@ -23,7 +26,20 @@ router.get('/hosts', async (req, res, next) => {
const hosts = isAdmin(req)
? await access.allHosts()
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
res.json({ results: hosts });
// Enrich with connection state for the dashboard's host list: whether a
// session is live right now (active bridges, session_registry), plus the
// last successful/failed connection times (models/metrics).
const connectedSlugs = new Set(registry.list().map((s) => s.slug));
const last = await metrics.lastForHosts(hosts.map((h) => h.slug));
const enriched = hosts.map((h) => ({
...h,
connected: connectedSlugs.has(h.slug),
lastConnected: (last[h.slug] && last[h.slug].lastConnected) || null,
lastFailed: (last[h.slug] && last[h.slug].lastFailed) || null,
}));
res.json({ results: enriched });
} catch (err) { next(err); }
});
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// WireGuard management API — admin-gated.
//
// Sites (exit nodes):
// GET /api/wireguard/sites list all exit nodes
// POST /api/wireguard/sites create exit node
// PATCH /api/wireguard/sites/:id update exit node
// DELETE /api/wireguard/sites/:id remove exit node
//
// Peers (client devices):
// GET /api/wireguard/peers list all peers (no private keys)
// POST /api/wireguard/peers create peer (returns private key ONCE)
// PATCH /api/wireguard/peers/:id update name / exit node / note
// DELETE /api/wireguard/peers/:id remove peer
// GET /api/wireguard/peers/:id/conf download wg0.conf (contains private key)
// GET /api/wireguard/peers/:id/qr PNG QR code of the client conf (base64)
const router = require('express').Router();
const QRCode = require('qrcode');
const conf = require('@simpleworkjs/conf');
const wgSite = require('../models/wg_site');
const wgPeer = require('../models/wg_peer');
const { renderClientConf } = require('../utils/wg_conf');
// Gateway's own WireGuard public key + endpoint come from conf.wireguard.
// These are set in docker-compose / theta-env and describe this gateway's
// wg0 interface that clients point at.
function gwConf() {
const wg = conf.wireguard || {};
return {
publicKey: wg.serverPublicKey || '',
endpoint: wg.serverEndpoint || '',
dns: wg.dns || '',
};
}
// ── Gateway Info ─────────────────────────────────────────────────────────────
router.get('/gateway-info', async (req, res) => {
res.json(gwConf());
});
// ── Sites ───────────────────────────────────────────────────────────────────
router.get('/sites', async (req, res, next) => {
try {
res.json({ results: await wgSite.list() });
} catch (e) { next(e); }
});
router.post('/sites', async (req, res, next) => {
try {
const { name, endpoint, publicKey, subnet, exitAll, siteId, note } = req.body;
if (!name || !endpoint || !publicKey) {
return res.status(400).json({ message: 'name, endpoint, and publicKey are required' });
}
const site = await wgSite.create(
{ name, endpoint, publicKey, subnet, exitAll, siteId, note },
req.user && req.user.uid
);
res.status(201).json(site);
} catch (e) { next(e); }
});
router.patch('/sites/:id', async (req, res, next) => {
try {
const site = await wgSite.update(req.params.id, req.body);
res.json(site);
} catch (e) { next(e); }
});
router.delete('/sites/:id', async (req, res, next) => {
try {
await wgSite.remove(req.params.id);
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── Peers ───────────────────────────────────────────────────────────────────
router.get('/peers', async (req, res, next) => {
try {
res.json({ results: await wgPeer.list() });
} catch (e) { next(e); }
});
router.post('/peers', async (req, res, next) => {
try {
const { name, exitSiteId, note } = req.body;
if (!name) return res.status(400).json({ message: 'name is required' });
const peer = await wgPeer.create(
{ name, exitSiteId, note },
req.user && req.user.uid
);
// Return the full peer including privateKey — shown ONCE.
res.status(201).json(peer);
} catch (e) { next(e); }
});
router.patch('/peers/:id', async (req, res, next) => {
try {
const peer = await wgPeer.update(req.params.id, req.body);
res.json(wgPeer.toPublic(peer));
} catch (e) { next(e); }
});
router.delete('/peers/:id', async (req, res, next) => {
try {
await wgPeer.remove(req.params.id);
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── Config / QR ─────────────────────────────────────────────────────────────
async function buildConf(id) {
const peer = await wgPeer.get(id); // includes privateKey
if (!peer) throw Object.assign(new Error('Peer not found'), { status: 404 });
const site = peer.exitSiteId ? await wgSite.get(peer.exitSiteId) : null;
const { publicKey, endpoint, dns } = gwConf();
return renderClientConf({ peer, site, serverPub: publicKey, serverEndpoint: endpoint, dns });
}
router.get('/peers/:id/conf', async (req, res, next) => {
try {
const confText = await buildConf(req.params.id);
const peer = await wgPeer.get(req.params.id);
const filename = `${(peer.name || peer.id).replace(/[^a-z0-9_-]/gi, '_')}.conf`;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(confText);
} catch (e) { next(e); }
});
router.get('/peers/:id/qr', async (req, res, next) => {
try {
const confText = await buildConf(req.params.id);
const dataUrl = await QRCode.toDataURL(confText, {
errorCorrectionLevel: 'M',
width: 400,
margin: 2,
});
res.json({ qr: dataUrl });
} catch (e) { next(e); }
});
// Gateway's own public key (unauthenticated — needed to display in the UI).
router.get('/gateway-info', (req, res) => {
res.json({ ...gwConf() });
});
module.exports = router;
+6 -2
View File
@@ -23,7 +23,7 @@ function counter(onBytes) {
// Connect the upstream ssh2.Client, retrying once after a short pause if the
// first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
// first-time key injection).
function connectUpstream({ host, port, username, privateKey, onHostKey, uid, justInjected }) {
function connectUpstream({ host, port, username, privateKey, cert, onHostKey, uid, justInjected, expectedHostKeyFp }) {
return new Promise((resolve, reject) => {
let attempted = false;
const dial = (allowRetry) => {
@@ -42,12 +42,16 @@ function connectUpstream({ host, port, username, privateKey, onHostKey, uid, jus
})
.connect({
host, port, username, privateKey,
certificates: cert ? [cert] : undefined,
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
keepaliveInterval: 15000,
hostVerifier: (key) => {
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
if (onHostKey) onHostKey(fp);
return true; // v1: trust-on-use, fingerprint audited. Pinning = follow-up.
if (expectedHostKeyFp && expectedHostKeyFp !== fp) {
return false;
}
return true; // v1: trust-on-use if not pinned, fingerprint audited.
},
});
};
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// mDNS local-discovery announcer (MULTI_SITE_SPEC.md Appendix B). Advertises
// this site's local presence so an agent on the same LAN segment (with
// prefer_local_directory enabled -- see theta-agent's local_discovery.go)
// can skip the relay/WAN path and talk to the local instance directly.
//
// What gets announced is deliberately just "which public hostnames does
// this site front, and at what local IP" -- nothing about identity or
// trust. The listening side never weakens certificate validation based on
// this; it only ever changes DNS resolution (see the hard rule documented
// in theta-agent's local_discovery.go).
const SERVICE_TYPE = 'theta-suite'; // -> _theta-suite._tcp, matches theta-agent's mdnsServiceName
function announcedHosts() {
return (process.env.THETA_LOCAL_DISCOVERY_HOSTS || '')
.split(',')
.map((h) => h.trim())
.filter(Boolean);
}
let bonjourInstance = null;
let publishedService = null;
async function startMdnsAnnounce() {
const hosts = announcedHosts();
if (hosts.length === 0) {
console.log('[mdns-announce] THETA_LOCAL_DISCOVERY_HOSTS not set -- nothing to announce, skipping');
return;
}
const { Bonjour } = require('bonjour-service');
bonjourInstance = new Bonjour(undefined, (err) => {
console.error('[mdns-announce] bonjour-service error:', err.message);
});
publishedService = bonjourInstance.publish({
name: `theta-suite-${process.env.SITE_SLUG || 'site'}`,
type: SERVICE_TYPE,
port: Number(process.env.PORT) || 80,
txt: { hosts: hosts.join(','), site: process.env.SITE_SLUG || '' }
});
console.log(`[mdns-announce] announcing on the local network: ${hosts.join(', ')}`);
}
function stopMdnsAnnounce() {
if (bonjourInstance) {
bonjourInstance.destroy();
bonjourInstance = null;
publishedService = null;
}
}
module.exports = { startMdnsAnnounce, stopMdnsAnnounce };
+41 -13
View File
@@ -129,17 +129,30 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (err) { throw fail('key-inject-failed', err.message); }
let cert;
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
try {
if (usePki) {
const { getSignedCert } = require('../utils/vault_cert');
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
} else {
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
}
} catch (err) {
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
throw fail(failType, err.message, host ? host.slug : undefined);
}
let upstream;
try {
upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port,
username: state.uid, privateKey: JUMP_KEYS.clientKey,
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
uid: state.uid, justInjected, onHostKey,
expectedHostKeyFp: host && host.metadata && host.metadata.sshHostKeyFp,
});
} catch (err) { throw fail('upstream-unreachable', err.message); }
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
return { upstream, host, endpoint };
}
@@ -147,8 +160,10 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
// detail carries the real underlying error message (e.g. ECONNREFUSED,
// ETIMEDOUT, an ssh2 auth-failure string) so audit records aren't reduced to
// just the generic reason code -- without it, a network-layer failure and an
// SSH auth failure both looked identical in the audit log.
function fail(reason, detail) { const e = new Error(reason); e.reason = reason; e.detail = detail; return e; }
// SSH auth failure both looked identical in the audit log. hostSlug (when the
// target was already resolved to a known host) lets callers attribute the
// failure to that host for per-host "last failed connection" tracking.
function fail(reason, detail, hostSlug) { const e = new Error(reason); e.reason = reason; e.detail = detail; e.hostSlug = hostSlug; return e; }
async function runGrammar(session, client, state) {
// Register session listeners IMMEDIATELY — before any async work.
@@ -179,7 +194,7 @@ async function runGrammar(session, client, state) {
const reason = err.reason || 'error';
rejectUp(new Error(reasonMessage(reason)));
await record.finish({ success: false, failReason: reason, failDetail: err.detail });
await metrics.bump({ uid: state.uid, success: false });
await metrics.bump({ uid: state.uid, hostSlug: err.hostSlug, success: false });
}
}
@@ -203,9 +218,9 @@ async function runTuiSession(session, client, state) {
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
const finishFail = async (reason, detail) => {
const finishFail = async (reason, detail, hostSlug) => {
await record.finish({ success: false, failReason: reason, failDetail: detail });
await metrics.bump({ uid: state.uid, success: false });
await metrics.bump({ uid: state.uid, hostSlug, success: false });
try { client.end(); } catch (_) {}
};
@@ -226,19 +241,32 @@ async function runTuiSession(session, client, state) {
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (err) { return finishFail('key-inject-failed', err.message); }
let cert;
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
try {
if (usePki) {
const { getSignedCert } = require('../utils/vault_cert');
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
} else {
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
}
} catch (err) {
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
return finishFail(failType, err.message, tui.host.slug);
}
let upstream;
try {
upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port,
username: state.uid, privateKey: JUMP_KEYS.clientKey,
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
expectedHostKeyFp: tui.host && tui.host.metadata && tui.host.metadata.sshHostKeyFp,
});
} catch (err) {
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
return finishFail('upstream-unreachable', err.message);
return finishFail('upstream-unreachable', err.message, tui.host.slug);
}
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
+52 -8
View File
@@ -9,10 +9,29 @@ const ESC = '\x1b';
const CLEAR = `${ESC}[2J${ESC}[H`;
const HIDE_CUR = `${ESC}[?25l`;
const SHOW_CUR = `${ESC}[?25h`;
const INV = `${ESC}[7m`;
// Basic styles
const RST = `${ESC}[0m`;
const DIM = `${ESC}[2m`;
const BOLD = `${ESC}[1m`;
const DIM = `${ESC}[2m`;
// Colors (30-37: standard, 90-97: bright)
const RED = `${ESC}[31m`;
const BRIGHT_RED = `${ESC}[91m`;
const CYAN = `${ESC}[36m`;
const BRIGHT_CYAN = `${ESC}[96m`;
const GREEN = `${ESC}[32m`;
const BRIGHT_GREEN = `${ESC}[92m`;
const YELLOW = `${ESC}[33m`;
const BRIGHT_YELLOW = `${ESC}[93m`;
const MAGENTA = `${ESC}[35m`;
const BRIGHT_MAGENTA = `${ESC}[95m`;
const BLUE = `${ESC}[34m`;
const BRIGHT_BLUE = `${ESC}[94m`;
// Inverted selection with color
const INV_GREEN = `${ESC}[42m${ESC}[30m`; // Green bg, black text
const INV = `${ESC}[7m`;
function pickHost(channel, uid, hosts) {
return new Promise((resolve) => {
@@ -35,18 +54,43 @@ function pickHost(channel, uid, hosts) {
const list = visible();
if (selected >= list.length) selected = Math.max(0, list.length - 1);
let out = CLEAR + HIDE_CUR;
out += `${BOLD} Theta42 Jump — hosts for ${uid}${RST}\r\n`;
out += `${DIM} ↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n\r\n`;
// Header with gradient-style color
out += `\r\n ${BOLD}${BRIGHT_CYAN}╔════════════════════════════════════════════════════════╗${RST}\r\n`;
out += ` ${BOLD}${BRIGHT_CYAN}${RST} ${BOLD}${BRIGHT_MAGENTA}Theta42 Jump${RST} ${DIM}·${RST} ${BRIGHT_GREEN}hosts for ${uid}${RST} ${BOLD}${BRIGHT_CYAN}${RST}\r\n`;
out += ` ${BOLD}${BRIGHT_CYAN}╚════════════════════════════════════════════════════════╝${RST}\r\n`;
out += `\r\n`;
out += ` ${DIM}↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n`;
out += `\r\n`;
if (!list.length) {
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
out += ` ${YELLOW}${RST} ${DIM}(no match for "${filter}")${RST}\r\n`;
} else {
list.forEach((h, i) => {
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
const row = ` ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${ip}` : ''}`;
out += (i === selected ? `${INV}> ${h.name} (${h.slug})${ip ? ` ${ip}` : ''}${RST}` : row) + '\r\n';
const isProd = h.metadata && h.metadata.isProduction;
const envBadge = isProd ? `${BOLD}${RED}PROD${RST} ` : `${DIM}DEV${RST} `;
if (i === selected) {
// Selected row with green inverse background
const selRow = `${INV_GREEN} ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${CYAN}${ip}${RST}` : ''} ${envBadge} ${BOLD}${BRIGHT_GREEN}◄ SELECTED ►${RST}${INV_GREEN}${RST}`;
out += selRow + '\r\n';
} else {
// Normal row with subtle coloring
const nameColor = i % 2 === 0 ? BRIGHT_CYAN : CYAN;
out += ` ${nameColor}${h.name}${RST} ${DIM}(${h.slug})${RST}${ip ? ` ${BLUE}${ip}${RST}` : ''} ${envBadge}\r\n`;
}
});
}
if (filter) out += `\r\n ${DIM}filter:${RST} ${filter}`;
if (filter) {
out += `\r\n ${DIM}filter: ${BRIGHT_YELLOW}${filter}${RST}`;
}
// Footer
out += `\r\n\r\n ${DIM}────────────────────────────────────────────────────────${RST}\r\n`;
out += ` ${DIM}Press${RST} ${BOLD}1-9${RST} ${DIM}to quick-select · ${BOLD}q${RST} ${DIM}to quit${RST}\r\n`;
channel.write(out);
};
+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 };
+3 -1
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: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: []},
{href: '/wireguard', icon: 'fa-solid fa-shield-halved', label: 'WireGuard', groups: ['admin', 'app_jump_admin']},
{href: '/mesh', icon: 'fa-solid fa-diagram-project', label: 'Mesh', groups: ['admin', 'app_jump_admin']},
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin', 'app_jump_admin']},
],
};
+44
View File
@@ -0,0 +1,44 @@
'use strict';
const conf = require('@simpleworkjs/conf');
/**
* Requests a signed SSH certificate from the SSO Manager's OpenBao/Vault proxy.
*
* @param {string} publicKey - The jump host's public key (e.g. 'ssh-rsa AAAAB3...')
* @param {string} targetUid - The username the cert should be valid for
* @returns {Promise<string>} - The signed SSH certificate
*/
async function getSignedCert(publicKey, targetUid) {
const sso = conf.sso || {};
const pkiConfig = conf.ssh?.pki || {};
const vaultRole = pkiConfig.role || 'jump-host-role';
const endpoint = `${sso.url}/api/vault/ssh/sign/${vaultRole}`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${sso.apiToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
public_key: publicKey,
valid_principals: targetUid
})
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`Failed to sign SSH cert (status ${response.status}): ${errText}`);
}
const data = await response.json();
if (!data.data || !data.data.signed_key) {
throw new Error('Vault response missing signed_key');
}
return data.data.signed_key;
}
module.exports = { getSignedCert };
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// Renders a WireGuard client wg0.conf from a peer + site record.
//
// The generated config is a standard WireGuard config that works with:
// - wg-quick (Linux / macOS)
// - the official WireGuard iOS / Android apps (via QR code)
// - TunnelBear, WireGuard Windows client, etc.
/**
* Build a client wg0.conf string.
*
* @param {object} peer - WG peer record from wg_peer model
* @param {object} site - Exit node record from wg_site model (may be null)
* @param {string} serverPub - Gateway server's own public key
* @param {string} serverEndpoint - "host:port" for the gateway
* @param {string} dns - Optional DNS server to push to client
* @returns {string}
*/
function renderClientConf({ peer, site, serverPub, serverEndpoint, dns }) {
const allowedIPs = site
? (site.exitAll ? '0.0.0.0/0, ::/0' : site.subnet || '0.0.0.0/0')
: '0.0.0.0/0, ::/0'; // no exit site = full tunnel
const lines = [
'[Interface]',
`PrivateKey = ${peer.privateKey}`,
`Address = ${peer.assignedIP}/32`,
];
if (dns) lines.push(`DNS = ${dns}`);
lines.push('');
lines.push('[Peer]');
lines.push(`PublicKey = ${serverPub}`);
// If client selected an exit site, add preshared key routing hint via
// AllowedIPs. Site gateways are peers-of-peers; the server handles routing.
if (site) {
lines.push(`AllowedIPs = ${allowedIPs}`);
} else {
lines.push('AllowedIPs = 0.0.0.0/0, ::/0');
}
lines.push(`Endpoint = ${serverEndpoint}`);
lines.push('PersistentKeepalive = 25');
if (peer.note) {
lines.unshift(`# ${peer.note}`);
}
return lines.join('\n') + '\n';
}
/**
* Build a minimal server-side [Peer] block for wg0.conf (for reference/export).
*/
function renderServerPeerBlock(peer) {
return [
`# ${peer.name || peer.id}`,
'[Peer]',
`PublicKey = ${peer.publicKey}`,
`AllowedIPs = ${peer.assignedIP}/32`,
'',
].join('\n');
}
module.exports = { renderClientConf, renderServerPeerBlock };
+174
View File
@@ -0,0 +1,174 @@
'use strict';
// Bring up a local WireGuard interface, preferring the in-kernel
// implementation and falling back to the userspace `wireguard-go` reference
// implementation when the kernel module isn't available (older/hardened
// kernels, some container/cloud images, non-Linux). Both paths end with an
// identically-named network interface that `wg`/`ip` commands (and the rest
// of this module) treat the same way -- callers never need to know which
// mode ended up in use.
const { execFileSync, spawn } = require('child_process');
const probes = new Map(); // name -> { mode, process (userspace only) }
function run(cmd, args) {
return execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
}
function tryRun(cmd, args) {
try { return { ok: true, out: run(cmd, args) }; }
catch (e) { return { ok: false, err: (e.stderr || e.message || '').toString() }; }
}
// One-time, cheap probe: can this kernel create a wireguard-type link at
// all? Uses a throwaway interface name so it never collides with a real one.
let kernelSupport = null;
function kernelWireguardAvailable() {
if (kernelSupport !== null) return kernelSupport;
const probeName = 'wgprobe' + process.pid;
const add = tryRun('ip', ['link', 'add', 'dev', probeName, 'type', 'wireguard']);
if (add.ok) {
tryRun('ip', ['link', 'del', 'dev', probeName]);
kernelSupport = true;
} else {
kernelSupport = false;
}
return kernelSupport;
}
function interfaceExists(name) {
return tryRun('ip', ['link', 'show', 'dev', name]).ok;
}
async function waitForInterface(name, timeoutMs = 5000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (interfaceExists(name)) return true;
await new Promise((r) => setTimeout(r, 100));
}
return false;
}
// Idempotent: calling this again for an interface that's already up (kernel
// or userspace) is a no-op, not an error.
async function ensureInterface(name) {
if (interfaceExists(name)) {
return { mode: probes.get(name) ? probes.get(name).mode : 'kernel' };
}
if (kernelWireguardAvailable()) {
const add = tryRun('ip', ['link', 'add', 'dev', name, 'type', 'wireguard']);
if (!add.ok && !/File exists/.test(add.err)) {
throw new Error(`kernel WireGuard interface creation failed: ${add.err}`);
}
probes.set(name, { mode: 'kernel' });
console.log(`[wg_iface] ${name}: using in-kernel WireGuard`);
return { mode: 'kernel' };
}
// Userspace fallback: wireguard-go daemonizes and creates the TUN device
// itself; we just wait for it to appear rather than assuming a fixed delay.
console.log(`[wg_iface] ${name}: kernel WireGuard unavailable, falling back to wireguard-go (userspace)`);
const child = spawn('wireguard-go', [name], { detached: true, stdio: 'ignore' });
child.unref();
const up = await waitForInterface(name);
if (!up) throw new Error(`wireguard-go did not bring up interface '${name}' within timeout`);
probes.set(name, { mode: 'userspace', pid: child.pid });
return { mode: 'userspace', pid: child.pid };
}
function setPrivateKey(name, privateKeyBase64, listenPort) {
// `wg setconf` reads the private key from a file, not argv (argv would leak
// it via /proc/<pid>/cmdline to anyone on the host). Pipe it through stdin
// via a temp file instead -- see setPeer's note on the same tradeoff.
// ListenPort matters: without one, WG binds an ephemeral port, which is
// fine for a purely outbound roaming client but useless for a gateway
// another gateway needs to dial back into as an Endpoint.
const fs = require('fs');
const os = require('os');
const path = require('path');
const tmp = path.join(os.tmpdir(), `wg-${name}-${Date.now()}.conf`);
const lines = ['[Interface]', `PrivateKey = ${privateKeyBase64}`];
if (listenPort) lines.push(`ListenPort = ${listenPort}`);
fs.writeFileSync(tmp, lines.join('\n') + '\n', { mode: 0o600 });
try {
run('wg', ['setconf', name, tmp]);
} finally {
fs.unlinkSync(tmp);
}
}
function setAddress(name, cidr) {
// Flush first so re-applying (e.g. after a mesh index reassignment, which
// shouldn't normally happen but must not silently stack addresses if it
// does) leaves exactly one address, not an accumulating list.
tryRun('ip', ['addr', 'flush', 'dev', name]);
run('ip', ['addr', 'add', cidr, 'dev', name]);
run('ip', ['link', 'set', 'up', 'dev', name]);
}
// Apply (or update) one peer. Safe to call repeatedly for the same peer --
// `wg set ... peer <pub>` upserts.
//
// `wg set ... allowed-ips` ONLY configures WireGuard's own crypto-routing
// table (which packets get encrypted/decrypted for this peer) -- it does
// NOT add a kernel route for that destination. wg-quick does that as a
// separate step; since we drive `wg`/`ip` directly (no wg-quick), we have to
// add it ourselves or the tunnel handshakes fine but nothing ever actually
// routes through it (confirmed the hard way: a real encrypted handshake
// completed between two containers with zero kernel route present, and
// ping still showed 100% loss).
function setPeer(name, { publicKey, endpoint, allowedIPs, keepalive }) {
const ips = allowedIPs || [];
const args = ['set', name, 'peer', publicKey, 'allowed-ips', ips.join(',')];
if (endpoint) args.push('endpoint', endpoint);
if (keepalive) args.push('persistent-keepalive', String(keepalive));
run('wg', args);
for (const cidr of ips) {
const add = tryRun('ip', ['route', 'add', cidr, 'dev', name]);
// "File exists" happens when the interface's own /24 already covers
// this range (added automatically by `ip addr add`) -- fine, not an
// error. Anything else should surface.
if (!add.ok && !/File exists/.test(add.err)) {
throw new Error(`failed to add kernel route ${cidr} via ${name}: ${add.err}`);
}
}
}
// Removes the peer AND the kernel routes setPeer() added for its
// AllowedIPs -- query them BEFORE removing the peer (once gone, `wg` no
// longer knows what to clean up, and nothing else tracks these routes,
// since they were added by us directly, not by wg-quick).
//
// Safe to assume none of a peer's AllowedIPs collide with this gateway's
// own local address range: mesh indexes are unique per gateway
// (models/mesh_gateway.js's nextFreeMeshIndex), so a peer's
// 172.24.<peerIndex>.0/24 can never equal our own 172.24.<ownIndex>.0/24.
function removePeer(name, publicKey) {
const show = tryRun('wg', ['show', name, 'allowed-ips']);
let allowedIPs = [];
if (show.ok) {
const line = show.out.split('\n').find((l) => l.startsWith(publicKey + '\t'));
if (line) {
allowedIPs = (line.split('\t')[1] || '').split(/\s+/).filter((ip) => ip && ip !== '(none)');
}
}
tryRun('wg', ['set', name, 'peer', publicKey, 'remove']);
for (const cidr of allowedIPs) {
tryRun('ip', ['route', 'del', cidr, 'dev', name]);
}
}
module.exports = {
kernelWireguardAvailable,
ensureInterface,
setPrivateKey,
setAddress,
setPeer,
removePeer,
interfaceExists
};
+29
View File
@@ -0,0 +1,29 @@
'use strict';
// WireGuard key generation using Node's built-in crypto (X25519).
// WireGuard keys ARE X25519 keys in raw base64 — no wg binary needed.
const crypto = require('crypto');
/**
* Generate a WireGuard keypair.
* @returns {{ privateKey: string, publicKey: string }} — base64 encoded
*/
function generateKeypair() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('x25519', {
publicKeyEncoding: { type: 'spki', format: 'der' },
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
});
// DER-encoded PKCS#8 private key: raw 32-byte X25519 scalar starts at offset 16
const rawPriv = privateKey.slice(16, 48);
// DER-encoded SPKI public key: raw 32-byte point starts at offset 12
const rawPub = publicKey.slice(12, 44);
return {
privateKey: rawPriv.toString('base64'),
publicKey: rawPub.toString('base64'),
};
}
module.exports = { generateKeypair };
+66 -2
View File
@@ -1,5 +1,46 @@
<%- include('top') %>
<script type="text/javascript">app.auth.forceLogin();</script>
<script type="text/javascript">app.auth.forceLogin(['admin', 'app_jump_admin']);</script>
<div class="container mt-4">
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-active"></div>
<div class="text-muted small text-uppercase">Active sessions</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-total"></div>
<div class="text-muted small text-uppercase">Total connections</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6 text-danger" id="stat-fail"></div>
<div class="text-muted small text-uppercase">Failed</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-users"></div>
<div class="text-muted small text-uppercase">Users seen</div>
</div></div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
@@ -29,8 +70,28 @@
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next &rarr;</button>
</div>
</div>
</div>
<script type="text/javascript">
function rows(sel, list){
var $b = $(sel).empty();
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
list.forEach(function(x){
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
});
}
function loadMetrics(){
app.jump.metrics(function(error, data){
if(error || !data) return;
$('#stat-active').text(data.active);
$('#stat-total').text(data.total);
$('#stat-fail').text(data.fail);
$('#stat-users').text((data.topUsers || []).length);
rows('#top-hosts', data.topHosts);
rows('#top-users', data.topUsers);
});
}
var page = 0;
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
function applyFilters(){ page = 0; load(); }
@@ -57,6 +118,9 @@
$('#next').prop('disabled', (page + 1) * size >= total);
});
}
$(document).ready(load);
$(document).ready(function(){
loadMetrics();
load();
});
</script>
<%- include('bottom') %>
+172 -85
View File
@@ -1,30 +1,25 @@
<%- include('top') %>
<script type="text/javascript">app.auth.forceLogin();</script>
<div class="container mt-4">
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-active"></div>
<div class="text-muted small text-uppercase">Active sessions</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-total"></div>
<div class="text-muted small text-uppercase">Total connections</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6 text-danger" id="stat-fail"></div>
<div class="text-muted small text-uppercase">Failed</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-users"></div>
<div class="text-muted small text-uppercase">Users seen</div>
</div></div>
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header"><i class="fa-solid fa-terminal me-1"></i> Quick Jump</div>
<div class="card-body">
<p class="text-muted small mb-2">
Skip the picker: <code>ssh &lt;your-username&gt;_-_&lt;host-slug&gt;@&lt;this-jump-host&gt;</code>
connects straight to a host. Or just <code>ssh &lt;your-username&gt;@&lt;this-jump-host&gt;</code>
for the interactive picker.
</p>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="quick-jump-cmd" readonly>
<button class="btn btn-outline-secondary" onclick="copyFieldValue('#quick-jump-cmd')" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -32,20 +27,12 @@
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header"><i class="fa-solid fa-network-wired me-1"></i> <span id="my-hosts-title">Hosts you can reach</span></div>
<table class="table table-sm mb-0"><tbody id="my-hosts"></tbody></table>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Host</th><th>Slug</th><th class="text-end">Address</th><th>Last connection</th><th>Last failed connection</th><th></th></tr></thead>
<tbody id="my-hosts"></tbody>
</table>
</div>
</div>
</div>
</div>
@@ -63,64 +50,122 @@
(e.g. <code>GET /api/user/hosts</code>) — not for SSH login. A token carries
no group claims, so it can't reach admin-only endpoints.
</p>
<table class="table table-sm mb-0">
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
<tbody id="api-tokens"></tbody>
</table>
<div class="card-body">
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
<div id="api-tokens">
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow-sm mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="fa-solid fa-key"></i> {{name}}</h6>
<small class="text-muted font-monospace">{{id_short}}</small>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
{{#description}}<p>{{description}}</p>{{/description}}
<dl class="row mb-0 small">
<dt class="col-sm-3">Token ID</dt>
<dd class="col-sm-9"><code>{{id_short}}</code></dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">{{{created_display}}}</dd>
<dt class="col-sm-3">Last used</dt>
<dd class="col-sm-9">{{{last_used_display}}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{expires_display}}}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
<button type="button" onclick="rotateApiToken('{{id}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
<button type="button" onclick="revokeApiToken('{{id}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
function rows(sel, list){
var $b = $(sel).empty();
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
list.forEach(function(x){
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
// The web UI and the SSH front door share a hostname, just not a port.
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
function sshCommand(target){
var uid = app.auth.user && app.auth.user.username;
if(!uid) return '';
var portFlag = SSH_PORT === 22 ? '' : ' -p ' + SSH_PORT;
return 'ssh ' + uid + (target ? '_-_' + target : '') + '@' + location.hostname + portFlag;
}
function copyFieldValue(sel){
var $el = $(sel);
var text = $el.val();
if(!text) return;
navigator.clipboard.writeText(text).then(function(){
app.messages.toast('Copied to clipboard', 'success');
}, function(){
app.messages.toast('Could not copy — select and copy manually', 'danger');
});
}
function hostRows(sel, hosts){
var $b = $(sel).empty();
if(!hosts || !hosts.length){ $b.append('<tr><td class="text-muted">No hosts reachable.</td></tr>'); return; }
hosts.forEach(function(h){
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
$b.append('<tr><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
// Green: a session to this host is live right now. Yellow: the most
// recent attempt to this host failed (and none is currently live).
var rowClass = h.connected ? 'table-success'
: (h.lastFailed && (!h.lastConnected || h.lastFailed > h.lastConnected)) ? 'table-warning'
: '';
$b.append('<tr class="' + rowClass + '"><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td></tr>');
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
+ '<td class="small">' + (h.lastConnected ? app.jump.fmtTime(h.lastConnected) : '—') + '</td>'
+ '<td class="small">' + (h.lastFailed ? app.jump.fmtTime(h.lastFailed) : '—') + '</td>'
+ '<td class="text-end">'
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copyFieldValue(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
+ '</td></tr>');
});
}
function tokenRows(tokens){
var $b = $('#api-tokens').empty();
if(!tokens || !tokens.length){ $b.append('<tr><td colspan="5" class="text-muted">No API tokens.</td></tr>'); return; }
tokens.forEach(function(t){
var expires = t.expires_at ? app.jump.fmtTime(t.expires_at) : 'Never';
var lastUsed = t.last_used_on ? app.jump.fmtTime(t.last_used_on) : 'Never';
$b.append(
'<tr>'
+ '<td>' + app.jump.esc(t.name) + '</td>'
+ '<td class="text-muted small">' + app.jump.fmtTime(t.created_on) + '</td>'
+ '<td class="text-muted small">' + lastUsed + '</td>'
+ '<td class="text-muted small">' + expires + '</td>'
+ '<td class="text-end">'
+ '<button class="btn btn-sm btn-outline-secondary" onclick="rotateApiToken(\'' + t.id + '\', this)" title="Rotate"><i class="fa-solid fa-rotate"></i></button> '
+ '<button class="btn btn-sm btn-outline-danger" onclick="revokeApiToken(\'' + t.id + '\', this)" title="Revoke"><i class="fa-solid fa-trash"></i></button>'
+ '</td>'
+ '</tr>'
);
});
// expires_at/created_on/last_used_on come back as redis-hash strings for
// some fields and real numbers for others depending on the model's field
// type -- fmtTime already handles both via moment(ms, 'x').
function fmtExpiry(token){
var exp = Number(token.expires_at);
if(!exp) return '<span class="badge text-bg-secondary">never</span>';
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
return '<span class="badge text-bg-warning">' + moment(exp).fromNow() + '</span>';
}
var tokensById = {};
function processToken(token){
tokensById[token.id] = token;
token.id_short = token.id.slice(0, 12) + '…';
token.expires_display = fmtExpiry(token);
token.created_display = app.jump.fmtTime(token.created_on);
token.last_used_display = token.last_used_on ? app.jump.fmtTime(token.last_used_on) : 'Never';
return token;
}
function loadApiTokens(){
app.apiToken.list(function(error, data){
if(error) return tokenRows([]);
tokenRows(data && data.results);
var tokens = (!error && data && data.results) || [];
$.scope.apiTokenCard.empty();
tokens.forEach(function(t){ $.scope.apiTokenCard.push(processToken(t)); });
$('#api-tokens-empty').toggle(tokens.length === 0);
});
}
// Shared "reveal secret once" display -- also used by proxy/sso-manager-node.
function showToken(title, token){
app.modal.open({title: title, bodyHtml:
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
+ '<div class="input-group"><input type="text" class="form-control font-monospace" readonly value="' + app.jump.esc(token) + '"></div>'
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.jump.esc(token) + '">'
// Reuses the same copy-to-clipboard helper as the Quick Jump card
// above (toast feedback -- FontAwesome replaces <i> icons with
// inline <svg>, so a checkmark-flash-the-icon approach silently
// no-ops; the toast doesn't have that problem).
+ '<button class="btn btn-outline-secondary" onclick="copyFieldValue(\'#revealed-token\')" title="Copy"><i class="fa-solid fa-copy"></i></button></div>'
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.jump.esc(token) + '</code></p>'
});
}
@@ -132,27 +177,77 @@
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control" id="new-token-description" placeholder="optional">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days, blank = never)</label>'
+ '<input type="number" class="form-control" id="new-token-days" min="1">'
+ '</div>'
+ '<button class="btn btn-primary" onclick="submitApiToken()"><i class="fa-solid fa-check"></i> Create</button>'
+ '</div>',
footer: {buttonsHtml: app.modal.footerButtons({onSave: 'submitApiToken()', saveLabel: 'Create'})},
});
$body.find('#new-token-name').focus();
}
function submitApiToken(){
var name = $('#new-token-name').val().trim();
var $card = $('#api-tokens').closest('.card');
if(!name) return app.messages.action('Name is required', $card, 'danger');
if(!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
app.apiToken.add({
name: name,
description: $('#new-token-description').val(),
expires_in_days: $('#new-token-days').val(),
}, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to create token', $card, 'danger');
if(error) return app.messages.action((data && data.message) || 'Failed to create token', app.modal.body(), 'danger');
// Deliberately no app.modal.close() here -- app.modal is a
// singleton, and close() immediately followed by open() (inside
// showToken) in the same tick collides with Bootstrap's
// hide-transition guard, so the reveal modal silently never
// shows. open() alone already overwrites the (already-visible)
// modal's content in place.
showToken('API Token Created', data.token);
loadApiTokens();
});
}
function editToken(id){
var t = tokensById[id]; if(!t) return;
app.modal.open({
title: 'Edit Token',
bodyHtml:
'<input type="hidden" id="edit-token-id" value="' + app.jump.esc(id) + '">'
+ '<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control" id="edit-token-name" value="' + app.jump.esc(t.name || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control" id="edit-token-description" value="' + app.jump.esc(t.description || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days, blank = keep as-is, 0 = never)</label>'
+ '<input type="number" class="form-control" id="edit-token-days" min="0">'
+ '</div>',
footer: {
metaHtml: 'Created by ' + app.jump.esc(t.created_by || '—') + ' on ' + app.jump.fmtTime(t.created_on),
buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
},
});
}
function saveEditToken(){
var payload = {
id: $('#edit-token-id').val(),
name: $('#edit-token-name').val(),
description: $('#edit-token-description').val(),
expires_in_days: $('#edit-token-days').val(),
};
app.apiToken.update(payload, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to update token', app.modal.body(), 'danger');
app.modal.close();
loadApiTokens();
});
}
async function revokeApiToken(id, btn){
var $card = $(btn).closest('.card');
var ok = await app.messages.confirm('Revoke this API token? It stops working immediately.', $card, 'danger');
@@ -174,17 +269,9 @@
}
$(document).ready(async function(){
app.jump.metrics(function(error, data){
if(error || !data) return;
$('#stat-active').text(data.active);
$('#stat-total').text(data.total);
$('#stat-fail').text(data.fail);
$('#stat-users').text((data.topUsers || []).length);
rows('#top-hosts', data.topHosts);
rows('#top-users', data.topUsers);
});
await app.auth.loadUser();
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
if(app.auth.isAdmin()) $('#my-hosts-title').text('My hosts');
$('#quick-jump-cmd').val(sshCommand());
app.jump.hosts(function(error, data){
if(error) return hostRows('#my-hosts', []);
hostRows('#my-hosts', data && data.results);
+25
View File
@@ -0,0 +1,25 @@
<%- include('top') %>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 text-center">
<div class="mb-4">
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
</div>
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
<p class="text-muted mb-4">
<% if (error.status === 404) { %>
The page you are looking for doesn't exist or has been moved.
<% } else { %>
An unexpected error occurred. Please try again later.
<% } %>
</p>
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
<i class="fa-solid fa-house me-2"></i>Return to Home
</a>
</div>
</div>
</div>
<%- include('bottom') %>
+1 -1
View File
@@ -90,7 +90,7 @@
<hr />
<div class="d-grid">
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
<i class="fa-solid fa-id-badge"></i> Log in with SSO
<i class="fa-solid fa-id-badge"></i> Log in with Jump
</a>
</div>
<% } %>
+167
View File
@@ -0,0 +1,167 @@
<%- include('top') %>
<script type="text/javascript">app.auth.forceLogin();</script>
<div class="container mt-4 mb-5">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h3 class="mb-1"><i class="fa-solid fa-diagram-project text-primary me-2"></i>Gateway Mesh</h3>
<p class="text-muted small mb-0">Site-to-site WireGuard tunnels between theta-gateway instances. Different from <a href="/wireguard">WireGuard</a>, which manages individual roaming-client peers and exit nodes.</p>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-light border-secondary">
<i class="fa-solid fa-server me-2"></i>This Gateway
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="p-3 border rounded bg-light dark-bg-subtle">
<div class="text-muted small fw-semibold">MESH INTERFACE</div>
<div class="font-monospace fw-bold text-primary mt-1" id="mesh-iface">Loading...</div>
</div>
</div>
<div class="col-md-4">
<div class="p-3 border rounded bg-light dark-bg-subtle">
<div class="text-muted small fw-semibold">WIREGUARD MODE</div>
<div class="fw-bold mt-1" id="mesh-kernel-mode">Loading...</div>
</div>
</div>
<div class="col-md-4">
<div class="p-3 border rounded bg-light dark-bg-subtle">
<div class="text-muted small fw-semibold">MESHED GATEWAYS</div>
<div class="fw-bold mt-1" id="mesh-gateway-count">Loading...</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header"><i class="fa-solid fa-key me-2"></i>Mint a Join Token</div>
<div class="card-body">
<p class="small text-muted">Give this to a new gateway so it can join this one's mesh (single-use, expires in 15 minutes). It calls this gateway's <code>/api/mesh/register</code> with it.</p>
<button class="btn btn-sm btn-success" onclick="mintMeshJoinToken()"><i class="fa-solid fa-plus me-1"></i> Mint Join Token</button>
<div id="mesh-join-token-result" class="mt-2"></div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header"><i class="fa-solid fa-right-to-bracket me-2"></i>Join a Remote Gateway's Mesh</div>
<div class="card-body">
<p class="small text-muted">Have a join token from another gateway? Use it here to mesh THIS gateway into that one.</p>
<div class="mb-2">
<input type="text" id="mesh-remote-endpoint" class="form-control form-control-sm" placeholder="Remote gateway URL (e.g. https://jump.master.example.com)">
</div>
<div class="mb-2">
<input type="text" id="mesh-remote-token" class="form-control form-control-sm font-monospace" placeholder="Join token (mjt_...)">
</div>
<button class="btn btn-sm btn-primary" onclick="joinRemoteMesh()"><i class="fa-solid fa-link me-1"></i> Join</button>
</div>
</div>
</div>
</div>
<div class="card shadow-sm mt-4">
<div class="card-header"><i class="fa-solid fa-network-wired me-2"></i>Meshed Gateways</div>
<div class="card-body p-0" id="mesh-gateways-wrap">
<div class="text-center py-4 text-muted">Loading...</div>
</div>
</div>
</div>
<%- include('bottom') %>
<script type="text/javascript">
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
$(document).ready(function(){
loadMeshStatus();
});
function loadMeshStatus() {
app.api.get('mesh/gateways', function(err, data){
if (err || !data) {
$('#mesh-iface').text('(unavailable)');
$('#mesh-kernel-mode').text('(unavailable)');
$('#mesh-gateway-count').text('—');
$('#mesh-gateways-wrap').html('<div class="text-center py-4 text-danger">Could not load mesh status: ' + esc((err && err.message) || 'unknown error') + '</div>');
return;
}
$('#mesh-iface').text(data.iface || 'wg-mesh');
$('#mesh-kernel-mode').html(data.kernelWireguard
? '<span class="badge bg-success"><i class="fa-solid fa-microchip me-1"></i> In-kernel</span>'
: '<span class="badge bg-warning text-dark"><i class="fa-solid fa-layer-group me-1"></i> Userspace (wireguard-go)</span>');
var gateways = data.gateways || [];
$('#mesh-gateway-count').text(gateways.length + ' gateway' + (gateways.length === 1 ? '' : 's'));
renderGatewaysTable(gateways);
});
}
function renderGatewaysTable(gateways) {
var $w = $('#mesh-gateways-wrap');
if (!gateways.length) {
$w.html('<div class="text-center py-4 text-muted"><i class="fa-solid fa-diagram-project me-2"></i>No meshed gateways yet.<br><small>Mint a join token above and have another gateway join, or join this one into a remote gateway\'s mesh.</small></div>');
return;
}
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
+ '<th>Site</th><th>Mesh Index</th><th>Mesh Address</th><th>Endpoint</th><th>Public Key</th><th>Last Seen</th><th class="text-end">Actions</th>'
+ '</tr></thead><tbody>';
gateways.forEach(function(g){
var isSelf = g.siteSlug === '(self)';
html += '<tr' + (isSelf ? ' class="table-active"' : '') + '>'
+ '<td>' + (isSelf ? '<em>This gateway</em>' : esc(g.siteSlug || '(unlabeled)')) + '</td>'
+ '<td><span class="badge bg-primary">' + esc(g.meshIndex) + '</span></td>'
+ '<td><code class="small">172.24.' + esc(g.meshIndex) + '.0/24</code></td>'
+ '<td><code class="small text-primary">' + esc(g.endpoint || '—') + '</code></td>'
+ '<td><code class="small text-truncate d-inline-block" style="max-width:220px;" title="' + esc(g.publicKey) + '">' + esc(g.publicKey) + '</code></td>'
+ '<td class="small text-muted">' + (g.lastSeenAt ? new Date(Number(g.lastSeenAt)).toLocaleString() : '—') + '</td>'
+ '<td class="text-end">' + (isSelf ? '' :
'<button class="btn btn-sm btn-outline-danger" onclick="removeMeshGateway(\'' + esc(g.id) + '\', \'' + esc(g.siteSlug || g.id) + '\')" title="Remove peer"><i class="fa-solid fa-trash"></i></button>')
+ '</td>'
+ '</tr>';
});
html += '</tbody></table>';
$w.html(html);
}
async function removeMeshGateway(id, label) {
var ok = await app.messages.confirm('Remove mesh peer "' + label + '"? This tears down the local WireGuard peer + routes. The other side keeps its half until removed there too.', $('#mesh-gateways-wrap'), 'danger');
if (!ok) return;
app.api.delete('mesh/gateways/' + id, function(err){
if (err) return app.messages.toast('Failed to remove gateway: ' + err.message, 'danger');
app.messages.toast('Gateway removed', 'success');
loadMeshStatus();
});
}
function mintMeshJoinToken() {
app.api.post('mesh/join-tokens', {}, function(err, data){
if (err) return app.messages.toast('Failed to mint join token: ' + err.message, 'danger');
$('#mesh-join-token-result').html(
'<div class="alert alert-success small mb-0">' +
'<strong>Shown once — copy it now:</strong><br>' +
'<code class="user-select-all">' + esc(data.token) + '</code>' +
'<br><span class="text-muted">Expires in ' + Math.round((data.expiresInSeconds || 0) / 60) + ' minutes.</span>' +
'</div>'
);
});
}
function joinRemoteMesh() {
var remoteEndpoint = ($('#mesh-remote-endpoint').val() || '').trim();
var joinToken = ($('#mesh-remote-token').val() || '').trim();
if (!remoteEndpoint || !joinToken) {
return app.messages.toast('Enter the remote gateway URL and a join token', 'warning');
}
app.api.post('mesh/join', { remoteEndpoint: remoteEndpoint, joinToken: joinToken }, function(err, data){
if (err) return app.messages.toast('Join failed: ' + err.message, 'danger');
app.messages.toast('Meshed successfully — this gateway is mesh index ' + data.meshIndex + ', peer is index ' + data.peerMeshIndex, 'success');
loadMeshStatus();
});
}
</script>
+2
View File
@@ -1,6 +1,7 @@
<%- include('top') %>
<script type="text/javascript">app.auth.forceLogin();</script>
<div class="container mt-4">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
@@ -13,6 +14,7 @@
</table>
</div>
</div>
</div>
<script type="text/javascript">
function loadSessions(){
+10 -2
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 { %>
@@ -82,16 +82,24 @@
</div>
<script type="text/javascript">
// --sw-content-offset tracks the same height as #spa-shell's margin-top
// (fixed navbar, plus the update banner while it's shown), so any
// in-page sticky element (e.g. a sticky search/sort bar) can offset
// itself below both fixed elements via `top: var(--sw-content-offset)`
// instead of colliding with them at the viewport's true top:0.
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
let offset = $nav.outerHeight() + $banner.outerHeight();
$('#spa-shell').css('margin-top', offset + 'px');
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
sessionStorage.setItem('update-banner-dismissed', '1');
}
+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') %>