Compare commits

...

31 Commits

Author SHA1 Message Date
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
wmantly a6af160627 Release 1.8.2: audit records carry the real upstream-connect error as failDetail (#21) 2026-07-28 17:57:28 -04:00
wmantly 8c9646b65c Thread real upstream-connect errors into audit records as failDetail (#19)
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.
2026-07-28 15:50:25 -04:00
wmantly 1d09f243dd Release 1.8.1: Redis persistence fix (#20) 2026-07-28 15:50:11 -04:00
wmantly ec4ca97af4 Fix: Redis had zero persistence — every rebuild wiped sessions, (#18)
in-flight OAuth logins, and any admin-created API token

redis-server ran with --save '' --appendonly no (deliberately ephemeral,
per the original "audit/metrics/session storage" framing). That stopped
being a safe assumption once API tokens (PATs) lived in this same Redis
-- a PAT is supposed to be a stable, long-lived credential, not
disposable session state, but every `docker rm -f jump-host` + rebuild
silently invalidated every one that existed.

Matches proxy's existing pattern exactly: AOF + periodic RDB persisted
to $REDIS_DATA_DIR (default /data), which the deployment mounts as a
volume (see the companion theta-env change).

Verified against a live container: minted a real PAT, force-recreated
the container (docker rm -f + rebuild), confirmed the same token still
authenticates afterward.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 15:46:40 -04:00
wmantly 21ef8960c4 Merge pull request #17 from theta42/release/1.8.0
Release 1.8.0
2026-07-28 13:35:18 -04:00
wmantly a5bef2980b Release 1.8.0: fix TUI-mode SSH connection drops, HTML-escaped loading indicator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:32:28 -04:00
wmantly ab2ee0fed3 Merge pull request #16 from theta42/fix/tui-session-listener-race
Fix: TUI-mode SSH connections could drop with PTY/shell request failed
2026-07-28 13:07:55 -04:00
wmantly ab9e04e007 Merge pull request #15 from theta42/fix/loading-message-html-escaped
Fix HTML-escaped loading indicator in formAJAX
2026-07-28 13:07:49 -04:00
wmantly a3a6787776 Fix: TUI-mode SSH connections could drop with "PTY allocation
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>
2026-07-28 12:58:49 -04:00
wmantly 0ead0199a3 Fix HTML-escaped loading indicator in formAJAX
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>
2026-07-28 12:53:08 -04:00
wmantly 0af2fc7e3d Merge pull request #14 from theta42/release/1.7.1
Release 1.7.1
2026-07-28 00:20:38 -04:00
wmantly bc2180116f Release 1.7.1: add no-native-dialogs regression test
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:19:25 -04:00
wmantly 1b70701795 Merge pull request #13 from theta42/test/no-native-dialogs
Add regression test: no native alert()/confirm()/prompt()
2026-07-27 23:58:56 -04:00
wmantly 98e1e0e279 Add regression test: no native alert()/confirm()/prompt()
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>
2026-07-27 23:57:41 -04:00
wmantly b03fcefaae Merge pull request #12 from theta42/release/1.7.0
Release 1.7.0
2026-07-27 20:26:56 -04:00
wmantly 3474482f6f Release 1.7.0: self-service API tokens
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 20:25:40 -04:00
wmantly da361a8a86 Merge pull request #11 from theta42/feature/api-tokens
Add self-service API tokens (PATs)
2026-07-27 20:13:40 -04:00
wmantly 4326d5588e Add self-service API tokens (PATs) — the UI had no way to create one
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>
2026-07-27 19:44:04 -04:00
wmantly 4a70b5b27e Merge pull request #10 from theta42/release/1.6.0
Release 1.6.0
2026-07-27 14:18:33 -04:00
wmantly 43caac13d5 Release 1.6.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:17:13 -04:00
wmantly 465f923393 Merge pull request #9 from theta42/modernize/simpleworkjs-frontend
Adopt @simpleworkjs/frontend messages/modal/validate modules
2026-07-27 14:05:40 -04:00
wmantly 782a829cb9 Adopt @simpleworkjs/frontend's messages/modal/validate modules
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>
2026-07-27 14:02:15 -04:00
wmantly fd863b89ca Merge pull request #8 from theta42/feature/dashboard-hosts
Dashboard: list hosts you can reach; fix key-injection ObjectClassViolationError
2026-07-26 23:12:30 -04:00
wmantly 4fb4e77007 Dashboard: list hosts you can reach; bump @simpleworkjs/ldap to 1.0.1
- 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>
2026-07-26 23:11:23 -04:00
wmantly a56a31421d Merge pull request #7 from theta42/feature/standalone-mode
Add standalone mode (no LDAP/SSO required)
2026-07-26 21:37:06 -04:00
wmantly d33e324b31 docs: document standalone mode; bump to 1.4.0
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>
2026-07-26 21:35:03 -04:00
wmantly 4879769cc7 Add standalone mode with @simpleworkjs/orm-backed user/host stores
- 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>
2026-07-26 21:34:53 -04:00
wmantly da274a3ced Merge pull request #6 from theta42/docs/screenshots-refresh
docs: add jump-host screenshots
2026-07-26 16:31:53 -04:00
wmantly b9be0fe4e1 docs: add jump-host screenshots (login, dashboard, sessions, audit)
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>
2026-07-26 16:29:10 -04:00
42 changed files with 2452 additions and 405 deletions
+56
View File
@@ -4,6 +4,62 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions 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`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [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
- **Audit records for a failed upstream connection only ever said `upstream-unreachable`** — `resolveAndConnect` discarded the real error from `connectUpstream` (ECONNREFUSED, ETIMEDOUT, an ssh2 auth-failure message, etc.) and replaced it with that one generic string, so there was no way to tell a network-layer failure from an auth failure from the audit log alone. This is what blocked root-causing the "Could not reach 192.168.1.206" (emby host) report — the real error is now captured and surfaced as a new `failDetail` field on the audit record, shown as a tooltip on the fail badge in the admin audit table.
## [1.8.1] - 2026-07-28
### Fixed
- **Redis had zero persistence** (`--save '' --appendonly no`, no data-dir volume) — every container rebuild/recreation silently wiped all sessions, in-flight OAuth logins, and any admin-created API token. This is why re-running `setup.sh` appeared to "break OAuth with jump": the jump-host container gets recreated, and any token or in-flight login vanished with it. Now Redis persists (AOF + periodic RDB) to `/data`, mounted as a named volume (`jump-redis-data`) in theta-env's compose file. Verified live: minted a PAT, force-recreated the container, confirmed the same PAT still authenticated afterward.
## [1.8.0] - 2026-07-28
### Fixed
- **TUI-mode SSH connections (a bare `ssh user@host`, no target) could drop with "PTY allocation request failed" / "shell request failed"** — `runTuiSession` awaited two real round-trips (an audit-log write, then a directory API call) *before* attaching the session's pty/shell/exec listeners, so a client that sent those requests quickly enough got auto-rejected by ssh2 before anything was listening. `runGrammar` (the `uid_-_target` path) already had the equivalent fix; this ports it to the picker path.
- **`formAJAX`'s loading indicator showed literal HTML**, not a spinner — same fix as sso-manager-node/proxy's companion releases.
## [1.7.1] - 2026-07-28
### Added
- **Regression test**: a static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page. This app has never had one; keeps it that way.
## [1.7.0] - 2026-07-27
### Added
- **Self-service API tokens (PATs)** — `models/api_token.js` + `routes/api_token.js` (mounted at `/api-token`), Bearer-token support in `middleware/auth.js`, and a create/list/rotate/revoke card on the dashboard. Ports proxy's `jmp_<id>_<secret>` pattern; unlike proxy's, a jump-host token carries no group claims, so it authenticates as its creator for non-admin routes only (never passes `requireAdmin`). jump-host previously had no PAT support at all.
## [1.6.0] - 2026-07-27
### Changed
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js` (unused by any current view here, so this is dedup/future-proofing rather than a behavior change). `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched.
## [1.5.0] - 2026-07-27
### Added
- **Web UI dashboard now lists the hosts you can reach** ("Hosts you can reach", or "All hosts" for admins) — previously the dashboard only showed usage metrics, with no way to see your actual access from the browser. Backed by a new `GET /api/user/hosts` endpoint (auth-only, not admin-gated): admins get the full inventory via `utils/access.js`'s new `allHosts()`, everyone else gets the same group-based resolution the SSH front door uses.
- `utils/access.js`'s `accessibleHosts()` now accepts a pre-resolved `groups` array on the user object, skipping the LDAP `getGroups(dn)` round-trip — the web UI's OIDC session already has its groups claim and has no LDAP `dn` to query with.
### Fixed
- **Bumped `@simpleworkjs/ldap` to 1.0.1**, which fixes `addSshKey` throwing `ObjectClassViolationError` (LDAP `0x41`) on accounts predating the `ldapPublicKey` auxiliary objectClass. This is the code path this jump host's key-injection (`utils/key_inject.js`) uses on every first connection for a user — on affected accounts it aborted the SSH connection entirely (`key-inject-failed`).
## [1.4.0] - 2026-07-26
### Added
- **Standalone mode** — run the jump host with no LDAP directory and no SSO Manager at all. Set `standalone.enabled: true` and user authentication and host discovery switch to `@simpleworkjs/orm`-backed stores (Sequelize; SQLite by default, any Sequelize-supported dialect via `conf.orm`) instead of the directory services. `models/user_ldap.js` and `utils/access.js` become conditional facades that pick their backend at require time — `ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, and the web UI are unchanged either way.
- New ORM models: `StandaloneUser` (`uid`, `passwordHash`, `sshPublicKeys`, `groups`) and `StandaloneHost` (`slug`, `displayName`, `kind`, `metadata`), plus `models/user_file.js` and `utils/hosts_file.js`, which implement the same interfaces as the LDAP client and `accessibleHosts()` respectively. There's no admin UI for standalone users/hosts yet — see the README's "Standalone mode" section for the ORM-model seeding snippet. In standalone mode every stored host is reachable by every stored user; there's no group-based authorization yet.
- 47 tests pass (24 existing + 15 new unit + 3 existing integration + 5 new standalone integration).
### Fixed
- **`services/ssh_server.js` used `|| 2222` for the listen port**, so an explicit `listenPort: 0` (ephemeral port, used by the test suite) was silently overridden back to 2222. Changed to `?? 2222`.
- **`services/ssh_server.js` awaited `audit.create()` before registering session listeners.** A client that sends `exec`/`shell` immediately after connecting could have its request dropped because nothing was listening yet. Listener registration now happens first.
## [1.3.0] - 2026-07-26 ## [1.3.0] - 2026-07-26
### Changed ### Changed
+52 -3
View File
@@ -2,9 +2,13 @@
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted 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 stack. Users SSH into one public host and land on any downstream host they're
entitled to — authenticated against the shared LDAP directory, authorized from entitled to — audited end to end.
the [SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory
graph, audited 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.
## Two ways to connect ## Two ways to connect
@@ -44,8 +48,53 @@ bridged straight in.
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the 4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
downstream sshd. Every session is audited. downstream sshd. Every session is audited.
## Standalone mode
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`):
```js
standalone: { enabled: true },
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
```
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:
```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 ## Requirements
*(default LDAP + SSO mode — see [Standalone mode](#standalone-mode) to skip
all of this)*
- The SSO Manager (OpenLDAP directory + `/api/discovery`). - The SSO Manager (OpenLDAP directory + `/api/discovery`).
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`). - Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on - An LDAP bind account with **write access to the `sshPublicKey` attribute** on
+11 -3
View File
@@ -12,9 +12,17 @@ if [[ -f /config/jump-secrets.js ]]; then
info "Loaded config from /config/jump-secrets.js" info "Loaded config from /config/jump-secrets.js"
fi fi
# Redis for audit/metrics/session storage (app connects to 127.0.0.1:6379). # Redis for audit/metrics/session AND api-token storage (app connects to
info "Starting redis..." # 127.0.0.1:6379). Persisted (AOF + periodic RDB) to /data, which the
redis-server --daemonize yes --save '' --appendonly no # deployment should mount as a volume -- without this, every container
# recreation silently wiped every session, in-flight OAuth login, and any
# admin-created API token, which is especially bad for the last one since a
# PAT is meant to be a stable, long-lived credential, not session state.
REDIS_DATA_DIR="${REDIS_DATA_DIR:-/data}"
mkdir -p "$REDIS_DATA_DIR"
info "Starting redis (AOF persisted to $REDIS_DATA_DIR)..."
redis-server --daemonize yes --dir "$REDIS_DATA_DIR" --appendonly yes \
--appendfilename appendonly.aof --save 900 1 --save 300 10 --save 60 10000
# Wait for redis to answer before starting the app. # Wait for redis to answer before starting the app.
for _ in $(seq 1 20); do for _ in $(seq 1 20); do
+1 -1
View File
@@ -1,5 +1,5 @@
title: Jump Host title: Jump Host
description: An SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics. description: An SSH jump host for the theta42 stack — directory-driven host bridging with audit and metrics; LDAP + SSO Manager by default, or fully standalone.
url: "https://theta42.github.io" url: "https://theta42.github.io"
baseurl: "/jump-host" baseurl: "/jump-host"
logo: /assets/img/theta42.svg logo: /assets/img/theta42.svg
+22
View File
@@ -112,6 +112,28 @@ Audit events and counters live in redis. Each event captures: user, auth method,
mode (grammar/picker), target slug/address/port, channel type, client IP, mode (grammar/picker), target slug/address/port, channel type, client IP,
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out. success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
## Standalone mode
Everything above describes the default backend. Set `standalone.enabled: true`
and two modules become conditional facades, swapping their entire
implementation at `require` time based on that flag — nothing else in the
codebase (`ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, the
web UI) changes or even knows which mode it's running in:
- **`models/user_ldap.js`** — LDAP client, or `models/user_file.js` (an
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)-backed
store implementing the same `getUser` / `getGroups` / `checkPassword` /
`addSshKey` interface).
- **`utils/access.js`** — LDAP groups + SSO `/api/discovery`, or
`utils/hosts_file.js` (same ORM package, same `accessibleHosts()` interface).
In standalone mode there's no group-based authorization: every stored host
is accessible to every stored user.
The ORM is Sequelize underneath, defaulting to a local SQLite file but
accepting any Sequelize-supported dialect via `conf.orm`. See
[Installation](installation.html#standalone-mode) for config and how to add
users/hosts (there's no admin UI for standalone data yet).
## Where it sits in the stack ## Where it sits in the stack
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the - **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
+4
View File
@@ -74,6 +74,10 @@ changes.
Targets that don't resolve to a host you're allowed to reach are refused (and 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. audited). Raw IPs that aren't a known directory host are denied by default.
> 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.
## Authentication ## Authentication
The jump host authenticates **you** against the directory: The jump host authenticates **you** against the directory:
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+18 -1
View File
@@ -1,7 +1,7 @@
--- ---
layout: default layout: default
title: Home title: Home
description: An SSH jump host for the theta42 stack — one public host, LDAP login, and directory-driven access to every downstream machine you're entitled to. description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to; LDAP by default, or fully standalone.
--- ---
# Jump Host # Jump Host
@@ -21,6 +21,21 @@ Part of the theta42 self-hosted identity stack, alongside
[Proxy](https://theta42.github.io/proxy/), composable with one command via [Proxy](https://theta42.github.io/proxy/), composable with one command via
[theta-env](https://theta42.github.io/theta-env/). [theta-env](https://theta42.github.io/theta-env/).
## Screenshots
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
*(click any screenshot to view full size)*
Don't want to run LDAP or the SSO Manager? **Standalone mode** stores users
and hosts in a local SQL database instead (SQLite by default, any
Sequelize-supported dialect if you want something else) — same SSH front door,
key injection, and audit trail. See
[Installation](installation.html#standalone-mode) to get started.
## Two ways to connect ## Two ways to connect
**Direct (WinSCP/SFTP-friendly):** **Direct (WinSCP/SFTP-friendly):**
@@ -79,6 +94,8 @@ This jump host answers both from your directory:
audit log, per-user/per-host counters audit log, per-user/per-host counters
- **Full audit trail** — who, target, method, result, bytes, duration, and the - **Full audit trail** — who, target, method, result, bytes, duration, and the
downstream host-key fingerprint downstream host-key fingerprint
- **Standalone mode** — no LDAP, no SSO Manager; users and hosts live in a
local SQL database (Sequelize, any dialect — SQLite by default)
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal - Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
installer, or bundled in theta-env installer, or bundled in theta-env
+46 -1
View File
@@ -1,7 +1,7 @@
--- ---
layout: default layout: default
title: Installation title: Installation
description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus the required LDAP write-ACL and port-22 options. description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus standalone mode (no LDAP/SSO), the LDAP write-ACL, and port-22 options.
--- ---
# Installation # Installation
@@ -10,6 +10,51 @@ Three ways to run the jump host, in increasing manual effort. All read their
config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf) config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf)
(`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env). (`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env).
## Standalone mode (no LDAP/SSO) {#standalone-mode}
Skip LDAP and the SSO Manager entirely. Not to be confused with "Standalone
Docker" below, which is still LDAP + SSO, just run outside theta-env. Set in your secrets/config:
```js
standalone: { enabled: true },
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
```
`orm` is passed straight to Sequelize, so any supported dialect works — SQLite
is just the zero-dependency default. Everything downstream of auth (bridging,
key injection, the web UI, audit) is unchanged.
There's no admin UI for standalone users/hosts yet, so add them directly with
the ORM models:
```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 },
});
```
Every host in the standalone inventory is reachable by every standalone user —
there's no group-based authorization yet (`groups` on `StandaloneUser` is
accepted for interface parity with the LDAP path, not enforced).
The rest of this page (requirements, the LDAP write-ACL, the three install
paths) describes the default LDAP + SSO mode — skip it if you're running
standalone.
## Requirements ## Requirements
- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP - The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
+15
View File
@@ -98,6 +98,21 @@ module.exports = {
maxEvents: 50000, maxEvents: 50000,
}, },
// Standalone mode: run without LDAP or SSO Manager. When enabled, user
// authentication and host discovery use @simpleworkjs/orm-backed stores
// (Sequelize, defaulting to SQLite) instead of the directory services.
standalone: {
enabled: false,
},
// ORM config for standalone mode. Passed through to Sequelize — any dialect
// works. Defaults to SQLite for zero-dependency local dev.
orm: {
dialect: 'sqlite',
storage: './data/standalone.sqlite',
logging: false,
},
// Orchestrator-only keys (ignored by the app, read by theta-env). // Orchestrator-only keys (ignored by the app, read by theta-env).
stack: {}, stack: {},
}; };
+8
View File
@@ -4,4 +4,12 @@ module.exports = {
ssh: { ssh: {
hostKeyPath: './data/keys', hostKeyPath: './data/keys',
}, },
standalone: {
enabled: true,
},
orm: {
dialect: 'sqlite',
storage: './data/standalone.sqlite',
logging: false,
},
}; };
+18
View File
@@ -9,6 +9,24 @@ const { Auth } = require('../models');
async function auth(req, res, next){ async function auth(req, res, next){
try{ try{
// API-only token: `Authorization: Bearer jmp_<id>_<secret>`. Carries no
// group claims (see models/api_token.js), so it authenticates as its
// creator but never passes requireAdmin below.
const authz = req.header('authorization') || '';
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
const t = await Auth.checkApiToken(authz.slice(7));
req.token = {
user: {username: t.created_by},
created_by: t.created_by,
groupsArray: () => [],
check: () => true,
is_valid: true,
};
req.user = req.token.user;
req.groups = [];
return next();
}
req.token = await Auth.checkToken(req.header('auth-token')); req.token = await Auth.checkToken(req.header('auth-token'));
req.user = req.token.user; req.user = req.token.user;
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : []; req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const Table = require('.');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
// Self-service personal access token (PAT) for the jump host's own API.
// Format: jmp_<id>_<secret>
// id — 24-char hex, stored plaintext as the record key (O(1) lookup)
// secret — 48-char hex, stored only as a bcrypt hash (isPrivate); shown ONCE
//
// Authenticated via `Authorization: Bearer jmp_...`. Mirrors proxy's
// models/api_token.js — see that file for the fuller design notes. jump-host
// has no per-user group snapshot the way proxy/sso do (its authz is a single
// admin/non-admin bit off conf.auth.adminGroups/adminUsers), so a token
// authenticates as its creator only; the auth middleware re-derives
// admin-ness from that user's current groups, same as a live session.
//
// No `static _ttl`: records persist (lifetime is the optional expires_at field).
const PREFIX = 'jmp_';
const randomHex = (bytes) => crypto.randomBytes(bytes).toString('hex');
class ApiToken extends Table{
static _key = 'id';
static _keyMap = {
'id': {default: function(){ return randomHex(12) }, type: 'string'},
'secret_hash': {isRequired: true, type: 'string', isPrivate: true},
'name': {isRequired: true, type: 'string', min: 1, max: 255},
'description': {default: '', type: 'string'},
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'expires_at': {default: 0, type: 'number'}, // epoch ms; 0 = never
'last_used_on': {default: 0, type: 'number'},
'is_valid': {default: true, type: 'boolean'},
}
get isExpired() {
return this.expires_at > 0 && (new Date).getTime() > this.expires_at;
}
static async add(data){
const id = randomHex(12);
const secret = randomHex(24);
data.id = id;
data.secret_hash = await bcrypt.hash(secret, 10);
const token = await this.create(data);
token._raw_token = `${PREFIX}${id}_${secret}`;
return token;
}
async rotate(){
const secret = randomHex(24);
await this.update({ secret_hash: await bcrypt.hash(secret, 10) });
return `${PREFIX}${this.id}_${secret}`;
}
// Validate a raw `jmp_<id>_<secret>` string. Throws a generic Error on any
// failure so the caller (Auth.checkApiToken) can collapse every case into
// one 401 (no existence / wrong-secret / expired leak).
static async authenticate(raw){
const m = /^jmp_([0-9a-f]{24})_([0-9a-f]{48})$/i.exec(String(raw || ''));
if(!m) throw new Error('InvalidApiToken');
let token;
try{
token = await this.get(m[1]);
}catch(e){
throw new Error('InvalidApiToken');
}
if(!token) throw new Error('InvalidApiToken');
const ok = await bcrypt.compare(m[2], token.secret_hash);
if(!ok || !token.is_valid || token.isExpired) throw new Error('InvalidApiToken');
// Best-effort: stamp last use. Fire-and-forget so a Redis hiccup never
// fails an otherwise-valid request.
try{ await token.update({ last_used_on: (new Date).getTime() }); }catch(_){}
return token;
}
}
ApiToken.register();
module.exports = {ApiToken};
+22 -5
View File
@@ -32,14 +32,17 @@ async function getRedis() {
module.exports.getRedis = getRedis; module.exports.getRedis = getRedis;
// Register models (order matters: User before AuthToken's relation resolves). // Register models (order matters: User before AuthToken's relation resolves,
// and before ApiToken so `require('.')`'s Table is already exporting User).
require('./user_redis'); // User (redis-backed local + OIDC JIT) require('./user_redis'); // User (redis-backed local + OIDC JIT)
const { ApiToken } = require('./api_token');
module.exports.ApiToken = ApiToken;
// Shared OIDC client (authorization-code + PKCE): session models (Token, // Shared OIDC client (authorization-code + PKCE): session models (Token,
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start // AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
// /oidc/callback router — all created on this app's Table/redis. jump-host has // /oidc/callback router — all created on this app's Table/redis. checkApiToken
// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent). // wraps ApiToken.authenticate, same wiring as proxy's models/index.js.
const oidcClient = createOidcClient({ Table }); const oidcClient = createOidcClient({ Table, checkApiToken: (raw) => ApiToken.authenticate(raw) });
module.exports.Token = oidcClient.Token; module.exports.Token = oidcClient.Token;
module.exports.AuthToken = oidcClient.AuthToken; module.exports.AuthToken = oidcClient.AuthToken;
module.exports.OidcState = oidcClient.OidcState; module.exports.OidcState = oidcClient.OidcState;
@@ -49,4 +52,18 @@ module.exports.authRouter = oidcClient.router;
require('./audit_event'); require('./audit_event');
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js). // Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' }); bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
// Standalone mode: initialize @simpleworkjs/orm for local user/host stores.
// The ORM must be loaded before any code calls user_ldap or access — both of
// which check conf.standalone.enabled at require time and may delegate to the
// ORM-backed wrappers. Model registration is synchronous; table sync is async
// but the first query will implicitly wait (Sequelize.sync is in-flight).
// Export the promise so integration tests can await it before seeding data.
let ormReady = Promise.resolve();
if (conf.standalone && conf.standalone.enabled) {
const { init } = require('@simpleworkjs/orm');
const ormConf = conf.orm || { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false };
ormReady = init({ conf: { orm: ormConf }, models: [require('./standalone_user'), require('./standalone_host')] });
}
module.exports.ormReady = ormReady;
+30
View File
@@ -0,0 +1,30 @@
'use strict';
// ORM model for standalone-mode hosts. Stored in the configured SQL database
// (default SQLite) when conf.standalone.enabled is true. The hosts_file.js
// wrapper translates between this model and the accessibleHosts() interface
// that ssh_server.js expects.
const { Model, fields } = require('@simpleworkjs/orm');
// Patch StringField.toSequelize() to pass through primaryKey (same fix as in
// standalone_user.js — see that file for details).
if (!fields.StringField.prototype.toSequelize.toString().includes('primaryKey')) {
const orig = fields.StringField.prototype.toSequelize;
fields.StringField.prototype.toSequelize = function () {
const def = orig.call(this);
if (this.primaryKey) def.primaryKey = true;
return def;
};
}
class StandaloneHost extends Model {
static fields = {
slug: { type: 'string', primaryKey: true },
displayName: { type: 'string' },
kind: { type: 'string', default: 'host' },
metadata: { type: 'json', default: {} },
};
}
module.exports = StandaloneHost;
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// ORM model for standalone-mode users. Stored in the configured SQL database
// (default SQLite) when conf.standalone.enabled is true. The user_file.js
// wrapper translates between this model and the LDAP-client interface that
// ssh_server.js and key_inject.js expect.
const { Model, fields } = require('@simpleworkjs/orm');
// Patch: StringField.toSequelize() and IntegerField.toSequelize() don't pass
// through primaryKey / autoIncrement (unlike UUIDField which does). Fix them
// so string and int primary keys work.
const origStringToSeq = fields.StringField.prototype.toSequelize;
fields.StringField.prototype.toSequelize = function () {
const def = origStringToSeq.call(this);
if (this.primaryKey) def.primaryKey = true;
return def;
};
const origIntToSeq = fields.IntegerField.prototype.toSequelize;
fields.IntegerField.prototype.toSequelize = function () {
const def = origIntToSeq.call(this);
if (this.primaryKey) def.primaryKey = true;
if (this.autoIncrement) def.autoIncrement = true;
return def;
};
class StandaloneUser extends Model {
static fields = {
uid: { type: 'string', primaryKey: true },
passwordHash: { type: 'string', isPrivate: true },
sshPublicKeys: { type: 'json', default: [] },
groups: { type: 'json', default: [] },
};
}
module.exports = StandaloneUser;
+65
View File
@@ -0,0 +1,65 @@
'use strict';
// ORM-backed user store for standalone mode. Implements the same interface as
// the @simpleworkjs/ldap client so ssh_server.js and key_inject.js work
// unchanged: getUser(uid), getGroups(dn), checkPassword(dn, pw), addSshKey(dn, keyLine).
//
// Users are stored via the StandaloneUser ORM model (Sequelize, any dialect).
// DNs are synthetic: uid=<uid>,ou=people,dc=standalone,dc=local — the real
// identity is the uid; the DN exists only for interface compatibility with
// callers that thread user.dn through to checkPassword / addSshKey.
const bcrypt = require('bcrypt');
const StandaloneUser = require('./standalone_user');
const DN_PREFIX = 'uid=';
const DN_SUFFIX = ',ou=people,dc=standalone,dc=local';
function dnFor(uid) {
return `${DN_PREFIX}${uid}${DN_SUFFIX}`;
}
function uidFromDn(dn) {
if (!dn || typeof dn !== 'string') return null;
const m = dn.match(/^uid=([^,]+)/);
return m ? m[1] : null;
}
async function getUser(uid) {
const user = await StandaloneUser.get(uid);
if (!user) return null;
return {
dn: dnFor(user.uid),
uid: user.uid,
sshPublicKeys: user.sshPublicKeys || [],
};
}
async function getGroups(dn) {
const uid = uidFromDn(dn);
if (!uid) return [];
const user = await StandaloneUser.get(uid);
if (!user) return [];
return user.groups || [];
}
async function checkPassword(dn, pw) {
const uid = uidFromDn(dn);
if (!uid) return false;
const user = await StandaloneUser.get(uid);
if (!user || !user.passwordHash) return false;
return bcrypt.compare(pw, user.passwordHash);
}
async function addSshKey(dn, keyLine) {
const uid = uidFromDn(dn);
if (!uid) return;
const user = await StandaloneUser.get(uid);
if (!user) return;
const keys = [...(user.sshPublicKeys || [])];
if (keys.includes(keyLine)) return; // idempotent
keys.push(keyLine);
await user.update({ sshPublicKeys: keys });
}
module.exports = { getUser, getGroups, checkPassword, addSshKey };
+17 -16
View File
@@ -1,22 +1,23 @@
'use strict'; 'use strict';
// Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the // User authentication backend — LDAP in production, ORM-backed file store in
// shared @simpleworkjs/ldap package: // standalone mode. Both export the same interface:
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null // getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
// getGroups(dn) -> [cn, ...] (groupOfNames membership) // getGroups(dn) -> [cn, ...]
// checkPassword(dn, pw) -> bool (simple bind as the user) // checkPassword(dn, pw) -> bool
// addSshKey(dn, keyLine) -> void (idempotent multi-value add) // addSshKey(dn, keyLine) -> void (idempotent)
//
// Behavior is unchanged from the previous in-tree implementation: posixAccount
// user filter, groupOfNames group filter, bind-as-user password check,
// TypeOrValueExists treated as success on key add, and the same loose TLS
// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions).
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { createLdapClient } = require('@simpleworkjs/ldap');
const ldapConf = conf.ldap || {}; if (conf.standalone && conf.standalone.enabled) {
module.exports = createLdapClient({ // Standalone mode: use the ORM-backed user store.
...ldapConf, module.exports = require('./user_file');
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false }, } else {
}); // Production mode: use the LDAP directory.
const { createLdapClient } = require('@simpleworkjs/ldap');
const ldapConf = conf.ldap || {};
module.exports = createLdapClient({
...ldapConf,
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
});
}
+866 -7
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-jump-host", "name": "t42-jump-host",
"version": "1.3.0", "version": "1.9.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics", "description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [ "author": [
{ {
@@ -20,11 +20,13 @@
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0", "@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
+14 -3
View File
@@ -11,11 +11,22 @@ app.jump = (function(app){
var qs = $.param(query || {}); var qs = $.param(query || {});
app.api.get('audit' + (qs ? '?' + qs : ''), cb); app.api.get('audit' + (qs ? '?' + qs : ''), cb);
} }
return {metrics: metrics, sessions: sessions, audit: audit}; function hosts(cb){ app.api.get('user/hosts', cb); }
return {metrics: metrics, sessions: sessions, audit: audit, hosts: hosts};
})(app);
// Self-service API token (PAT) management.
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 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};
})(app); })(app);
// Shared render helpers. // Shared render helpers.
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; }; app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); }; app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); };
app.jump.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>' app.jump.result = function(e){ if (e.success) return '<span class="badge bg-success">ok</span>';
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; }; var title = e.failDetail ? ' title="' + app.jump.esc(e.failDetail) + '"' : '';
return '<span class="badge bg-danger"' + title + '>' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
+21 -78
View File
@@ -363,7 +363,7 @@ app.auth = (function(app){
} }
if(requiredGroups && !await memberOf(requiredGroups, user)){ if(requiredGroups && !await memberOf(requiredGroups, user)){
app.util.actionMessage( app.messages.action(
`<h1> `<h1>
<i class="fa-solid fa-triangle-exclamation"></i> <i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b> <b>You do not have permission to be here.</b>
@@ -520,68 +520,15 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
}; };
function actionMessage(message, $targetPassed, type, callback){ // escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
message = message || ''; // app.util.escapeHtml and app.messages.action/confirm.
function escapeHtml(s){
let $target = $targetPassed.closest('div.card').find('.actionMessage'); return String(s == null ? '' : s)
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]); .replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
type = type || 'info'; .replace(/>/g, '&gt;')
callback = callback || function(){}; .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
if($target.html() === message) return;
if($target.html()){
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function(index, className){
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('bg-' + type);
// Messages that bring their own buttons (actionConfirm) are left
// alone; everything else gets the standard dismiss button.
if(!message.includes('<button')) message = `
<span class="align-middle">${message}</span>
<button class="action-close btn btn-sm btn-outline-dark float-end">
<i class="fa-solid fa-xmark"></i>
</button>
`
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
}
function actionConfirm(message, $target, type, callback){
return new Promise((resolve, reject) =>{
let id = crypto.randomUUID();
message = `
<h4 class"align-middle" >
<i class="fa-solid fa-triangle-exclamation"></i>
<b>${message}</b>
<span class="float-end">
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
<i class="fa-solid fa-circle-check"></i>
Confirm
</button>
<button type="button" class="btn btn-danger confirm-${id}">
<i class="fa-solid fa-circle-stop"></i>
Cancel
</button>
</span>
</h4>
`
actionMessage(message, $target, type);
$("body").on('click', `.confirm-${id}`, function(){
actionMessage('', $target, type);
resolve(!!$(this).data('confirm'));
});
});
} }
$.fn.serializeObject = function() { $.fn.serializeObject = function() {
@@ -640,8 +587,7 @@ app.util = (function(app){
return { return {
downloadFile: downloadFile, downloadFile: downloadFile,
getUrlParameter: getUrlParameter, getUrlParameter: getUrlParameter,
actionMessage: actionMessage, escapeHtml: escapeHtml,
actionConfirm,
} }
})(app); })(app);
@@ -696,9 +642,9 @@ $( document ).ready(async function(){
$(this).closest('.card').slideUp('fast'); $(this).closest('.card').slideUp('fast');
}); });
$('.actionMessage').on('click', 'button.action-close', function(event){ // action-close click handling is wired by @simpleworkjs/frontend's
app.util.actionMessage(null, $(this)); // app.messages.js (delegated on document, so it also covers messages
}); // rendered after this ready handler runs).
setInterval(()=>{ setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{ $('.momentFromNow').each((idx, el)=>{
@@ -729,20 +675,17 @@ function formAJAX(btn){
var method = ($form.attr('method') || 'post').toLowerCase(); var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){ if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger') app.messages.action('Please fix the form errors.', $form, 'danger')
return false; return false;
} }
app.util.actionMessage( // Plain text: app.messages.action HTML-escapes its message (by design,
`<div class="spinner-border" role="status"> // see @simpleworkjs/frontend), so raw markup like a spinner <div> would
<span class="visually-hidden">Loading...</span> // render literally instead of as an element.
</div>`, app.messages.action('Saving…', $form, 'info');
$form,
'info'
);
app.api[method]($form.attr('action'), formData, function(error, data){ app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
$form.validateClear(); $form.validateClear();
if(!error){ if(!error){
$form.trigger("reset"); $form.trigger("reset");
@@ -750,7 +693,7 @@ function formAJAX(btn){
}else{ }else{
console.log('formAJAX res error', error, data) console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){ if(data && data.name === 'ObjectValidateError'){
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
} }
if(data && data.keys){ if(data && data.keys){
console.log('form key errors', data.keys) console.log('form key errors', data.keys)
-201
View File
@@ -1,201 +0,0 @@
( function( $ ) {
var settings = {
rule: {
eq: function(value, options){
var compare = $('[name=' + options + ']').val();
if ( value != compare ) {
return "Miss-match";
}
}
},
};
$.fn.validate = function(event) {
// let thisSettings = $.extend(true, settings, settingsObj);
let hasErrors = false;
if(this.is('[validate]')) return this.validateField(event);
if(!this.attr('isValid')){
console.log('adding reset event')
this.on('reset', function(){
$(this).attr('isValid', false);
$(this).validateClear();
})
}
this.find('[validate]').each(function(){
if(!$(this).validateField()) hasErrors = true;
});
this.attr('isValid', !hasErrors);
if(hasErrors && event) event.preventDefault();
return !hasErrors;
};
$.fn.validateClear = function(){
$(this).find('input').each(function(){
$(this).removeClass('is-invalid');
$(this).removeClass('is-valid');
})
}
$.fn.validateField = function(){
var attr = this.attr('validate').split(':'); //array of params
var rule = attr[0];
var options = attr[1];
var value = this.val(); //link to input value
var message;
if(this.prop('disabled')) return true;
//checks if field is required, and length
if(!isNaN(options) && value.length < options){
message = `Must be ${options} characters`;
}
//checks if empty to stop processing
if(!isNaN(options) && value.length === 0) {
}else if(rule in settings.rule){
message = settings.rule[rule].apply(this, [value, options]);
}
this.validateMessage(message)
return !message;
}
$.fn.validateMessage = function(message){
if(message && message !== true){
this.closest('.form-group').find('b.invalid-feedback').html(message);
this.addClass('is-invalid');
}else{
this.removeClass('is-invalid');
this.addClass('is-valid');
}
return this;
};
jQuery.extend({
validateSettings: function( settingsObj ) {
$.extend( true, settings, settingsObj );
},
validateInit: function( ettingsObj ) {
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
$( this ).validate( settingsObj, event );
});
}
});
}( jQuery ));
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
// incoming host may be a wildcard ("*.example.com"); the target may not.
(function(){
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
// Either one bare label (Docker service names, /etc/hosts entries) or a
// dotted hostname with an alphabetic TLD.
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
var FORBIDDEN = /[\s/:]/;
function isIPv4( value ) {
var parts = value.split( '.' );
if ( parts.length !== 4 ) return false;
return parts.every( function( p ) {
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
});
}
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
// (any number of fragments, incl. a bare "**" global catch-all).
function isHostPattern( value ) {
if ( value.length > 253 ) return false;
return value.split( '.' ).every( function( l ) {
return l === '*' || l === '**' || LABEL.test( l );
});
}
function forbidden( value ) {
return FORBIDDEN.test( value ) || value.includes( '://' );
}
// Incoming host: IPv4 or a wildcard host pattern.
function checkHost( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || isHostPattern( value ) ) return;
return "Enter a valid host or wildcard (*, **)";
}
// Downstream target: IPv4 or a strict hostname, no wildcard.
function checkTarget( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
return "Enter a valid hostname or IP";
}
$.validateSettings({
rule:{
ip: function( value ) {
value = value.split( '.' );
if ( value.length != 4 ) {
return "Malformed IP";
}
$.each( value, function( key, value ) {
if( value > 255 || value < 0 ) {
return "Malformed IP";
}
});
},
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
host: function( value ) {
return checkHost( value );
},
// Downstream target — hostname or IPv4, no wildcard.
target: function( value ) {
return checkTarget( value );
},
// Back-compat alias (no wildcard).
hostname: function( value ) {
return checkTarget( value );
},
user: function( value ) {
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
// or at least 3 of {lowercase, uppercase, number, symbol}.
password: function( value ) {
if ( typeof value !== 'string' || value.length < 8 ) {
return "Password must be at least 8 characters";
}
if ( value.length >= 12 ) return;
var classes = 0;
if ( /[a-z]/.test( value ) ) classes++;
if ( /[A-Z]/.test( value ) ) classes++;
if ( /[0-9]/.test( value ) ) classes++;
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
if ( classes < 3 ) {
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
}
}
}
});
})();
+4
View File
@@ -9,6 +9,10 @@ router.use('/auth', require('../models').authRouter);
// Who am I — needs a valid session but no admin gate (drives the login state). // Who am I — needs a valid session but no admin gate (drives the login state).
router.use('/user', middleware.auth, require('./user')); router.use('/user', middleware.auth, require('./user'));
// Self-service API token (PAT) management — any authenticated user, no
// 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). // Jump-host data — admin only (audit log, active sessions, metrics).
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump')); router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
+120
View File
@@ -0,0 +1,120 @@
'use strict';
// Self-service API token (PAT) management. Every endpoint is owner-scoped: a
// user only sees / mutates tokens where created_by === req.user.username.
// Mirrors proxy's routes/api_token.js. Mounted under middleware.auth only
// (no requireAdmin) — any authenticated user may mint one, but the token
// itself carries no group claims (see models/api_token.js), so it can only
// reach non-admin routes (e.g. GET /api/user/hosts), never the admin-gated
// ones under routes/jump.js.
const router = require('express').Router();
const {ApiToken} = require('../models');
function forbidden(){
let error = new Error('Forbidden');
error.name = 'Forbidden';
error.message = 'You do not own this API token.';
error.status = 403;
return error;
}
// Resolve a token the caller owns. Missing or not-yours both raise 403 (no
// existence leak; ids are unguessable random hex anyway).
async function getOwned(req, id){
let token;
try{
token = await ApiToken.get(id);
}catch(e){
throw forbidden();
}
if(!token || token.created_by !== req.user.username) throw forbidden();
return token;
}
router.get('/', async function(req, res, next){
try{
return res.json({results: await ApiToken.listDetail({created_by: req.user.username})});
}catch(error){
next(error);
}
});
router.post('/', async function(req, res, next){
try{
const days = req.body.expires_in_days !== '' && req.body.expires_in_days !== undefined
? Number(req.body.expires_in_days) : 0;
const token = await ApiToken.add({
name: req.body.name,
description: req.body.description || '',
created_by: req.user.username,
expires_at: days > 0 ? (new Date).getTime() + days * 86400000 : 0,
});
return res.json({
results: token,
token: token._raw_token,
message: `API token '${token.name}' created. Save it now — it will not be shown again.`,
});
}catch(error){
next(error);
}
});
router.get('/:id', async function(req, res, next){
try{
return res.json({results: await getOwned(req, req.params.id)});
}catch(error){
next(error);
}
});
router.put('/:id', async function(req, res, next){
try{
const token = await getOwned(req, req.params.id);
const update = {};
for(const k of ['name', 'description']){
if(req.body[k] !== undefined) update[k] = req.body[k];
}
if(req.body.expires_in_days !== undefined && req.body.expires_in_days !== ''){
const days = Number(req.body.expires_in_days);
update.expires_at = days > 0 ? (new Date).getTime() + days * 86400000 : 0;
}else if(req.body.expires_at !== undefined){
update.expires_at = Number(req.body.expires_at) || 0;
}
return res.json({
results: await token.update(update),
message: `API token '${token.name}' updated.`,
});
}catch(error){
next(error);
}
});
router.delete('/:id', async function(req, res, next){
try{
const token = await getOwned(req, req.params.id);
await token.remove();
return res.json({id: req.params.id, message: `API token '${token.name}' revoked.`});
}catch(error){
next(error);
}
});
router.post('/:id/rotate', async function(req, res, next){
try{
const token = await getOwned(req, req.params.id);
const raw = await token.rotate();
return res.json({
token: raw,
message: `API token '${token.name}' rotated. Save it — it will not be shown again.`,
});
}catch(error){
next(error);
}
});
module.exports = router;
+5 -1
View File
@@ -14,6 +14,10 @@ const values = {
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '', titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name, name: conf.name,
logo: conf.logo, 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, ...buildInfo,
}; };
@@ -21,7 +25,7 @@ const values = {
// as the sibling apps), and the app's own JS/CSS/img from public/. // as the sibling apps), and the app's own JS/CSS/img from public/.
mountStaticModules(router, { mountStaticModules(router, {
root: path.join(__dirname, '..'), root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'], deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat', '@simpleworkjs/frontend'],
}); });
// Liveness probe — no auth. // Liveness probe — no auth.
+13
View File
@@ -5,6 +5,7 @@
const router = require('express').Router(); const router = require('express').Router();
const { isAdmin } = require('../middleware/auth'); const { isAdmin } = require('../middleware/auth');
const access = require('../utils/access');
router.get('/me', (req, res) => { router.get('/me', (req, res) => {
res.json({ res.json({
@@ -14,4 +15,16 @@ router.get('/me', (req, res) => {
}); });
}); });
// The hosts this session can SSH to — every host for an admin, otherwise the
// same group-based resolution the SSH front door uses (accessibleHosts),
// fed the OIDC session's already-known groups instead of an LDAP lookup.
router.get('/hosts', async (req, res, next) => {
try {
const hosts = isAdmin(req)
? await access.allHosts()
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
res.json({ results: hosts });
} catch (err) { next(err); }
});
module.exports = router; module.exports = router;
+63 -21
View File
@@ -130,7 +130,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
let justInjected = false; let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { throw fail('key-inject-failed'); } catch (err) { throw fail('key-inject-failed', err.message); }
let upstream; let upstream;
try { try {
@@ -139,20 +139,32 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
username: state.uid, privateKey: JUMP_KEYS.clientKey, username: state.uid, privateKey: JUMP_KEYS.clientKey,
uid: state.uid, justInjected, onHostKey, uid: state.uid, justInjected, onHostKey,
}); });
} catch (_) { throw fail('upstream-unreachable'); } } catch (err) { throw fail('upstream-unreachable', err.message); }
return { upstream, host, endpoint }; return { upstream, host, endpoint };
} }
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; } // 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; }
async function runGrammar(session, client, state) { async function runGrammar(session, client, state) {
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' }); // Register session listeners IMMEDIATELY — before any async work.
// The client sends exec/shell requests right after opening the session;
// Deferred upstream — attach the bridge NOW, resolve/reject after connect. // if we await audit.create() first, those requests arrive before the
// listeners are registered and ssh2 rejects them with CHANNEL_FAILURE.
let resolveUp, rejectUp; let resolveUp, rejectUp;
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; }); const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
attachSession(session, upstreamPromise, record); const dummyAudit = { patch() {}, finish() {}, event: {} };
attachSession(session, upstreamPromise, dummyAudit);
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
// Wire the real audit record into the already-attached session.
dummyAudit.patch = (...a) => record.patch(...a);
dummyAudit.finish = (...a) => record.finish(...a);
Object.defineProperty(dummyAudit, 'event', { get: () => record.event });
try { try {
const { upstream, host, endpoint } = await resolveAndConnect(state, record, { const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
@@ -166,25 +178,47 @@ async function runGrammar(session, client, state) {
} catch (err) { } catch (err) {
const reason = err.reason || 'error'; const reason = err.reason || 'error';
rejectUp(new Error(reasonMessage(reason))); rejectUp(new Error(reasonMessage(reason)));
await record.finish({ success: false, failReason: 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, success: false });
} }
} }
async function runTuiSession(session, client, state) { async function runTuiSession(session, client, state) {
// Register session listeners IMMEDIATELY, before any await — same fix,
// same reason, as runGrammar above. The client sends pty-req and shell
// requests right after opening the session; awaiting audit.create() and
// accessibleHosts() first (both real round-trips: Redis, then the
// directory API) left a window where those requests could arrive before
// runTui had attached any listener for them, and ssh2 auto-rejects an
// unlistened channel request with CHANNEL_FAILURE — surfacing to the
// client as "PTY allocation request failed" / "shell request failed",
// with the connection then just sitting there (nothing left to drive it).
let resolveHosts, rejectHosts;
const hostsPromise = new Promise((res, rej) => { resolveHosts = res; rejectHosts = rej; });
// A silent catch so a rejection isn't "unhandled" if the client never
// sends a shell request at all (exec-only) — runTui's own .catch() below
// still runs independently when it does.
hostsPromise.catch(() => {});
const tuiPromise = runTui(session, state.uid, hostsPromise);
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' }); const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
const finishFail = async (reason) => { const finishFail = async (reason, detail) => {
await record.finish({ success: false, failReason: reason }); await record.finish({ success: false, failReason: reason, failDetail: detail });
await metrics.bump({ uid: state.uid, success: false }); await metrics.bump({ uid: state.uid, success: false });
try { client.end(); } catch (_) {} try { client.end(); } catch (_) {}
}; };
let hosts; let hosts;
try { hosts = await accessibleHosts(state.user); } try {
catch (_) { return finishFail('directory-unreachable'); } hosts = await accessibleHosts(state.user);
resolveHosts(hosts);
} catch (_) {
rejectHosts(new Error('directory-unreachable'));
return finishFail('directory-unreachable');
}
const tui = await runTui(session, state.uid, hosts); const tui = await tuiPromise;
if (!tui.host) return finishFail('cancelled'); if (!tui.host) return finishFail('cancelled');
state.target = tui.host.slug; state.target = tui.host.slug;
@@ -193,7 +227,7 @@ async function runTuiSession(session, client, state) {
let justInjected = false; let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { return finishFail('key-inject-failed'); } catch (err) { return finishFail('key-inject-failed', err.message); }
let upstream; let upstream;
try { try {
@@ -202,9 +236,9 @@ async function runTuiSession(session, client, state) {
username: state.uid, privateKey: JUMP_KEYS.clientKey, username: state.uid, privateKey: JUMP_KEYS.clientKey,
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }), uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
}); });
} catch (_) { } catch (err) {
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {} try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
return finishFail('upstream-unreachable'); return finishFail('upstream-unreachable', err.message);
} }
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug }); registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
@@ -245,7 +279,10 @@ function reasonMessage(reason) {
// Run the TUI picker over a shell channel; returns { host, channel, ptyInfo }. // Run the TUI picker over a shell channel; returns { host, channel, ptyInfo }.
// host is null if the user quit. exec/subsystem in picker mode are rejected. // host is null if the user quit. exec/subsystem in picker mode are rejected.
function runTui(session, uid, hosts) { // Takes a Promise for the accessible-hosts list (not the resolved list)
// so the caller can register these listeners before that lookup completes
// — see the comment in runTuiSession for why that ordering matters.
function runTui(session, uid, hostsPromise) {
return new Promise((resolve) => { return new Promise((resolve) => {
let ptyInfo = null; let ptyInfo = null;
let settled = false; let settled = false;
@@ -254,9 +291,14 @@ function runTui(session, uid, hosts) {
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); }); session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
session.on('shell', (accept) => { session.on('shell', (accept) => {
const channel = accept(); const channel = accept();
pickHost(channel, uid, hosts).then((host) => { hostsPromise.then((hosts) => {
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} } pickHost(channel, uid, hosts).then((host) => {
finish({ host, channel, ptyInfo }); if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
finish({ host, channel, ptyInfo });
});
}).catch(() => {
try { channel.write('\r\n Could not reach the directory.\r\n'); channel.close(); } catch (_) {}
finish({ host: null });
}); });
}); });
session.on('exec', (accept) => { session.on('exec', (accept) => {
@@ -280,7 +322,7 @@ function start() {
} }
); );
const port = (conf.ssh && conf.ssh.listenPort) || 2222; const port = (conf.ssh && conf.ssh.listenPort) ?? 2222;
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0'; const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
server.listen(port, host, () => { server.listen(port, host, () => {
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`); console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
@@ -156,6 +156,29 @@ test('shell bridges and echoes', async () => {
assert.match(out, /echo:ping/); assert.match(out, /echo:ping/);
}); });
test('connectUpstream rejects with a specific, non-generic error when the target refuses the connection', async () => {
// Regression coverage for ssh_server.js's resolveAndConnect: it used to
// discard this error entirely (catch (_) { throw fail('upstream-unreachable') }),
// so the audit log recorded the same generic reason for a refused port, a
// timeout, or a bad key alike. Now the real message is threaded through as
// failDetail, so this must stay meaningful.
// Bind a server just to reserve a free port, then close it immediately so
// nothing is listening there — guarantees ECONNREFUSED rather than relying
// on a hardcoded port number that might be in use.
const closedPort = await new Promise((resolve) => {
const probe = require('net').createServer();
probe.listen(0, '127.0.0.1', () => { const p = probe.address().port; probe.close(() => resolve(p)); });
});
await assert.rejects(
connectUpstream({ host: '127.0.0.1', port: closedPort, username: 'test', privateKey: jumpKey, uid: 'test', justInjected: false }),
(err) => {
assert.ok(err.message && err.message.length > 0);
assert.notStrictEqual(err.message, 'upstream-unreachable');
return true;
},
);
});
test('sftp subsystem bytes pass through', async () => { test('sftp subsystem bytes pass through', async () => {
const { conn, ready } = connectJump(); const { conn, ready } = connectJump();
await ready; await ready;
+232
View File
@@ -0,0 +1,232 @@
'use strict';
// End-to-end standalone SSH test: a real downstream sshd, the full jump host
// SSH server (ssh_server.js), and an SSH client. Authentication and host
// discovery use the ORM-backed standalone stores (temp file SQLite).
//
// Follows the same hermetic pattern as ssh_bridge.test.js but exercises the
// full stack: conf → ORM → user_ldap facade → ssh_server → bridge.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { Server, Client, utils } = require('ssh2');
const bcrypt = require('bcrypt');
const conf = require('@simpleworkjs/conf');
// ── Conf must be set BEFORE any module that checks conf.standalone.enabled ──
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-standalone-'));
const dbPath = path.join(tmpDir, 'test.sqlite');
conf.standalone = { enabled: true };
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
conf.ssh = {
listenHost: '127.0.0.1',
listenPort: 0,
hostKeyPath: path.join(tmpDir, 'keys'),
passwordAuth: 'all',
keyComment: 'jump-host-test',
defaultPort: 22,
connectTimeoutMs: 5000,
maxSessions: 10,
};
conf.redis = { prefix: 'jump_host_test_standalone_' };
conf.audit = { maxEvents: 100 };
// ── Require models/index FIRST so it initializes the ORM exactly once.
// This also registers the standalone models. We await ormReady before
// seeding data, then start the SSH server. ──
const models = require('../../models');
const StandaloneUser = require('../../models/standalone_user');
const StandaloneHost = require('../../models/standalone_host');
let downstream, downstreamPort, jump, jumpPort;
let testUserKey;
function startDownstream() {
return new Promise((resolve) => {
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
client.on('authentication', (ctx) => ctx.accept());
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept();
session.on('pty', (a) => a && a());
session.on('shell', (a) => {
const ch = a();
ch.write('downstream-shell-ready\n');
ch.on('data', (d) => ch.write('echo:' + d));
});
session.on('exec', (a, r, info) => {
const ch = a();
ch.write(`ran:${info.command}`);
ch.exit(0);
ch.end();
});
session.on('subsystem', (a, r, info) => {
if (info.name !== 'sftp') return r && r();
const ch = a();
ch.on('data', (d) => ch.write(Buffer.concat([Buffer.from('sftp:'), d])));
});
});
});
});
srv.listen(0, '127.0.0.1', () => resolve(srv));
});
}
before(async () => {
// 1. Start downstream.
downstream = await startDownstream();
downstreamPort = downstream.address().port;
// 2. Wait for the ORM to finish syncing tables (init was called by models/index
// at require time — we just need the tables to exist before seeding).
await models.ormReady;
// 3. Seed test data.
const userKeyPair = utils.generateKeyPairSync('ed25519');
testUserKey = userKeyPair.private;
const userPubKey = utils.parseKey(userKeyPair.private);
const userPubLine = `${userPubKey.type} ${userPubKey.getPublicSSH().toString('base64')} testuser@test`;
const passwordHash = await bcrypt.hash('testpass', 4);
await StandaloneUser.create({
uid: 'testuser',
passwordHash,
sshPublicKeys: [userPubLine],
groups: ['admin'],
});
await StandaloneHost.create({
slug: 'host_test',
displayName: 'Test Downstream',
kind: 'host',
metadata: { address: `ssh://127.0.0.1:${downstreamPort}`, ip: '127.0.0.1', sshPort: downstreamPort },
});
// 4. Start the jump host SSH server.
const sshServer = require('../../services/ssh_server');
jump = sshServer.start();
await new Promise((resolve) => {
const check = () => {
const addr = jump.address();
if (addr) { jumpPort = addr.port; resolve(); }
else setTimeout(check, 10);
};
check();
});
});
after(() => {
try { downstream && downstream.close(); } catch (_) {}
try { jump && jump.close(); } catch (_) {}
try { models.redisClient.destroy(); } catch (_) {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
process.on('unhandledRejection', () => {});
function connectJump(opts = {}) {
const conn = new Client();
const connectOpts = {
host: '127.0.0.1',
port: jumpPort,
username: opts.username || 'testuser_-_host_test',
...opts,
};
return {
conn,
ready: new Promise((res, rej) => {
conn.on('ready', res).on('error', rej).connect(connectOpts);
}),
};
}
// ── Tests ──
test('public key auth + grammar mode exec', async () => {
const { conn, ready } = connectJump({ privateKey: testUserKey });
await ready;
const out = await new Promise((resolve, reject) => {
conn.exec('hello-world', (err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
});
});
conn.end();
assert.match(out, /ran:hello-world/);
});
test('public key auth + grammar mode shell', async () => {
const { conn, ready } = connectJump({ privateKey: testUserKey });
await ready;
const out = await new Promise((resolve, reject) => {
conn.shell((err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => {
buf += d;
if (buf.includes('echo:ping')) resolve(buf);
});
setTimeout(() => stream.write('ping'), 150);
setTimeout(() => resolve(buf), 5000);
});
});
conn.end();
assert.match(out, /downstream-shell-ready/);
assert.match(out, /echo:ping/);
});
test('password auth + grammar mode exec', async () => {
const { conn, ready } = connectJump({
username: 'testuser_-_host_test',
password: 'testpass',
});
await ready;
const out = await new Promise((resolve, reject) => {
conn.exec('pw-test', (err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
});
});
conn.end();
assert.match(out, /ran:pw-test/);
});
test('password auth denied with wrong password', async () => {
const conn = new Client();
const result = await new Promise((resolve) => {
conn.on('ready', () => resolve('unexpected-ready'));
conn.on('error', () => resolve('auth-failed'));
conn.connect({
host: '127.0.0.1', port: jumpPort,
username: 'testuser_-_host_test',
password: 'wrongpass',
});
});
assert.strictEqual(result, 'auth-failed');
});
test('unknown user rejected', async () => {
const conn = new Client();
const result = await new Promise((resolve) => {
conn.on('ready', () => resolve('unexpected-ready'));
conn.on('error', () => resolve('auth-failed'));
conn.connect({
host: '127.0.0.1', port: jumpPort,
username: 'nobody_-_host_test',
password: 'testpass',
});
});
assert.strictEqual(result, 'auth-failed');
});
+27 -1
View File
@@ -2,7 +2,7 @@
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert'); const assert = require('node:assert');
const { accessibleHosts, clearCache } = require('../../utils/access'); const { accessibleHosts, allHosts, clearCache } = require('../../utils/access');
function stubLdap(groups) { function stubLdap(groups) {
return { getGroups: async () => groups }; return { getGroups: async () => groups };
@@ -54,6 +54,32 @@ test('caches per uid', async () => {
assert.strictEqual(calls, 1); assert.strictEqual(calls, 1);
}); });
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
clearCache();
let ldapCalled = false;
const user = { uid: 'erin', groups: ['host_web01_access'] };
const fetchImpl = stubFetch({
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
});
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
assert.strictEqual(ldapCalled, false);
});
test('allHosts fetches the whole host inventory with no group filter', async () => {
const fetchImpl = async (url) => {
assert.ok(!url.includes('group='), 'must not filter by group');
assert.ok(url.includes('kind=host'));
return { ok: true, json: async () => ({ results: [
{ id: '1', kind: 'host', slug: 'host_a' },
{ id: '2', kind: 'host', slug: 'host_b' },
] }) };
};
const hosts = await allHosts({ fetchImpl });
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 () => { test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
clearCache(); clearCache();
const user = { uid: 'dave', dn: 'd' }; const user = { uid: 'dave', dn: 'd' };
+87
View File
@@ -0,0 +1,87 @@
'use strict';
// Unit tests for the ORM-backed host inventory (utils/hosts_file.js).
// Uses a temp file SQLite database — no external services needed.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const conf = require('@simpleworkjs/conf');
const { init } = require('@simpleworkjs/orm');
const StandaloneHost = require('../../models/standalone_host');
let tmpDir;
let hostsFile; // required after ORM init
before(async () => {
// Unique temp DB so this test file doesn't collide with other ORM tests.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-hostfile-'));
const dbPath = path.join(tmpDir, 'test.sqlite');
conf.standalone = { enabled: true };
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
await init({ conf: { orm: conf.orm }, models: [StandaloneHost] });
await StandaloneHost.create({
slug: 'host_web01',
displayName: 'Web Server 01',
kind: 'host',
metadata: { address: 'ssh://10.0.0.10:22', ip: '10.0.0.10', sshPort: 22 },
});
await StandaloneHost.create({
slug: 'host_db',
displayName: 'Database Server',
kind: 'host',
metadata: { address: 'ssh://10.0.0.20:22', ip: '10.0.0.20', sshPort: 22 },
});
await StandaloneHost.create({
slug: 'app_gitea',
displayName: 'Gitea',
kind: 'service',
metadata: { url: 'https://gitea.internal' },
});
hostsFile = require('../../utils/hosts_file');
});
after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
test('accessibleHosts returns all hosts', async () => {
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
assert.strictEqual(hosts.length, 2);
const slugs = hosts.map((h) => h.slug).sort();
assert.deepStrictEqual(slugs, ['host_db', 'host_web01']);
});
test('accessibleHosts filters to kind=host', async () => {
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
const kinds = [...new Set(hosts.map((h) => h.kind))];
assert.deepStrictEqual(kinds, ['host']);
});
test('accessibleHosts returns host resources with expected shape', async () => {
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
const web = hosts.find((h) => h.slug === 'host_web01');
assert.ok(web);
assert.strictEqual(web.id, 'host_web01');
assert.strictEqual(web.displayName, 'Web Server 01');
assert.strictEqual(web.metadata.ip, '10.0.0.10');
assert.strictEqual(web.metadata.sshPort, 22);
});
test('accessibleHosts returns empty array when no hosts exist', async () => {
// Delete all hosts and verify empty result.
const all = await StandaloneHost.list();
for (const h of all) {
await h.delete({ force: true });
}
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
assert.deepStrictEqual(hosts, []);
});
@@ -0,0 +1,43 @@
'use strict';
// Regression guard: native alert()/confirm()/prompt() calls block all further
// browser events on the page (found live, mid browser-automation testing, on
// sso-manager-node's equivalent secret-rotate flow) and are visually
// inconsistent with the rest of the UI. This app has no such call sites;
// keep it that way.
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', '..', d));
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
function walk(dir) {
let files = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files = files.concat(walk(full));
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
}
return files;
}
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
const offenders = [];
for (const root of ROOTS) {
for (const file of walk(root)) {
const src = fs.readFileSync(file, 'utf8');
let m;
NATIVE_DIALOG_RE.lastIndex = 0;
while ((m = NATIVE_DIALOG_RE.exec(src))) {
const line = src.slice(0, m.index).split('\n').length;
offenders.push(`${path.relative(path.join(__dirname, '..', '..'), file)}:${line}${m[2]}(`);
}
}
}
assert.deepStrictEqual(offenders, []);
});
+113
View File
@@ -0,0 +1,113 @@
'use strict';
// Unit tests for the ORM-backed user store (models/user_file.js).
// Uses a temp file SQLite database — no external services needed.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const bcrypt = require('bcrypt');
const conf = require('@simpleworkjs/conf');
const { init } = require('@simpleworkjs/orm');
const StandaloneUser = require('../../models/standalone_user');
let testPasswordHash;
let tmpDir;
let userFile; // required after ORM init
before(async () => {
// Unique temp DB so this test file doesn't collide with other ORM tests.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-userfile-'));
const dbPath = path.join(tmpDir, 'test.sqlite');
conf.standalone = { enabled: true };
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
await init({ conf: { orm: conf.orm }, models: [StandaloneUser] });
testPasswordHash = await bcrypt.hash('testpass', 4);
await StandaloneUser.create({
uid: 'alice',
passwordHash: testPasswordHash,
sshPublicKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop'],
groups: ['admin', 'developers'],
});
// Now that the ORM is initialized and conf.standalone is set, require the
// facade. It checks conf.standalone.enabled at require time.
userFile = require('../../models/user_file');
});
after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
test('getUser returns user with synthesized dn and keys', async () => {
const user = await userFile.getUser('alice');
assert.ok(user);
assert.strictEqual(user.uid, 'alice');
assert.strictEqual(user.dn, 'uid=alice,ou=people,dc=standalone,dc=local');
assert.deepStrictEqual(user.sshPublicKeys, ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop']);
});
test('getUser returns null for unknown uid', async () => {
const user = await userFile.getUser('nobody');
assert.strictEqual(user, null);
});
test('getGroups returns user groups', async () => {
const groups = await userFile.getGroups('uid=alice,ou=people,dc=standalone,dc=local');
assert.deepStrictEqual(groups, ['admin', 'developers']);
});
test('getGroups returns empty array for unknown dn', async () => {
const groups = await userFile.getGroups('uid=nobody,ou=people,dc=standalone,dc=local');
assert.deepStrictEqual(groups, []);
});
test('getGroups returns empty array for malformed dn', async () => {
const groups = await userFile.getGroups('not-a-dn');
assert.deepStrictEqual(groups, []);
});
test('checkPassword returns true for correct password', async () => {
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'testpass');
assert.strictEqual(ok, true);
});
test('checkPassword returns false for wrong password', async () => {
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'wrongpass');
assert.strictEqual(ok, false);
});
test('checkPassword returns false for unknown user', async () => {
const ok = await userFile.checkPassword('uid=nobody,ou=people,dc=standalone,dc=local', 'testpass');
assert.strictEqual(ok, false);
});
test('addSshKey appends a new key', async () => {
const newKey = 'ssh-rsa AAAAB3NzaC1yc2E... bob@desktop';
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', newKey);
const user = await StandaloneUser.get('alice');
assert.ok(user.sshPublicKeys.includes(newKey));
});
test('addSshKey is idempotent', async () => {
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop';
const userBefore = await StandaloneUser.get('alice');
const countBefore = userBefore.sshPublicKeys.length;
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', key);
const userAfter = await StandaloneUser.get('alice');
assert.strictEqual(userAfter.sshPublicKeys.length, countBefore);
});
test('addSshKey is a no-op for unknown user', async () => {
// Should not throw.
await userFile.addSshKey('uid=nobody,ou=people,dc=standalone,dc=local', 'ssh-rsa AAA...');
});
+82 -57
View File
@@ -1,70 +1,95 @@
'use strict'; 'use strict';
// Which directory hosts may a user reach, and how do we dial them? // Host discovery — SSO Manager API in production, ORM-backed inventory in
// // standalone mode. Both export the same interface:
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's // accessibleHosts(user) -> [host resources]
// /api/discovery/me only answers for the API token's own user, and /graph // clearCache(uid?) -> void
// 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'
//
// 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
// unit testing.
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
const userLdap = require('../models/user_ldap');
const CACHE_TTL_MS = 30 * 1000; if (conf.standalone && conf.standalone.enabled) {
const cache = new Map(); // uid -> {at, hosts} // Standalone mode: use the ORM-backed host inventory. Every host is
// accessible to every user, so allHosts and accessibleHosts coincide.
const { accessibleHosts } = require('./hosts_file');
module.exports = { accessibleHosts, allHosts: () => accessibleHosts(), clearCache: () => {} };
} else {
// Production mode: LDAP groups + SSO API (unchanged).
// Build a directory client bound to conf.sso. fetchImpl is injectable so the // Which directory hosts may a user reach, and how do we dial them?
// unit tests can stub the transport; the shared client validates the //
// `{ results }` envelope on every call (turns the old bare-array drift into a // v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
// thrown error instead of a silent `[]`). // /api/discovery/me only answers for the API token's own user, and /graph
function directoryClient({ fetchImpl = fetch } = {}) { // omits ResourceGroup links — so we combine the user's LDAP groups (queried
const sso = conf.sso || {}; // directly) with per-group resource lookups:
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl }); //
} // 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'
//
// 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
// unit testing.
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) { const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
return directoryClient({ fetchImpl }).getResourcesByGroup(group); const userLdap = require('../models/user_ldap');
}
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) { const CACHE_TTL_MS = 30 * 1000;
const hit = cache.get(user.uid); const cache = new Map(); // uid -> {at, hosts}
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
const groups = await ldap.getGroups(user.dn); // Build a directory client bound to conf.sso. fetchImpl is injectable so the
// unit tests can stub the transport; the shared client validates the
const seen = new Map(); // `{ results }` envelope on every call (turns the old bare-array drift into a
for (const cn of groups) { // thrown error instead of a silent `[]`).
let resources; function directoryClient({ fetchImpl = fetch } = {}) {
try { const sso = conf.sso || {};
resources = await fetchResourcesByGroup(cn, { fetchImpl }); return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: 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);
}
} }
const hosts = [...seen.values()]; async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
cache.set(user.uid, { at: Date.now(), hosts }); return directoryClient({ fetchImpl }).getResourcesByGroup(group);
return hosts; }
}
function clearCache(uid) { // Every host in the inventory, unfiltered — for admins (the web UI's own
if (uid) cache.delete(uid); // account is already gated by requireAdmin before this is ever called).
else cache.clear(); async function allHosts({ fetchImpl = fetch } = {}) {
} const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
return resources.filter(r => r.kind === 'host');
}
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup }; async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
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);
}
}
const hosts = [...seen.values()];
cache.set(user.uid, { at: Date.now(), hosts });
return hosts;
}
function clearCache(uid) {
if (uid) cache.delete(uid);
else cache.clear();
}
module.exports = { accessibleHosts, allHosts, clearCache, fetchResourcesByGroup };
}
+30
View File
@@ -0,0 +1,30 @@
'use strict';
// ORM-backed host inventory for standalone mode. Implements the same interface
// as utils/access.js so ssh_server.js works unchanged: accessibleHosts(user)
// returns an array of host resources the user may reach.
//
// In standalone mode all hosts in the inventory are accessible to every
// authenticated user — there is no group-based filtering. The _user parameter
// is accepted for interface compatibility but ignored.
const StandaloneHost = require('../models/standalone_host');
async function accessibleHosts(_user) {
const hosts = await StandaloneHost.list({ where: { kind: 'host' } });
// The ORM returns model instances; map to plain objects matching the shape
// that target_match.js and tui_picker.js expect.
return hosts.map((h) => ({
id: h.slug, // slug doubles as the stable id in standalone mode
kind: h.kind,
slug: h.slug,
displayName: h.displayName,
metadata: h.metadata || {},
}));
}
function clearCache() {
// No cache in standalone mode — every call reads from the DB.
}
module.exports = { accessibleHosts, clearCache };
+179 -2
View File
@@ -28,7 +28,37 @@
</div> </div>
</div> </div>
<div class="row g-3"> <div class="row g-3 mb-4">
<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="copySshCommand('#quick-jump-cmd')" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<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="col-md-6">
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div> <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> <table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
@@ -41,6 +71,27 @@
</div> </div>
</div> </div>
<div class="row g-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<p class="text-muted small px-3 pt-3 mb-0">
Personal access tokens authenticate as you against this jump host's own API
(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>
</div>
</div>
<script type="text/javascript"> <script type="text/javascript">
function rows(sel, list){ function rows(sel, list){
var $b = $(sel).empty(); var $b = $(sel).empty();
@@ -49,7 +100,125 @@
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>'); $b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
}); });
} }
$(document).ready(function(){ // 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 copySshCommand(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)) || '';
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
$b.append('<tr><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>'
+ '<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="copySshCommand(\'#' + 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>'
);
});
}
function loadApiTokens(){
app.apiToken.list(function(error, data){
if(error) return tokenRows([]);
tokenRows(data && data.results);
});
}
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>'
+ '<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>'
});
}
function createApiToken(){
var $body = app.modal.open({title: 'New API Token', bodyHtml:
'<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
+ '</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>'
});
$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');
app.apiToken.add({
name: name,
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');
showToken('API Token Created', data.token);
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');
if(!ok) return;
app.apiToken.remove(id, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to revoke token', $card, 'danger');
loadApiTokens();
});
}
async function rotateApiToken(id, btn){
var $card = $(btn).closest('.card');
var ok = await app.messages.confirm('Rotate this API token? The old token stops working immediately.', $card, 'warning');
if(!ok) return;
app.apiToken.rotate(id, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to rotate token', $card, 'danger');
showToken('API Token Rotated', data.token);
loadApiTokens();
});
}
$(document).ready(async function(){
app.jump.metrics(function(error, data){ app.jump.metrics(function(error, data){
if(error || !data) return; if(error || !data) return;
$('#stat-active').text(data.active); $('#stat-active').text(data.active);
@@ -59,6 +228,14 @@
rows('#top-hosts', data.topHosts); rows('#top-hosts', data.topHosts);
rows('#top-users', data.topUsers); rows('#top-users', data.topUsers);
}); });
await app.auth.loadUser();
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
$('#quick-jump-cmd').val(sshCommand());
app.jump.hosts(function(error, data){
if(error) return hostRows('#my-hosts', []);
hostRows('#my-hosts', data && data.results);
});
loadApiTokens();
}); });
</script> </script>
<%- include('bottom') %> <%- include('bottom') %>
+3 -1
View File
@@ -21,9 +21,11 @@
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script> <script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script> <script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script> <script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
<script type="text/javascript" src='/static/lib/js/val.js'></script>
<script type="text/javascript" src="/static-modules/moment/moment.js"></script> <script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script> <script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script> <script type="text/javascript" src="/static/js/app.js"></script>
</head> </head>
<body> <body>
+18
View File
@@ -11,7 +11,24 @@
module.exports = { module.exports = {
name: 'My Org', name: 'My Org',
// Standalone mode: run with no LDAP directory and no SSO Manager. When
// enabled, user auth and host discovery use the ORM-backed stores below
// instead of `ldap` + `sso` (both become unused). See the README's
// "Standalone mode" section for how to add users/hosts.
standalone: {
enabled: false,
},
// ORM config for standalone mode (Sequelize — any dialect works, not just
// sqlite). Ignored unless standalone.enabled is true.
orm: {
dialect: 'sqlite',
storage: './data/standalone.sqlite',
logging: false,
},
// The directory the users live in (the SSO Manager's OpenLDAP). // The directory the users live in (the SSO Manager's OpenLDAP).
// Unused when standalone.enabled is true.
// //
// IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on // IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on
// the sshPublicKey attribute of user entries — the jump host injects its // the sshPublicKey attribute of user entries — the jump host injects its
@@ -36,6 +53,7 @@ module.exports = {
// SSO Manager directory (inventory) API. apiToken is a personal access // SSO Manager directory (inventory) API. apiToken is a personal access
// token (sso_<id>_<secret>) of any user that can read /api/discovery/*. // token (sso_<id>_<secret>) of any user that can read /api/discovery/*.
// Unused when standalone.enabled is true.
sso: { sso: {
url: 'https://sso.example.com', url: 'https://sso.example.com',
apiToken: 'sso_CHANGE_ME', apiToken: 'sso_CHANGE_ME',