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>
jump-host's docs had no screenshots at all. Add the four core web UI
views and reference them from index.md.
Co-Authored-By: Claude Sonnet 5 <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>
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (the host/DNS delete buttons) landed its callback in
the data slot and never ran.
The login page's "reveal the card once we know you're logged out" branch
touched an element further down the same page, which threw when
isLoggedIn answered before the parser got there (it always did without a
stored token). It now runs on DOM ready.
Co-Authored-By: Claude Opus 5 <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>
model-redis opens a client when models/index is required, so with no
redis the integration test saw ECONNREFUSED as a post-test unhandled
rejection. Provide redis:7 at 127.0.0.1:6379 in CI (mirrors how
sso-manager-node tests run).
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>
Mirrors the proxy/sso-manager docs-site layout (Jekyll + Bootstrap
default layout, theta42 assets). Documents the username grammar, the TUI
picker, SFTP/WinSCP, how auth/access/key-injection/bridging work, and the
three install paths + the required sshPublicKey write-ACL.
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>