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).
This commit is contained in:
2026-07-30 11:58:28 -04:00
parent bd4464ed19
commit dfafffe154
9 changed files with 163 additions and 79 deletions
+6 -1
View File
@@ -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 };
+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;
+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); }
});
+11 -9
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 (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 });
+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: ['admin']},
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin', 'app_jump_admin']},
],
};
+64 -2
View File
@@ -1,7 +1,47 @@
<%- 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>
<div class="card-body pb-0">
@@ -33,6 +73,25 @@
</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(); }
@@ -59,6 +118,9 @@
$('#next').prop('disabled', (page + 1) * size >= total);
});
}
$(document).ready(load);
$(document).ready(function(){
loadMetrics();
load();
});
</script>
<%- include('bottom') %>
+15 -59
View File
@@ -2,33 +2,6 @@
<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>
</div>
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
@@ -54,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>
@@ -121,13 +86,6 @@
</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){
@@ -153,9 +111,16 @@
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>'
// 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>'
+ '<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>'
@@ -304,17 +269,8 @@
}
$(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', []);