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') %> - +
| Host | Slug | Address | Last connection | Last failed connection |
|---|