Compare commits

...

27 Commits

Author SHA1 Message Date
wmantly f386a5f9c3 Add ANSI colors to TUI picker
- Box-drawing header with cyan/magenta/green color treatment
- Per-row coloring with alternating cyan shades
- Environment badges (PROD in red, DEV in dim)
- Green inverse selection with '◄ SELECTED ►' indicator
- Yellow filter text and footer separator
- Title changed to 'SSO Manager'
2026-07-31 14:24:54 -04:00
wmantly 82318da484 Merge pull request #31 from theta42/release/1.11.0
Release 1.11.0: super/jump admin, per-host connection tracking, Audit metrics
2026-07-30 12:05:11 -04:00
wmantly 14784266b3 Release 1.11.0: super/jump admin, per-host connection tracking, Audit metrics 2026-07-30 12:02:25 -04:00
wmantly 362e77f3dd Merge pull request #30 from theta42/feat/super-admin-jump-admin-host-tracking
Add super/jump admin, per-host connection tracking; move stats to Audit
2026-07-30 12:00:30 -04:00
wmantly dfafffe154 Add super/jump admin, per-host connection tracking; move stats to Audit
- app_super_admin (cross-app, also recognized by sso-manager-node/proxy)
  and a new app_jump_admin group are added to conf.auth: super admins are
  full admins here same as app_sso_admin; jump admins get audit page/data
  access without other admin rights (isJumpAdmin/requireJumpAdmin in
  middleware/auth.js, wired into routes/api.js's audit-data gate and the
  /audit page's client-side forceLogin -- previously the page shell
  rendered for any logged-in user, only the data was gated).
- Dashboard: moved the stat boxes and Top hosts/Top users cards to the
  Audit page (audit is now the admin-facing metrics home; dashboard stays
  focused on "hosts I can reach"). Renamed "All hosts" to "My hosts".
- Host list now shows Last connection/Last failed connection columns and
  highlights rows green (live session, from session_registry) or yellow
  (most recent attempt failed) -- backed by new per-host last-success/
  last-fail timestamps in models/metrics.js, populated by ssh_server.js
  (which now attributes grammar/TUI connect failures to the resolved host
  when one was found, not just aggregate counters) and surfaced through
  GET /api/user/hosts (routes/user.js).
2026-07-30 11:58:28 -04:00
wmantly bd4464ed19 Merge pull request #29 from theta42/release/1.10.2
Release 1.10.2: page width standardization, Audit admin-gating
2026-07-30 09:52:05 -04:00
wmantly 061a044a70 Release 1.10.2: page width standardization, Audit admin-gating 2026-07-30 09:50:33 -04:00
wmantly c6a4a3c841 Merge pull request #28 from theta42/feat/page-width-audit-admin-gating
Match page width to sso-manager-node; gate Audit nav to admins
2026-07-29 22:22:12 -04:00
wmantly 0ef451e15d Match page width to sso-manager-node; gate Audit nav to admins
- Dashboard, Sessions, and Audit pages now wrap their content in
  <div class="container mt-4">, matching sso-manager-node/proxy's width
  instead of rendering full-bleed inside the fluid shell.
- Audit's nav entry now carries groups: ['admin'] (utils/ui.js), reusing
  the existing synthetic-admin-group nav-gating convention -- the API
  route was already server-side admin-gated, this closes the last gap by
  hiding the nav link/page for non-admins too.
- app-base.js (byte-identical across the 3 apps): added
  app.util.revealItem() and the --sw-content-offset sticky-positioning
  variable, carried over from the same round of changes in
  sso-manager-node/proxy. Not yet called anywhere in this app -- no
  sticky/reveal use case here yet -- but keeps the shared file in sync.
2026-07-29 22:20:56 -04:00
wmantly 6771904932 Merge pull request #27 from theta42/release/1.10.1
Release 1.10.1: fix API-token reveal modal race
2026-07-28 21:22:18 -04:00
wmantly c002afe043 Release 1.10.1: fix API-token reveal modal race 2026-07-28 21:21:01 -04:00
wmantly 0a2c25ae75 Merge pull request #26 from theta42/fix/apitoken-modal-race
Fix API-token reveal modal silently not showing after create
2026-07-28 21:20:46 -04:00
wmantly a15decb6f7 Fix API-token reveal modal silently not showing after create
app.modal.close() called immediately before showToken()'s app.modal.open()
in the same tick collides with Bootstrap's hide-transition guard on the
singleton modal, so the reveal never appears. open() alone already
overwrites the already-visible modal's content in place. Same root cause
as the OAuth-secret-reveal race fixed in sso-manager-node (v1.8.2) and the
create-token race fixed in proxy (v1.7.0), found while auditing this
exact pattern across all 3 apps this round.
2026-07-28 21:19:28 -04:00
wmantly 599136e4dc Release 1.10.0: unify API-token UI (card grid, Edit modal, description field) (#25) 2026-07-28 20:20:33 -04:00
wmantly a7d5efc764 Unify API-token UI: card grid, Edit modal, description field (#24)
The self-service API-token UI was inconsistent across all 3 apps
(sso-manager-node/proxy used a card grid with Edit/Rotate/Revoke and a
description field; jump-host used a bare table with no Edit action, no
description field anywhere in the UI, and icon-only buttons -- even though
its model and PUT route already fully supported both). jump-host is first
since it needed the least backend work (none -- description and the PUT
handler already existed, just unexposed) and the most view work, proving
the pattern before porting it to proxy/sso-manager-node.

- Card grid (jq-repeat="apiTokenCard") replacing the table, matching
  sso-manager-node's exact template: name + truncated token-id, optional
  description, a <dl> of Token ID/Created/Last used/Expires, and labeled
  Edit/Rotate/Revoke buttons.
- New Edit modal (app.modal, footer shows "Created by X on Y" via the
  token's existing created_by/created_on) -- net-new UI on top of the
  already-existing PUT /:id route.
- Create modal gained a Description field and now uses
  app.modal.footerButtons() for its Cancel/Create pair.
- Standardized status badges on Bootstrap 5's text-bg-* classes.
- Bumped @simpleworkjs/frontend to ^0.2.6 (footer/footerButtons support;
  this app was still on ^0.2.5) and added the missing app.apiToken.update()
  client wrapper (list/add/remove/rotate already existed).

Found and fixed a real bug along the way: the planned "flash a checkmark on
copy" touch (porting sso-manager-node's copyField pattern) silently does
nothing once FontAwesome replaces <i> icons with inline <svg> -- there's no
<i> left to swap classes on. Renamed the existing copySshCommand() (already
used by the Quick Jump feature, toast-based, unaffected by that FA
behavior) to copyFieldValue() and reused it for the token-reveal copy
button instead of introducing a second, broken copy mechanism.

Verified live: card grid renders with truncated token ID; Edit modal shows
real created-by/on data, saves a description change, and the card
refreshes; Create modal's new description field round-trips; secret-reveal
copy button fires the toast correctly for a real (non-programmatic) click.
2026-07-28 20:07:48 -04:00
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
21 changed files with 560 additions and 145 deletions
+49
View File
@@ -4,6 +4,55 @@ All notable changes to this project are documented here. Format loosely
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`.
## [1.11.0] - 2026-07-30
### Added
- **`app_super_admin` (cross-app) and `app_jump_admin` groups**: super admins are full admins here same as `app_sso_admin`; jump admins get audit page/data access without other admin rights. The Audit page/API is now actually admin-gated server-side (previously the page shell rendered for any logged-in user, only its data was gated).
- **Host list adds Last connection/Last failed connection columns** and highlights rows green (a session is live right now) or yellow (the most recent attempt failed), backed by new per-host last-success/last-fail timestamps in `models/metrics.js`. `services/ssh_server.js` now attributes grammar/TUI connect failures to the resolved host when one was found, not just aggregate counters.
### Changed
- **Dashboard's stat boxes and Top hosts/Top users cards moved to the Audit page** (audit is now the admin-facing metrics home; dashboard stays focused on "hosts I can reach"). "All hosts" renamed to "My hosts".
## [1.10.2] - 2026-07-30
### Changed
- **Dashboard, Sessions, and Audit pages now match sso-manager-node/proxy's page width**, wrapping content in a standard container instead of rendering full-bleed inside the fluid shell.
- **Audit's nav entry is now admin-gated** (`groups: ['admin']` in `utils/ui.js`), reusing the existing synthetic-admin-group nav-gating convention — the API route was already server-side admin-gated; this hides the nav link for non-admins too.
## [1.10.1] - 2026-07-28
### Fixed
- **The API-token reveal modal silently didn't show after creating a token** — `submitApiToken()` called `app.modal.close()` immediately before `showToken()`'s `app.modal.open()` in the same tick, colliding with Bootstrap's hide-transition guard on the singleton modal. Same root cause as the OAuth-secret-reveal race fixed in sso-manager-node (v1.8.2) and the create-token race fixed in proxy (v1.7.0).
## [1.10.0] - 2026-07-28
### Added
- **API-token UI unified with sso-manager-node/proxy**: card grid replacing the bare table, a new Edit modal (footer shows real created-by/on data), and a Description field on both the create and edit flows — the model and API already fully supported all of this, it just wasn't exposed anywhere in the dashboard.
### Changed
- `@simpleworkjs/frontend` bumped to `^0.2.6` (this app was still on `^0.2.5`).
## [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
+11 -3
View File
@@ -12,9 +12,17 @@ if [[ -f /config/jump-secrets.js ]]; then
info "Loaded config from /config/jump-secrets.js"
fi
# Redis for audit/metrics/session storage (app connects to 127.0.0.1:6379).
info "Starting redis..."
redis-server --daemonize yes --save '' --appendonly no
# Redis for audit/metrics/session AND api-token storage (app connects to
# 127.0.0.1:6379). Persisted (AOF + periodic RDB) to /data, which the
# 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.
for _ in $(seq 1 20); do
+7 -2
View File
@@ -6,7 +6,7 @@
// values (LDAP creds, SSO API token) belong in the secrets file.
module.exports = {
name: 'Jump Host',
name: 'SSO Manager',
logo: '/static/img/theta42.svg',
// LDAP directory the users live in (same directory the SSO manages).
@@ -80,7 +80,12 @@ module.exports = {
auth: {
// OIDC group memberships that grant web UI/API admin access.
adminGroups: ['app_sso_admin'],
// app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
adminGroups: ['app_sso_admin', 'app_super_admin'],
// OIDC group memberships that grant jump admin access (the audit page
// and its data), without granting other admin-only rights. Full admins
// (adminGroups/adminUsers) always have jump admin access too.
jumpAdminGroups: ['app_jump_admin'],
// Local anti-lockout admin: the first name here is bootstrapped as a
// redis-backed user on first boot (password from localAdminPass, or a
// random one printed to the log once). Lets you in even with OIDC down.
+20 -1
View File
@@ -56,6 +56,25 @@ async function requireAdmin(req, res, next){
next(error);
}
// Jump admin = access to the audit page/data. A narrower grant than full
// jump-host admin: full admins (isAdmin) always qualify, plus anyone in
// conf.auth.jumpAdminGroups (e.g. a dedicated app_jump_admin LDAP group) can
// be granted audit access without also getting other admin-only rights.
function isJumpAdmin(req){
if(isAdmin(req)) return true;
const jumpAdminGroups = (conf.auth && conf.auth.jumpAdminGroups) || [];
return (req.groups || []).some(g => jumpAdminGroups.includes(g));
}
async function requireJumpAdmin(req, res, next){
if(isJumpAdmin(req)) return next();
const error = new Error('Forbidden');
error.name = 'Forbidden';
error.status = 403;
error.message = 'Jump admin access required.';
next(error);
}
// Socket.IO handshake auth (app-base.js connects with the session token).
async function authIO(socket, next){
try{
@@ -69,4 +88,4 @@ async function authIO(socket, next){
}
}
module.exports = { auth, requireAdmin, authIO, isAdmin };
module.exports = { auth, requireAdmin, authIO, isAdmin, isJumpAdmin, requireJumpAdmin };
+26 -2
View File
@@ -13,10 +13,34 @@ async function bump({ uid, hostSlug, success }) {
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
if (!success) ops.push(redis.incr(`${P()}fail`));
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
if (hostSlug) ops.push(redis.incr(`${P()}host_${hostSlug}`));
if (hostSlug) {
ops.push(redis.incr(`${P()}host_${hostSlug}`));
// Last-attempt timestamp per host, split by outcome -- drives the
// dashboard's "Last connection"/"Last failed connection" columns and
// row highlighting (see lastForHosts below).
ops.push(redis.set(`${P()}host_last_${success ? 'success' : 'fail'}_${hostSlug}`, Date.now()));
}
await Promise.all(ops);
}
// Per-host last-success/last-fail timestamps for a given list of slugs (e.g.
// the hosts a session can reach), for the dashboard's host list.
async function lastForHosts(slugs) {
const redis = await getRedis();
const result = {};
await Promise.all((slugs || []).map(async (slug) => {
const [lastSuccess, lastFail] = await Promise.all([
redis.get(`${P()}host_last_success_${slug}`),
redis.get(`${P()}host_last_fail_${slug}`),
]);
result[slug] = {
lastConnected: lastSuccess ? Number(lastSuccess) : null,
lastFailed: lastFail ? Number(lastFail) : null,
};
}));
return result;
}
async function summary() {
const redis = await getRedis();
const [total, fail] = await Promise.all([
@@ -37,4 +61,4 @@ async function summary() {
};
}
module.exports = { bump, summary };
module.exports = { bump, summary, lastForHosts };
+6 -6
View File
@@ -1,19 +1,19 @@
{
"name": "t42-jump-host",
"version": "1.5.0",
"version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-jump-host",
"version": "1.5.0",
"version": "1.9.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
@@ -178,9 +178,9 @@
}
},
"node_modules/@simpleworkjs/frontend": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "t42-jump-host",
"version": "1.7.1",
"version": "1.11.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
@@ -23,7 +23,7 @@
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
+6
View File
@@ -7,6 +7,12 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
/* Height of the fixed navbar (plus the update banner, while shown --
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
sticky element offset itself below both fixed elements via
`top: var(--sw-content-offset)` instead of colliding with them at the
viewport's true top:0. */
--sw-content-offset: 4.5rem;
}
#spa-shell {
+5 -3
View File
@@ -19,13 +19,15 @@ app.jump = (function(app){
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 update(args, cb){ app.api.put('api-token/' + args.id, 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};
return {list: list, add: add, update: update, remove: remove, rotate: rotate};
})(app);
// Shared render helpers.
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.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>'
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
app.jump.result = function(e){ if (e.success) return '<span class="badge bg-success">ok</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>'; };
+25 -7
View File
@@ -584,10 +584,31 @@ app.util = (function(app){
document.body.removeChild(element);
}
// Scroll a just-added/-edited element into view and flash its
// background, so the user's eye lands on the row that changed instead of
// it silently appearing/updating somewhere off-screen. Takes a jQuery
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
function revealItem(el){
var node = el && el.jquery ? el[0] : el;
if (!node) return;
if (typeof node.scrollIntoView === 'function') {
node.scrollIntoView({behavior: 'smooth', block: 'center'});
}
var prevTransition = node.style.transition;
var prevBg = node.style.backgroundColor;
node.style.transition = 'background-color 1.5s ease';
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
setTimeout(function(){
node.style.backgroundColor = prevBg;
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
}, 300);
}
return {
downloadFile: downloadFile,
getUrlParameter: getUrlParameter,
escapeHtml: escapeHtml,
revealItem: revealItem,
}
})(app);
@@ -679,13 +700,10 @@ function formAJAX(btn){
return false;
}
app.messages.action(
`<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>`,
$form,
'info'
);
// Plain text: app.messages.action HTML-escapes its message (by design,
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
// render literally instead of as an element.
app.messages.action('Saving…', $form, 'info');
app.api[method]($form.attr('action'), formData, function(error, data){
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
+2 -2
View File
@@ -13,7 +13,7 @@ router.use('/user', middleware.auth, require('./user'));
// 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).
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
// Jump-host data — jump admin only (audit log, active sessions, metrics).
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
module.exports = router;
+4
View File
@@ -14,6 +14,10 @@ const values = {
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
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,
};
+18 -2
View File
@@ -4,14 +4,17 @@
// browser who it is and whether it's an admin (drives login state + nav).
const router = require('express').Router();
const { isAdmin } = require('../middleware/auth');
const { isAdmin, isJumpAdmin } = require('../middleware/auth');
const access = require('../utils/access');
const metrics = require('../models/metrics');
const registry = require('../services/session_registry');
router.get('/me', (req, res) => {
res.json({
username: req.user && req.user.username,
groups: req.groups || [],
isAdmin: isAdmin(req),
isJumpAdmin: isJumpAdmin(req),
});
});
@@ -23,7 +26,20 @@ router.get('/hosts', async (req, res, next) => {
const hosts = isAdmin(req)
? await access.allHosts()
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
res.json({ results: hosts });
// Enrich with connection state for the dashboard's host list: whether a
// session is live right now (active bridges, session_registry), plus the
// last successful/failed connection times (models/metrics).
const connectedSlugs = new Set(registry.list().map((s) => s.slug));
const last = await metrics.lastForHosts(hosts.map((h) => h.slug));
const enriched = hosts.map((h) => ({
...h,
connected: connectedSlugs.has(h.slug),
lastConnected: (last[h.slug] && last[h.slug].lastConnected) || null,
lastFailed: (last[h.slug] && last[h.slug].lastFailed) || null,
}));
res.json({ results: enriched });
} catch (err) { next(err); }
});
+54 -18
View File
@@ -130,7 +130,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { throw fail('key-inject-failed'); }
catch (err) { throw fail('key-inject-failed', err.message, host ? host.slug : undefined); }
let upstream;
try {
@@ -139,12 +139,18 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
username: state.uid, privateKey: JUMP_KEYS.clientKey,
uid: state.uid, justInjected, onHostKey,
});
} catch (_) { throw fail('upstream-unreachable'); }
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
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. hostSlug (when the
// target was already resolved to a known host) lets callers attribute the
// failure to that host for per-host "last failed connection" tracking.
function fail(reason, detail, hostSlug) { const e = new Error(reason); e.reason = reason; e.detail = detail; e.hostSlug = hostSlug; return e; }
async function runGrammar(session, client, state) {
// Register session listeners IMMEDIATELY — before any async work.
@@ -174,25 +180,47 @@ async function runGrammar(session, client, state) {
} catch (err) {
const reason = err.reason || 'error';
rejectUp(new Error(reasonMessage(reason)));
await record.finish({ success: false, failReason: reason });
await metrics.bump({ uid: state.uid, success: false });
await record.finish({ success: false, failReason: reason, failDetail: err.detail });
await metrics.bump({ uid: state.uid, hostSlug: err.hostSlug, success: false });
}
}
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 finishFail = async (reason) => {
await record.finish({ success: false, failReason: reason });
await metrics.bump({ uid: state.uid, success: false });
const finishFail = async (reason, detail, hostSlug) => {
await record.finish({ success: false, failReason: reason, failDetail: detail });
await metrics.bump({ uid: state.uid, hostSlug, success: false });
try { client.end(); } catch (_) {}
};
let hosts;
try { hosts = await accessibleHosts(state.user); }
catch (_) { return finishFail('directory-unreachable'); }
try {
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');
state.target = tui.host.slug;
@@ -201,7 +229,7 @@ async function runTuiSession(session, client, state) {
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { return finishFail('key-inject-failed'); }
catch (err) { return finishFail('key-inject-failed', err.message, tui.host.slug); }
let upstream;
try {
@@ -210,9 +238,9 @@ async function runTuiSession(session, client, state) {
username: state.uid, privateKey: JUMP_KEYS.clientKey,
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 (_) {}
return finishFail('upstream-unreachable');
return finishFail('upstream-unreachable', err.message, tui.host.slug);
}
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
@@ -253,7 +281,10 @@ function reasonMessage(reason) {
// 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.
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) => {
let ptyInfo = null;
let settled = false;
@@ -262,9 +293,14 @@ function runTui(session, uid, hosts) {
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
session.on('shell', (accept) => {
const channel = accept();
pickHost(channel, uid, hosts).then((host) => {
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
finish({ host, channel, ptyInfo });
hostsPromise.then((hosts) => {
pickHost(channel, uid, hosts).then((host) => {
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) => {
+52 -8
View File
@@ -9,10 +9,29 @@ const ESC = '\x1b';
const CLEAR = `${ESC}[2J${ESC}[H`;
const HIDE_CUR = `${ESC}[?25l`;
const SHOW_CUR = `${ESC}[?25h`;
const INV = `${ESC}[7m`;
// Basic styles
const RST = `${ESC}[0m`;
const DIM = `${ESC}[2m`;
const BOLD = `${ESC}[1m`;
const DIM = `${ESC}[2m`;
// Colors (30-37: standard, 90-97: bright)
const RED = `${ESC}[31m`;
const BRIGHT_RED = `${ESC}[91m`;
const CYAN = `${ESC}[36m`;
const BRIGHT_CYAN = `${ESC}[96m`;
const GREEN = `${ESC}[32m`;
const BRIGHT_GREEN = `${ESC}[92m`;
const YELLOW = `${ESC}[33m`;
const BRIGHT_YELLOW = `${ESC}[93m`;
const MAGENTA = `${ESC}[35m`;
const BRIGHT_MAGENTA = `${ESC}[95m`;
const BLUE = `${ESC}[34m`;
const BRIGHT_BLUE = `${ESC}[94m`;
// Inverted selection with color
const INV_GREEN = `${ESC}[42m${ESC}[30m`; // Green bg, black text
const INV = `${ESC}[7m`;
function pickHost(channel, uid, hosts) {
return new Promise((resolve) => {
@@ -35,18 +54,43 @@ function pickHost(channel, uid, hosts) {
const list = visible();
if (selected >= list.length) selected = Math.max(0, list.length - 1);
let out = CLEAR + HIDE_CUR;
out += `${BOLD} Theta42 Jump — hosts for ${uid}${RST}\r\n`;
out += `${DIM} ↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n\r\n`;
// Header with gradient-style color
out += `\r\n ${BOLD}${BRIGHT_CYAN}╔════════════════════════════════════════════════════════╗${RST}\r\n`;
out += ` ${BOLD}${BRIGHT_CYAN}${RST} ${BOLD}${BRIGHT_MAGENTA}Theta42 Jump${RST} ${DIM}·${RST} ${BRIGHT_GREEN}hosts for ${uid}${RST} ${BOLD}${BRIGHT_CYAN}${RST}\r\n`;
out += ` ${BOLD}${BRIGHT_CYAN}╚════════════════════════════════════════════════════════╝${RST}\r\n`;
out += `\r\n`;
out += ` ${DIM}↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n`;
out += `\r\n`;
if (!list.length) {
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
out += ` ${YELLOW}${RST} ${DIM}(no match for "${filter}")${RST}\r\n`;
} else {
list.forEach((h, i) => {
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
const row = ` ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${ip}` : ''}`;
out += (i === selected ? `${INV}> ${h.name} (${h.slug})${ip ? ` ${ip}` : ''}${RST}` : row) + '\r\n';
const isProd = h.metadata && h.metadata.isProduction;
const envBadge = isProd ? `${BOLD}${RED}PROD${RST} ` : `${DIM}DEV${RST} `;
if (i === selected) {
// Selected row with green inverse background
const selRow = `${INV_GREEN} ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${CYAN}${ip}${RST}` : ''} ${envBadge} ${BOLD}${BRIGHT_GREEN}◄ SELECTED ►${RST}${INV_GREEN}${RST}`;
out += selRow + '\r\n';
} else {
// Normal row with subtle coloring
const nameColor = i % 2 === 0 ? BRIGHT_CYAN : CYAN;
out += ` ${nameColor}${h.name}${RST} ${DIM}(${h.slug})${RST}${ip ? ` ${BLUE}${ip}${RST}` : ''} ${envBadge}\r\n`;
}
});
}
if (filter) out += `\r\n ${DIM}filter:${RST} ${filter}`;
if (filter) {
out += `\r\n ${DIM}filter: ${BRIGHT_YELLOW}${filter}${RST}`;
}
// Footer
out += `\r\n\r\n ${DIM}────────────────────────────────────────────────────────${RST}\r\n`;
out += ` ${DIM}Press${RST} ${BOLD}1-9${RST} ${DIM}to quick-select · ${BOLD}q${RST} ${DIM}to quit${RST}\r\n`;
channel.write(out);
};
@@ -156,6 +156,29 @@ test('shell bridges and echoes', async () => {
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 () => {
const { conn, ready } = connectJump();
await ready;
+1 -1
View File
@@ -37,6 +37,6 @@ module.exports = {
nav: [
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: []},
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin', 'app_jump_admin']},
],
};
+66 -2
View File
@@ -1,5 +1,46 @@
<%- include('top') %>
<script type="text/javascript">app.auth.forceLogin();</script>
<script type="text/javascript">app.auth.forceLogin(['admin', 'app_jump_admin']);</script>
<div class="container mt-4">
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-active"></div>
<div class="text-muted small text-uppercase">Active sessions</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-total"></div>
<div class="text-muted small text-uppercase">Total connections</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6 text-danger" id="stat-fail"></div>
<div class="text-muted small text-uppercase">Failed</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-users"></div>
<div class="text-muted small text-uppercase">Users seen</div>
</div></div>
</div>
</div>
<div class="row g-3 mb-4">
<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>
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
@@ -29,8 +70,28 @@
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next &rarr;</button>
</div>
</div>
</div>
<script type="text/javascript">
function rows(sel, list){
var $b = $(sel).empty();
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
list.forEach(function(x){
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
});
}
function loadMetrics(){
app.jump.metrics(function(error, data){
if(error || !data) return;
$('#stat-active').text(data.active);
$('#stat-total').text(data.total);
$('#stat-fail').text(data.fail);
$('#stat-users').text((data.topUsers || []).length);
rows('#top-hosts', data.topHosts);
rows('#top-users', data.topUsers);
});
}
var page = 0;
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
function applyFilters(){ page = 0; load(); }
@@ -57,6 +118,9 @@
$('#next').prop('disabled', (page + 1) * size >= total);
});
}
$(document).ready(load);
$(document).ready(function(){
loadMetrics();
load();
});
</script>
<%- include('bottom') %>
+172 -85
View File
@@ -1,30 +1,25 @@
<%- include('top') %>
<script type="text/javascript">app.auth.forceLogin();</script>
<div class="container mt-4">
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-active"></div>
<div class="text-muted small text-uppercase">Active sessions</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-total"></div>
<div class="text-muted small text-uppercase">Total connections</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6 text-danger" id="stat-fail"></div>
<div class="text-muted small text-uppercase">Failed</div>
</div></div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center"><div class="card-body">
<div class="display-6" id="stat-users"></div>
<div class="text-muted small text-uppercase">Users seen</div>
</div></div>
<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="copyFieldValue('#quick-jump-cmd')" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -32,20 +27,12 @@
<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="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>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Host</th><th>Slug</th><th class="text-end">Address</th><th>Last connection</th><th>Last failed connection</th><th></th></tr></thead>
<tbody id="my-hosts"></tbody>
</table>
</div>
</div>
</div>
</div>
@@ -63,64 +50,122 @@
(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 class="card-body">
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
<div id="api-tokens">
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow-sm mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="fa-solid fa-key"></i> {{name}}</h6>
<small class="text-muted font-monospace">{{id_short}}</small>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
{{#description}}<p>{{description}}</p>{{/description}}
<dl class="row mb-0 small">
<dt class="col-sm-3">Token ID</dt>
<dd class="col-sm-9"><code>{{id_short}}</code></dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">{{{created_display}}}</dd>
<dt class="col-sm-3">Last used</dt>
<dd class="col-sm-9">{{{last_used_display}}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{expires_display}}}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
<button type="button" onclick="rotateApiToken('{{id}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
<button type="button" onclick="revokeApiToken('{{id}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
function rows(sel, list){
var $b = $(sel).empty();
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
list.forEach(function(x){
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
// 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 copyFieldValue(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)) || '';
$b.append('<tr><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
// Green: a session to this host is live right now. Yellow: the most
// recent attempt to this host failed (and none is currently live).
var rowClass = h.connected ? 'table-success'
: (h.lastFailed && (!h.lastConnected || h.lastFailed > h.lastConnected)) ? 'table-warning'
: '';
$b.append('<tr class="' + rowClass + '"><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></tr>');
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
+ '<td class="small">' + (h.lastConnected ? app.jump.fmtTime(h.lastConnected) : '—') + '</td>'
+ '<td class="small">' + (h.lastFailed ? app.jump.fmtTime(h.lastFailed) : '—') + '</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="copyFieldValue(\'#' + 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>'
);
});
// expires_at/created_on/last_used_on come back as redis-hash strings for
// some fields and real numbers for others depending on the model's field
// type -- fmtTime already handles both via moment(ms, 'x').
function fmtExpiry(token){
var exp = Number(token.expires_at);
if(!exp) return '<span class="badge text-bg-secondary">never</span>';
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
return '<span class="badge text-bg-warning">' + moment(exp).fromNow() + '</span>';
}
var tokensById = {};
function processToken(token){
tokensById[token.id] = token;
token.id_short = token.id.slice(0, 12) + '…';
token.expires_display = fmtExpiry(token);
token.created_display = app.jump.fmtTime(token.created_on);
token.last_used_display = token.last_used_on ? app.jump.fmtTime(token.last_used_on) : 'Never';
return token;
}
function loadApiTokens(){
app.apiToken.list(function(error, data){
if(error) return tokenRows([]);
tokenRows(data && data.results);
var tokens = (!error && data && data.results) || [];
$.scope.apiTokenCard.empty();
tokens.forEach(function(t){ $.scope.apiTokenCard.push(processToken(t)); });
$('#api-tokens-empty').toggle(tokens.length === 0);
});
}
// Shared "reveal secret once" display -- also used by proxy/sso-manager-node.
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>'
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.jump.esc(token) + '">'
// Reuses the same copy-to-clipboard helper as the Quick Jump card
// above (toast feedback -- FontAwesome replaces <i> icons with
// inline <svg>, so a checkmark-flash-the-icon approach silently
// no-ops; the toast doesn't have that problem).
+ '<button class="btn btn-outline-secondary" onclick="copyFieldValue(\'#revealed-token\')" title="Copy"><i class="fa-solid fa-copy"></i></button></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>'
});
}
@@ -132,27 +177,77 @@
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control" id="new-token-description" placeholder="optional">'
+ '</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>'
+ '</div>',
footer: {buttonsHtml: app.modal.footerButtons({onSave: 'submitApiToken()', saveLabel: 'Create'})},
});
$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');
if(!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
app.apiToken.add({
name: name,
description: $('#new-token-description').val(),
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');
if(error) return app.messages.action((data && data.message) || 'Failed to create token', app.modal.body(), 'danger');
// Deliberately no app.modal.close() here -- app.modal is a
// singleton, and close() immediately followed by open() (inside
// showToken) in the same tick collides with Bootstrap's
// hide-transition guard, so the reveal modal silently never
// shows. open() alone already overwrites the (already-visible)
// modal's content in place.
showToken('API Token Created', data.token);
loadApiTokens();
});
}
function editToken(id){
var t = tokensById[id]; if(!t) return;
app.modal.open({
title: 'Edit Token',
bodyHtml:
'<input type="hidden" id="edit-token-id" value="' + app.jump.esc(id) + '">'
+ '<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control" id="edit-token-name" value="' + app.jump.esc(t.name || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control" id="edit-token-description" value="' + app.jump.esc(t.description || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days, blank = keep as-is, 0 = never)</label>'
+ '<input type="number" class="form-control" id="edit-token-days" min="0">'
+ '</div>',
footer: {
metaHtml: 'Created by ' + app.jump.esc(t.created_by || '—') + ' on ' + app.jump.fmtTime(t.created_on),
buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
},
});
}
function saveEditToken(){
var payload = {
id: $('#edit-token-id').val(),
name: $('#edit-token-name').val(),
description: $('#edit-token-description').val(),
expires_in_days: $('#edit-token-days').val(),
};
app.apiToken.update(payload, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to update token', app.modal.body(), 'danger');
app.modal.close();
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');
@@ -174,17 +269,9 @@
}
$(document).ready(async function(){
app.jump.metrics(function(error, data){
if(error || !data) return;
$('#stat-active').text(data.active);
$('#stat-total').text(data.total);
$('#stat-fail').text(data.fail);
$('#stat-users').text((data.topUsers || []).length);
rows('#top-hosts', data.topHosts);
rows('#top-users', data.topUsers);
});
await app.auth.loadUser();
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
if(app.auth.isAdmin()) $('#my-hosts-title').text('My hosts');
$('#quick-jump-cmd').val(sshCommand());
app.jump.hosts(function(error, data){
if(error) return hostRows('#my-hosts', []);
hostRows('#my-hosts', data && data.results);
+2
View File
@@ -1,6 +1,7 @@
<%- include('top') %>
<script type="text/javascript">app.auth.forceLogin();</script>
<div class="container mt-4">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
@@ -13,6 +14,7 @@
</table>
</div>
</div>
</div>
<script type="text/javascript">
function loadSessions(){
+9 -1
View File
@@ -82,16 +82,24 @@
</div>
<script type="text/javascript">
// --sw-content-offset tracks the same height as #spa-shell's margin-top
// (fixed navbar, plus the update banner while it's shown), so any
// in-page sticky element (e.g. a sticky search/sort bar) can offset
// itself below both fixed elements via `top: var(--sw-content-offset)`
// instead of colliding with them at the viewport's true top:0.
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
let offset = $nav.outerHeight() + $banner.outerHeight();
$('#spa-shell').css('margin-top', offset + 'px');
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
sessionStorage.setItem('update-banner-dismissed', '1');
}