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>
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+6
-6
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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') %>
|
||||
|
||||
Reference in New Issue
Block a user