Compare commits

..

2 Commits

Author SHA1 Message Date
wmantly fd863b89ca Merge pull request #8 from theta42/feature/dashboard-hosts
Dashboard: list hosts you can reach; fix key-injection ObjectClassViolationError
2026-07-26 23:12:30 -04:00
wmantly 4fb4e77007 Dashboard: list hosts you can reach; bump @simpleworkjs/ldap to 1.0.1
- New GET /api/user/hosts (auth-only): all hosts for admins, group-filtered
  list for everyone else.
- accessibleHosts() accepts a pre-resolved user.groups, so the web UI's
  OIDC session skips a redundant LDAP getGroups(dn) call.
- Dashboard shows a "Hosts you can reach" / "All hosts" table.
- @simpleworkjs/ldap 1.0.1 fixes addSshKey's ObjectClassViolationError on
  accounts predating the ldapPublicKey objectClass -- was aborting key
  injection (and the SSH connection) on affected accounts.
- Bump to 1.5.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 23:11:23 -04:00
8 changed files with 101 additions and 16 deletions
+9
View File
@@ -4,6 +4,15 @@ 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.5.0] - 2026-07-27
### Added
- **Web UI dashboard now lists the hosts you can reach** ("Hosts you can reach", or "All hosts" for admins) — previously the dashboard only showed usage metrics, with no way to see your actual access from the browser. Backed by a new `GET /api/user/hosts` endpoint (auth-only, not admin-gated): admins get the full inventory via `utils/access.js`'s new `allHosts()`, everyone else gets the same group-based resolution the SSH front door uses.
- `utils/access.js`'s `accessibleHosts()` now accepts a pre-resolved `groups` array on the user object, skipping the LDAP `getGroups(dn)` round-trip — the web UI's OIDC session already has its groups claim and has no LDAP `dn` to query with.
### Fixed
- **Bumped `@simpleworkjs/ldap` to 1.0.1**, which fixes `addSshKey` throwing `ObjectClassViolationError` (LDAP `0x41`) on accounts predating the `ldapPublicKey` auxiliary objectClass. This is the code path this jump host's key-injection (`utils/key_inject.js`) uses on every first connection for a user — on affected accounts it aborted the SSH connection entirely (`key-inject-failed`).
## [1.4.0] - 2026-07-26
### Added
+6 -6
View File
@@ -1,19 +1,19 @@
{
"name": "t42-jump-host",
"version": "1.3.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-jump-host",
"version": "1.3.0",
"version": "1.4.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/ldap": "^1.0.0",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
@@ -177,9 +177,9 @@
}
},
"node_modules/@simpleworkjs/ldap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.1.tgz",
"integrity": "sha512-1jz3WQ9ghwNHz2mI+H33qw8nIQpLkoNHEy8lgdgsE/nCLK8JGU1SIKEdbsdMeXEQ0seKqyjuyXwmhdotsx6Lww==",
"license": "MIT",
"dependencies": {
"ldapts": "^8.1.8"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "t42-jump-host",
"version": "1.4.0",
"version": "1.5.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
@@ -20,10 +20,10 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
+2 -1
View File
@@ -11,7 +11,8 @@ app.jump = (function(app){
var qs = $.param(query || {});
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
}
return {metrics: metrics, sessions: sessions, audit: audit};
function hosts(cb){ app.api.get('user/hosts', cb); }
return {metrics: metrics, sessions: sessions, audit: audit, hosts: hosts};
})(app);
// Shared render helpers.
+13
View File
@@ -5,6 +5,7 @@
const router = require('express').Router();
const { isAdmin } = require('../middleware/auth');
const access = require('../utils/access');
router.get('/me', (req, res) => {
res.json({
@@ -14,4 +15,16 @@ router.get('/me', (req, res) => {
});
});
// The hosts this session can SSH to — every host for an admin, otherwise the
// same group-based resolution the SSH front door uses (accessibleHosts),
// fed the OIDC session's already-known groups instead of an LDAP lookup.
router.get('/hosts', async (req, res, next) => {
try {
const hosts = isAdmin(req)
? await access.allHosts()
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
res.json({ results: hosts });
} catch (err) { next(err); }
});
module.exports = router;
+27 -1
View File
@@ -2,7 +2,7 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { accessibleHosts, clearCache } = require('../../utils/access');
const { accessibleHosts, allHosts, clearCache } = require('../../utils/access');
function stubLdap(groups) {
return { getGroups: async () => groups };
@@ -54,6 +54,32 @@ test('caches per uid', async () => {
assert.strictEqual(calls, 1);
});
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
clearCache();
let ldapCalled = false;
const user = { uid: 'erin', groups: ['host_web01_access'] };
const fetchImpl = stubFetch({
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
});
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
assert.strictEqual(ldapCalled, false);
});
test('allHosts fetches the whole host inventory with no group filter', async () => {
const fetchImpl = async (url) => {
assert.ok(!url.includes('group='), 'must not filter by group');
assert.ok(url.includes('kind=host'));
return { ok: true, json: async () => ({ results: [
{ id: '1', kind: 'host', slug: 'host_a' },
{ id: '2', kind: 'host', slug: 'host_b' },
] }) };
};
const hosts = await allHosts({ fetchImpl });
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
});
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
clearCache();
const user = { uid: 'dave', dn: 'd' };
+15 -4
View File
@@ -8,9 +8,10 @@
const conf = require('@simpleworkjs/conf');
if (conf.standalone && conf.standalone.enabled) {
// Standalone mode: use the ORM-backed host inventory.
// Standalone mode: use the ORM-backed host inventory. Every host is
// accessible to every user, so allHosts and accessibleHosts coincide.
const { accessibleHosts } = require('./hosts_file');
module.exports = { accessibleHosts, clearCache: () => {} };
module.exports = { accessibleHosts, allHosts: () => accessibleHosts(), clearCache: () => {} };
} else {
// Production mode: LDAP groups + SSO API (unchanged).
@@ -48,11 +49,21 @@ if (conf.standalone && conf.standalone.enabled) {
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
}
// Every host in the inventory, unfiltered — for admins (the web UI's own
// account is already gated by requireAdmin before this is ever called).
async function allHosts({ fetchImpl = fetch } = {}) {
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
return resources.filter(r => r.kind === 'host');
}
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
const hit = cache.get(user.uid);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
const groups = await ldap.getGroups(user.dn);
// The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we
// look them up; the web UI already has the session's OIDC groups claim
// and passes it directly, skipping a redundant LDAP round-trip.
const groups = user.groups || await ldap.getGroups(user.dn);
const seen = new Map();
for (const cn of groups) {
@@ -80,5 +91,5 @@ if (conf.standalone && conf.standalone.enabled) {
else cache.clear();
}
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
module.exports = { accessibleHosts, allHosts, clearCache, fetchResourcesByGroup };
}
+26 -1
View File
@@ -28,6 +28,15 @@
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header"><i class="fa-solid fa-network-wired me-1"></i> <span id="my-hosts-title">Hosts you can reach</span></div>
<table class="table table-sm mb-0"><tbody id="my-hosts"></tbody></table>
</div>
</div>
</div>
<div class="row g-3">
<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>
@@ -49,7 +58,17 @@
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
});
}
$(document).ready(function(){
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>'
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td></tr>');
});
}
$(document).ready(async function(){
app.jump.metrics(function(error, data){
if(error || !data) return;
$('#stat-active').text(data.active);
@@ -59,6 +78,12 @@
rows('#top-hosts', data.topHosts);
rows('#top-users', data.topUsers);
});
await app.auth.loadUser();
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
app.jump.hosts(function(error, data){
if(error) return hostRows('#my-hosts', []);
hostRows('#my-hosts', data && data.results);
});
});
</script>
<%- include('bottom') %>