From dfafffe154a06979f0de80f024cf645001dbecc2 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 30 Jul 2026 11:58:28 -0400 Subject: [PATCH] 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). --- nodejs/conf/base.js | 7 +++- nodejs/middleware/auth.js | 21 +++++++++- nodejs/models/metrics.js | 28 ++++++++++++- nodejs/routes/api.js | 4 +- nodejs/routes/user.js | 20 +++++++++- nodejs/services/ssh_server.js | 20 +++++----- nodejs/utils/ui.js | 2 +- nodejs/views/audit.ejs | 66 ++++++++++++++++++++++++++++++- nodejs/views/dashboard.ejs | 74 +++++++---------------------------- 9 files changed, 163 insertions(+), 79 deletions(-) diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index ee2d3ea..45270b1 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -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. diff --git a/nodejs/middleware/auth.js b/nodejs/middleware/auth.js index a029b15..11286f8 100644 --- a/nodejs/middleware/auth.js +++ b/nodejs/middleware/auth.js @@ -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 }; diff --git a/nodejs/models/metrics.js b/nodejs/models/metrics.js index ef27b98..b63fa51 100644 --- a/nodejs/models/metrics.js +++ b/nodejs/models/metrics.js @@ -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 }; diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index 1293db1..08d0646 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -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; diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index a4f6e4e..5068a4c 100644 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -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); } }); diff --git a/nodejs/services/ssh_server.js b/nodejs/services/ssh_server.js index 20d6afb..9f89c7a 100644 --- a/nodejs/services/ssh_server.js +++ b/nodejs/services/ssh_server.js @@ -130,7 +130,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) { let justInjected = false; try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } - catch (err) { throw fail('key-inject-failed', err.message); } + catch (err) { throw fail('key-inject-failed', err.message, host ? host.slug : undefined); } let upstream; try { @@ -139,7 +139,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) { username: state.uid, privateKey: JUMP_KEYS.clientKey, uid: state.uid, justInjected, onHostKey, }); - } catch (err) { throw fail('upstream-unreachable', err.message); } + } catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); } return { upstream, host, endpoint }; } @@ -147,8 +147,10 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) { // 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; } +// 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. @@ -179,7 +181,7 @@ async function runGrammar(session, client, state) { const reason = err.reason || 'error'; rejectUp(new Error(reasonMessage(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, hostSlug: err.hostSlug, success: false }); } } @@ -203,9 +205,9 @@ async function runTuiSession(session, client, state) { const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' }); - const finishFail = async (reason, detail) => { + const finishFail = async (reason, detail, hostSlug) => { await record.finish({ success: false, failReason: reason, failDetail: detail }); - await metrics.bump({ uid: state.uid, success: false }); + await metrics.bump({ uid: state.uid, hostSlug, success: false }); try { client.end(); } catch (_) {} }; @@ -227,7 +229,7 @@ async function runTuiSession(session, client, state) { let justInjected = false; try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } - catch (err) { return finishFail('key-inject-failed', err.message); } + catch (err) { return finishFail('key-inject-failed', err.message, tui.host.slug); } let upstream; try { @@ -238,7 +240,7 @@ async function runTuiSession(session, client, state) { }); } catch (err) { try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {} - return finishFail('upstream-unreachable', err.message); + return finishFail('upstream-unreachable', err.message, tui.host.slug); } registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug }); diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 410da16..5c5cac6 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -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: ['admin']}, + {href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin', 'app_jump_admin']}, ], }; diff --git a/nodejs/views/audit.ejs b/nodejs/views/audit.ejs index b2297a4..93ad747 100644 --- a/nodejs/views/audit.ejs +++ b/nodejs/views/audit.ejs @@ -1,7 +1,47 @@ <%- include('top') %> - +
+
+
+
+
+
Active sessions
+
+
+
+
+
+
Total connections
+
+
+
+
+
+
Failed
+
+
+
+
+
+
Users seen
+
+
+
+ +
+
+
Top hosts
+
+
+
+
+
Top users
+
+
+
+
+
Audit log
@@ -33,6 +73,25 @@
<%- include('bottom') %> diff --git a/nodejs/views/dashboard.ejs b/nodejs/views/dashboard.ejs index 89768a6..c7c9d45 100644 --- a/nodejs/views/dashboard.ejs +++ b/nodejs/views/dashboard.ejs @@ -2,33 +2,6 @@
-
-
-
-
-
Active sessions
-
-
-
-
-
-
Total connections
-
-
-
-
-
-
Failed
-
-
-
-
-
-
Users seen
-
-
-
-
@@ -54,20 +27,12 @@
Hosts you can reach
-
-
-
-
- -
-
-
Top hosts
-
-
-
-
-
Top users
-
+
+ + + +
HostSlugAddressLast connectionLast failed connection
+
@@ -121,13 +86,6 @@