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>
- 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>
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>
- 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'
- 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).
- 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.
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.
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.
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.
resolveAndConnect discarded the actual error from connectUpstream
(ECONNREFUSED, ETIMEDOUT, an ssh2 auth failure, ...) and replaced it with
the generic reason string 'upstream-unreachable', so the audit log gave no
way to tell a network-layer failure from an auth failure -- which is why
"Could not reach 192.168.1.206" for the emby host couldn't be root-caused
without live host-shell access. Now the real error message is captured and
surfaced as failDetail, shown as a tooltip on the audit table's fail badge.
request failed" / "shell request failed"
runTuiSession awaited audit.create() (a Redis round-trip) and then
accessibleHosts() (a directory API call) BEFORE calling runTui(), which
is where the pty/shell/exec/subsystem listeners actually get attached to
the session. The client sends its pty-req and shell requests immediately
after opening the session -- if either await took long enough for those
requests to arrive first, ssh2 auto-rejects any channel request with no
listener (CHANNEL_FAILURE), which is exactly what OpenSSH reports as
"PTY allocation request failed on channel 0" / "shell request failed on
channel 0". The connection then just sat there, since nothing was left
to drive it.
runGrammar already has this exact fix (see its own comment); runTuiSession
never got the equivalent treatment. Fixed the same way: register the
session listeners synchronously, before any await, by having runTui take
a Promise for the hosts list instead of the resolved list -- the shell
handler awaits it internally once the client actually sends a shell
request, which by construction happens only after the listener already
exists.
Verified: publickey auth against a real deployment succeeds (confirms
the earlier ldaps:// fix holds), and the failure reproduces with the
exact reported error strings for a bare `ssh user@host` (TUI/picker mode,
no target) connection. Full suite: 50/50 passing, no regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same fix as sso-manager-node/proxy: formAJAX's loading indicator passed a
raw <div class="spinner-border"> string to app.messages.action, which
HTML-escapes its message by design (@simpleworkjs/frontend). Replaced
with plain text ("Saving…").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Native confirm() blocks all further browser events on the page (found
live, mid browser-automation testing, on sso-manager-node's equivalent
secret-rotate flow -- it froze the tab). This app has no such call sites
(never did); this static check (scans views/ and public/js|lib/js for
bare alert(/confirm(/prompt() calls) keeps it that way.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>