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.
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.
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.
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.
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.
* 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
- 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).
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.
jump-host had zero API-token support: no model, no route, no UI, and
Auth.checkApiToken was explicitly absent from the createOidcClient() call
(per the comment it left behind). proxy and sso-manager-node both have
this; jump-host didn't.
Ports proxy's models/api_token.js + routes/api_token.js pattern (jmp_
prefix instead of prx_), wires checkApiToken into createOidcClient(), adds
Bearer-token support to middleware/auth.js, and adds a token management
card to dashboard.ejs (create/list/rotate/revoke) using app.modal/
app.messages.
Scope note: a jump-host token carries no group claims (unlike proxy's,
which snapshots the creator's groups), so it authenticates as its creator
for non-admin routes (e.g. GET /api/user/hosts) but can never pass
requireAdmin — a deliberate, conservative default rather than recomputing
live admin status per-request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same swap as sso-manager-node/proxy: vendored app.util.actionMessage/
actionConfirm replaced by @simpleworkjs/frontend's app.messages.action/
confirm; vendored val.js replaced by the package's app.validate.js.
jump-host's views don't call actionMessage/actionConfirm/alert directly,
so no view changes are needed beyond the script includes.
app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched, same
reasoning as the other two apps' PRs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- New GET /api/user/hosts (auth-only): all hosts for admins, group-filtered
list for everyone else.
- accessibleHosts() accepts a pre-resolved user.groups, so the web UI's
OIDC session skips a redundant LDAP getGroups(dn) call.
- Dashboard shows a "Hosts you can reach" / "All hosts" table.
- @simpleworkjs/ldap 1.0.1 fixes addSshKey's ObjectClassViolationError on
accounts predating the ldapPublicKey objectClass -- was aborting key
injection (and the SSH connection) on affected accounts.
- Bump to 1.5.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rewire onto the shared @simpleworkjs/oidc-client, /directory-schema, /ldap, and
/app-stack packages (deleting the byte-identical local forks). utils/access.js
now fetches reachable hosts through the shared directory client, which
validates the {results} envelope and treats envelope drift as a failed group
rather than silently returning []. models/user_ldap.js is a thin wrapper over
createLdapClient (loose TLS default preserved). build_info moves to utils/ with
the shared {buildVersion,buildHash,buildYear} shape. Align ldapts ^8.1.8 and
redis ^6.1.0. Lockfile regenerated from the registry (no file:/link:).
Co-Authored-By: Claude <noreply@anthropic.com>
The web management UI was a bespoke minimal theme with LDAP-bind login.
Rebuild it to match the SSO Manager and Proxy — same stack, same look/feel,
same auth model. The SSH bridge, audit, metrics, and access logic are
unchanged; this is purely the web layer.
Frontend (mirrors proxy/sso):
- Express + EJS with the shared top.ejs/bottom.ejs shell, Bootstrap 5,
jQuery, jq-repeat, FontAwesome, Socket.IO, and the shared app-base.js /
val.js client framework (copied verbatim). Vendor libs served from
node_modules via /static-modules; app assets via /static.
- Dashboard / Sessions / Audit pages render in the common look/feel,
loading data through the authenticated /api/* endpoints.
Auth (mirrors proxy):
- OIDC against the SSO (utils/oidc.js + routes/auth.js + models/oidc_state)
plus a local anti-lockout admin (models/user_redis.js, bootstrapped from
auth.adminUsers[0] / auth.localAdminPass). AuthToken sessions carry the
group snapshot; middleware gates the data API on adminGroups or the local
admin. New config: oidc{} + auth.adminUsers/localAdminPass.
- /api/user/me drives the client login state; "Log in with SSO" hidden when
oidc.enabled is false.
Verified end to end: local admin login -> token -> /api/user/me isAdmin,
metrics/sessions/audit 200 with token / 401 without / 401 bad password;
static + page shells serve; 26 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An SSH jump host that authenticates users against the shared LDAP
directory, authorizes them from the SSO Manager's inventory graph, and
bridges them to downstream hosts — auditing everything.
- Username-grammar routing (uid_-_target@jump) + interactive TUI picker
- Inbound LDAP auth (publickey / password with off|local|all policy)
- Directory-driven access (LDAP groups x /api/discovery/resources?group=)
- Per-user key injection into sshPublicKey, connects downstream as the user
- Shell / exec / SFTP-subsystem bridging (WinSCP works)
- Web UI + HTTP API (:3002) for audit + metrics; LDAP-admin gated
- Packaged like proxy: ops/install.sh + systemd, all-in-one Docker, compose
- Tests: 23 unit + 3 integration (node --test), all green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>