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.
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.
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.
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>
* 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
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
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>
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>
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>
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.
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>
Add README/docs/secrets.js.example coverage for the new
@simpleworkjs/orm-backed standalone mode (no LDAP/SSO), and promote the
CHANGELOG's Unreleased entry to 1.4.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add standalone.enabled config flag to switch between LDAP+SSO and
ORM-backed backends without changing the production code path
- New ORM models: StandaloneUser (uid, passwordHash, sshPublicKeys,
groups) and StandaloneHost (slug, displayName, kind, metadata)
- user_file.js and hosts_file.js implement the same interfaces as the
LDAP client and accessibleHosts() respectively
- models/user_ldap.js and utils/access.js become conditional facades
that delegate based on conf.standalone.enabled at require time
- Zero changes to ssh_server.js core logic, bridge.js, key_inject.js,
tui_picker.js, or any other consumer
- Fix ssh_server.js: use ?? instead of || for listenPort (0 is falsy)
- Fix ssh_server.js: register session listeners before awaiting
audit.create() so client exec/shell requests aren't rejected
- Patch StringField.toSequelize() and IntegerField.toSequelize() to
pass through primaryKey (the ORM's UUIDField already does this)
- 47 tests pass (24 existing + 15 new unit + 3 existing integration
+ 5 new standalone integration)
- Defaults to SQLite; any Sequelize dialect works via conf.orm
Co-Authored-By: Claude <noreply@anthropic.com>
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).
Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
for each of the current user user/me groups. sso-manager-node sends LDAP
DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
undefined `callback2` reference that threw on a non-function callback.
Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.
jump-host specifics:
- .group-required base rule added to styles.css (no gated nav items yet).
- #spa-shell drops its inline margin-top; styles.css already sets it, and
the shared shell adjusts it when a banner is shown.
Co-Authored-By: Claude Opus 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>
node's own '**' glob expansion for --test worked on local node 22.23 but
not in CI (node 20/22 runners) — 'Could not find test/**/*.test.js'. Use
plain single-star globs the shell expands instead; test files are exactly
one level under test/unit and test/integration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
node --test --test-force-exit needs Node 20.14+/22, so the matrix is
[20.x, 22.x] and engines is bumped to match.
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>