Compare commits

...

21 Commits

Author SHA1 Message Date
wmantly fedbe81690 fix: only catalog hosts are jump targets (v1.19.0)
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m4s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m3s
Pull Request Tests / Test Summary (push) Failing after 4s
isManagedHost() treated a missing metadata.managed flag as permission, so
any host the SSO merely discovered -- an unpromoted Proxmox guest, a UniFi
client -- was offered in the TUI picker and accepted by the username
grammar.

Replaced with isCatalogHost(), mirroring the rule the SSO Directory's own
listing applies: a resource carrying discovery_sources but never promoted
is excluded; hand-created hosts and promoted ones are included; an
explicit managed:false is always excluded.

The two copies of this rule have now drifted apart once. If a third
consumer needs it, hoist it into @simpleworkjs/directory-schema rather
than copying again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:41:50 -04:00
wmantly a7b3416619 Merge pull request #37 from theta42/fix/version-1.18.0
chore: sync package.json to 1.18.0
2026-08-04 16:52:48 -04:00
wmantly f357c89ac7 chore: sync package.json + lockfile to v1.18.0 tag
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m5s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m3s
Pull Request Tests / Test Summary (push) Failing after 4s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:51:36 -04:00
wmantly b9415dcb17 Merge pull request #36 from theta42/release/v1.18.0
feat: error page + navbar active styling (v1.18.0)
2026-08-04 15:09:37 -04:00
wmantly 65ba1b16e3 feat: error page + navbar active styling (v1.18.0)
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m2s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m9s
Pull Request Tests / Test Summary (push) Failing after 4s
- Add SSO-style error page (views/error.ejs) and render it for browser
  navigation in the error handler (API still returns JSON).
- Navbar: username not underlined; only the active nav link is bold+underlined.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:26:48 -04:00
wmantly 8c4ec67282 Merge pull request #35 from theta42/fix/jump-target-filter-v1.17.2
fix(jump-host): Filter SSH connection targets to managed hosts only v1.17.2
2026-08-03 13:57:38 -04:00
wmantly 36e7dbf8aa fix(jump-host): Filter SSH connection targets to managed hosts only v1.17.2
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m4s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m3s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 13:57:11 -04:00
wmantly c56bfe21e5 Merge pull request #34 from theta42/fix/bump-version-1.17.1
chore: bump package.json version to 1.17.1
2026-08-03 02:35:37 -04:00
wmantly d533a94718 chore: bump package.json version to 1.17.1 2026-08-03 02:35:26 -04:00
wmantly fe18393d4e Merge pull request #33 from theta42/feature/v1.17.1-docs-restoration
docs: restore jump-host documentation and deployment guide
2026-08-03 02:19:02 -04:00
wmantly e41e7ff9e1 docs: restore complete jump-host documentation site and DEPLOYMENT.md 2026-08-03 02:18:56 -04:00
wmantly 1100872152 Merge pull request #32 from theta42/feat-machine-identity
feat: use machine identity for access queries
2026-08-02 18:50:45 -04:00
wmantly 16ab12a61c feat: use machine identity for access queries
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m16s
Pull Request Tests / Run Tests (22.x) (push) Failing after 1m11s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 18:49:00 -04:00
wmantly 0f6be51c35 feat: downstream host key pinning (#1) 2026-08-02 18:19:35 -04:00
wmantly 463111dfd6 fix: remove DEPLOYMENT.md from Docker build context 2026-08-02 11:52:55 -04:00
wmantly 4874544955 chore: release v1.16.0 2026-08-02 01:54:00 -04:00
wmantly 256476268f feat: implement OpenBao PKI certificate authentication 2026-08-02 01:54:00 -04:00
wmantly a57d579b0e docs: remove standalone deployment and docs folder 2026-08-02 00:51:30 -04:00
wmantly 49bf0fa1d3 chore: release v1.15.0 2026-08-02 00:16:18 -04:00
wmantly 1b4764e328 feat: Rename SSO Manager to Jump in UI 2026-08-02 00:16:18 -04:00
wmantly db3333e26d v1.14.1: bump @simpleworkjs/bao-conf to 1.0.1
bao-conf 1.0.0's init() threw when VAULT_TOKEN was unset, crashing boot
(.catch -> process.exit(1)) in any deployment without an OpenBao sidecar
(standalone Docker, bare metal). 1.0.1 makes init() fail-soft on a
missing token (warn + continue from CONF_SECRETS). The theta-env stack
is unaffected (it always sets a scoped VAULT_TOKEN).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:47:51 -04:00
16 changed files with 252 additions and 89 deletions
+31
View File
@@ -1,9 +1,40 @@
## v1.19.0
- fix: **only catalog hosts are jump targets.** `isManagedHost` treated a missing `metadata.managed` flag as permission, so any host the SSO merely *discovered* — an unpromoted Proxmox guest, a UniFi client — was offered in the TUI picker and accepted by the username grammar. The filter is now `isCatalogHost`, mirroring the SSO Directory's own rule: a resource carrying `discovery_sources` but never promoted is excluded, while hand-created hosts (no `discovery_sources`) and promoted ones (`managed: true`) are included, and an explicit `managed: false` is always excluded.
- test: regression coverage for all five cases (hand-made, discovered-unpromoted, discovered-promoted, `manual` source, explicitly unmanaged).
- docs: `docs/connecting.md` states that discovery results are not jump targets until promoted into the catalog.
## v1.18.0
- feat: Add SSO-style error page (404/500) for browser navigation instead of a bare text response
- feat: navbar — username no longer underlined; only the active link is bold + underlined
## v1.16.1
- fix: remove missing DEPLOYMENT.md from Docker build context
## v1.16.0
- Added OpenBao PKI SSH Certificate Support
- Fallback to LDAP Key injection
# v1.15.0
- feat: Rename SSO Manager to Jump in UI
# Changelog
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.14.1] - 2026-08-01
### Fixed
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
jump host exit at boot in any deployment without an OpenBao sidecar
(standalone Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing
token (warn + continue from `CONF_SECRETS`), matching the documented
contract. The theta-env stack is unaffected (it always sets a scoped
`VAULT_TOKEN`).
## [1.14.0] - 2026-08-01
### Changed
+1 -1
View File
@@ -37,7 +37,7 @@ COPY nodejs/utils ./utils
COPY nodejs/views ./views
COPY nodejs/public ./public
COPY README.md CHANGELOG.md DEPLOYMENT.md /
COPY README.md CHANGELOG.md /
COPY --from=gitinfo /commit.txt ./.build_commit
COPY docker-entrypoint.sh /usr/local/bin/
+11 -3
View File
@@ -66,14 +66,22 @@ directory access allows — it doubles as "what can I reach from here?"
## What you can reach
The set of hosts is computed per login: your LDAP group memberships intersected
with the SSO directory's hosts (via the `host_<name>_access` groups the
directory auto-creates for each machine). To get access to a new host, an admin
adds you to that host's access group in the SSO — nothing on the jump host
with the SSO directory's **catalog** hosts (via the `host_<name>_access` groups
the directory auto-creates for each machine). To get access to a new host, an
admin adds you to that host's access group in the SSO — nothing on the jump host
changes.
Targets that don't resolve to a host you're allowed to reach are refused (and
audited). Raw IPs that aren't a known directory host are denied by default.
**Only catalog hosts are jump targets.** A machine that the SSO merely
*discovered* — a Proxmox guest, a UniFi client — is not a jump target until an
admin promotes it into the directory catalog. The jump host applies the same
rule the SSO's own Directory listing does: a resource carrying
`discovery_sources` but never promoted is excluded, while hand-created hosts and
promoted ones are included. Previously the filter treated a missing `managed`
flag as permission, so unpromoted discovery results showed up in the picker.
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
> every registered host is reachable by every registered user — there's no
> group-based restriction to ask an admin about.
+11 -1
View File
@@ -4,6 +4,8 @@ const express = require('express');
const compression = require('compression');
require('./models'); // wire model-redis + register models
const conf = require('@simpleworkjs/conf');
const buildInfo = require('./utils/build_info');
const app = express();
@@ -41,7 +43,15 @@ app.use((err, req, res, next) => {
if(req.path.startsWith('/api/')){
return res.status(status).json({name: err.name || 'Error', message: err.message || 'Error'});
}
res.status(status).send(err.message || 'Error');
// Browser navigation gets the HTML error page (shared with SSO).
res.status(status).render('error', {
title: conf.environment !== 'production' ? 'dev' : '',
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
name: conf.name,
logo: conf.logo,
...buildInfo,
error: err,
});
});
module.exports = app;
+1 -1
View File
@@ -6,7 +6,7 @@
// values (LDAP creds, SSO API token) belong in the secrets file.
module.exports = {
name: 'SSO Manager',
name: 'Jump',
logo: '/static/img/theta42.svg',
// LDAP directory the users live in (same directory the SSO manages).
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "t42-jump-host",
"version": "1.11.0",
"version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-jump-host",
"version": "1.11.0",
"version": "1.19.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
@@ -158,9 +158,9 @@
}
},
"node_modules/@simpleworkjs/bao-conf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.0.tgz",
"integrity": "sha512-HxB2ohFuDKbwTfNh5dXCot0dd6qoP+3Ebz1xKH0eOhKNVNMjHU1p7XZv2VTe+VnHn+DV22Eh8lAgWxnX/pkXUw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-jump-host",
"version": "1.14.0",
"version": "1.19.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
+6
View File
@@ -3,6 +3,12 @@ nav.navbar{
padding-right: 1em;
}
/* Only the active top-nav link is bold + underlined; the username is plain. */
.top-nav a.active{
font-weight: bold;
text-decoration: underline;
}
body {
display: flex;
flex-direction: column;
+6 -2
View File
@@ -23,7 +23,7 @@ function counter(onBytes) {
// Connect the upstream ssh2.Client, retrying once after a short pause if the
// first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
// first-time key injection).
function connectUpstream({ host, port, username, privateKey, onHostKey, uid, justInjected }) {
function connectUpstream({ host, port, username, privateKey, cert, onHostKey, uid, justInjected, expectedHostKeyFp }) {
return new Promise((resolve, reject) => {
let attempted = false;
const dial = (allowRetry) => {
@@ -42,12 +42,16 @@ function connectUpstream({ host, port, username, privateKey, onHostKey, uid, jus
})
.connect({
host, port, username, privateKey,
certificates: cert ? [cert] : undefined,
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
keepaliveInterval: 15000,
hostVerifier: (key) => {
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
if (onHostKey) onHostKey(fp);
return true; // v1: trust-on-use, fingerprint audited. Pinning = follow-up.
if (expectedHostKeyFp && expectedHostKeyFp !== fp) {
return false;
}
return true; // v1: trust-on-use if not pinned, fingerprint audited.
},
});
};
+32 -6
View File
@@ -129,15 +129,28 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (err) { throw fail('key-inject-failed', err.message, host ? host.slug : undefined); }
let cert;
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
try {
if (usePki) {
const { getSignedCert } = require('../utils/vault_cert');
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
} else {
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
}
} catch (err) {
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
throw fail(failType, err.message, host ? host.slug : undefined);
}
let upstream;
try {
upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port,
username: state.uid, privateKey: JUMP_KEYS.clientKey,
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
uid: state.uid, justInjected, onHostKey,
expectedHostKeyFp: host && host.metadata && host.metadata.sshHostKeyFp,
});
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
@@ -228,15 +241,28 @@ async function runTuiSession(session, client, state) {
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (err) { return finishFail('key-inject-failed', err.message, tui.host.slug); }
let cert;
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
try {
if (usePki) {
const { getSignedCert } = require('../utils/vault_cert');
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
} else {
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
}
} catch (err) {
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
return finishFail(failType, err.message, tui.host.slug);
}
let upstream;
try {
upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port,
username: state.uid, privateKey: JUMP_KEYS.clientKey,
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
expectedHostKeyFp: tui.host && tui.host.metadata && tui.host.metadata.sshHostKeyFp,
});
} catch (err) {
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
+42 -35
View File
@@ -8,39 +8,33 @@ function stubLdap(groups) {
return { getGroups: async () => groups };
}
function stubFetch(byGroup) {
function stubFetch(byUid) {
return async (url) => {
const cn = decodeURIComponent(url.split('group=')[1]);
return { ok: true, json: async () => ({ results: byGroup[cn] || [] }) };
const uid = url.split('/access/')[1];
return { ok: true, json: async () => ({ results: byUid[uid] || [] }) };
};
}
test('unions hosts across groups, dedupes, drops non-hosts', async () => {
test('drops non-hosts from access projection', async () => {
clearCache();
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
const fetchImpl = stubFetch({
host_web01_access: [
alice: [
{ id: '1', kind: 'host', slug: 'host_web01' },
{ id: '2', kind: 'host', slug: 'host_db' },
{ id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host
],
host_db_access: [
{ id: '1', kind: 'host', slug: 'host_web01' }, // dupe by id
{ id: '2', kind: 'host', slug: 'host_db' },
],
});
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['host_web01_access', 'host_db_access']) });
const hosts = await accessibleHosts(user, { fetchImpl });
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
});
test('a failing group query does not sink the rest', async () => {
test('a failing access query returns empty list without throwing', async () => {
clearCache();
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
const fetchImpl = async (url) => {
if (url.includes('bad')) return { ok: false, status: 500 };
return { ok: true, json: async () => ({ results: [{ id: '3', kind: 'host', slug: 'host_ok' }] }) };
};
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['bad_access', 'good_access']) });
assert.deepStrictEqual(hosts.map((h) => h.id), ['3']);
const fetchImpl = async () => ({ ok: false, status: 500 });
const hosts = await accessibleHosts(user, { fetchImpl });
assert.deepStrictEqual(hosts, []);
});
test('caches per uid', async () => {
@@ -48,23 +42,19 @@ test('caches per uid', async () => {
let calls = 0;
const user = { uid: 'cara', dn: 'd' };
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
const ldap = { getGroups: async () => ['g1'] };
await accessibleHosts(user, { fetchImpl, ldap });
await accessibleHosts(user, { fetchImpl, ldap });
await accessibleHosts(user, { fetchImpl });
await accessibleHosts(user, { fetchImpl });
assert.strictEqual(calls, 1);
});
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
test('does not depend on user.groups or ldap.getGroups', async () => {
clearCache();
let ldapCalled = false;
const user = { uid: 'erin', groups: ['host_web01_access'] };
const user = { uid: 'erin' }; // no dn, no groups
const fetchImpl = stubFetch({
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
erin: [{ id: '5', kind: 'host', slug: 'host_web01' }],
});
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
const hosts = await accessibleHosts(user, { fetchImpl });
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
assert.strictEqual(ldapCalled, false);
});
test('allHosts fetches the whole host inventory with no group filter', async () => {
@@ -80,16 +70,33 @@ test('allHosts fetches the whole host inventory with no group filter', async ()
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 () => {
// Only catalog content is a jump target. Discovery writes `discovery_sources`;
// promoting to the catalog sets `managed: true`. An unpromoted Proxmox VM was
// reaching the picker because the filter defaulted `managed`-less hosts to true.
test('drops auto-discovered hosts that were never promoted', async () => {
clearCache();
const user = { uid: 'frank', dn: 'd' };
const fetchImpl = stubFetch({
frank: [
{ id: '1', kind: 'host', slug: 'host_web01' }, // hand-made: no discovery_sources
{ id: '2', kind: 'host', slug: 'vm-101', metadata: { discovery_sources: ['proxmox'] } }, // discovered, unpromoted
{ id: '3', kind: 'host', slug: 'vm-102', metadata: { discovery_sources: ['proxmox'], managed: true } }, // promoted
{ id: '4', kind: 'host', slug: 'host_db', metadata: { discovery_sources: ['manual'] } }, // manual source counts as catalog
{ id: '5', kind: 'host', slug: 'host_off', metadata: { managed: false } }, // explicitly out
],
});
const hosts = await accessibleHosts(user, { fetchImpl });
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '3', '4']);
});
test('a bare-array response (envelope drift) returns empty list', async () => {
clearCache();
const user = { uid: 'dave', dn: 'd' };
// drift shape: a bare array instead of { results: [...] }. The shared client
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
// good group alongside still yields its hosts.
const fetchImpl = async (url) => {
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
// throws DirectoryEnvelopeViolation; access.js must catch + continue.
const fetchImpl = async () => {
return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
};
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
const hosts = await accessibleHosts(user, { fetchImpl });
assert.deepStrictEqual(hosts, []);
});
+34 -32
View File
@@ -17,14 +17,9 @@ if (conf.standalone && conf.standalone.enabled) {
// Which directory hosts may a user reach, and how do we dial them?
//
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
// /api/discovery/me only answers for the API token's own user, and /graph
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
// directly) with per-group resource lookups:
//
// 1. LDAP: groups the user's DN is a member of
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
// 3. union, keep kind === 'host'
// We use the SSO's machine-aware /api/discovery/access/:uid endpoint,
// which evaluates the user's groups server-side and returns their complete
// access projection in one call.
//
// Results are cached per-uid for a short TTL — the TUI picker and the
// username-grammar path share the cache. Dependency-injected fetch/ldap for
@@ -51,37 +46,44 @@ if (conf.standalone && conf.standalone.enabled) {
// 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');
//
// "In the catalog" is the same predicate the SSO's own Directory listing
// applies (sso-manager-node routes/api_directory_admin.js GET /resources):
// a resource that was auto-discovered and never promoted is NOT catalog
// content and must never be offered as a jump target. Discovery writes
// `metadata.discovery_sources`; promoting sets `metadata.managed = true`.
// Hosts created by hand carry no discovery_sources at all and stay in.
//
// The two copies of this rule have already drifted apart once (unpromoted
// Proxmox VMs showing up in the picker); if a third consumer needs it,
// hoist it into @simpleworkjs/directory-schema rather than copying again.
function isCatalogHost(r) {
if (!r || r.kind !== 'host') return false;
const meta = r.metadata || {};
if (meta.managed === true || meta.managed === 'true') return true;
if (meta.managed === false || meta.managed === 'false') return false;
const sources = meta.discovery_sources || [];
const autoDiscovered = sources.length > 0 && !sources.includes('manual');
return !autoDiscovered;
}
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
async function allHosts({ fetchImpl = fetch } = {}) {
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
return resources.filter(isCatalogHost);
}
async function accessibleHosts(user, { fetchImpl = fetch } = {}) {
const hit = cache.get(user.uid);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
// 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) {
let resources;
try {
resources = await fetchResourcesByGroup(cn, { fetchImpl });
} catch (error) {
// One bad group must not hide the rest; the SSO being down
// surfaces as an empty list + log line, not a crash.
console.error(`[access] ${error.message}`);
continue;
}
for (const r of resources) {
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
}
let resources = [];
try {
resources = await directoryClient({ fetchImpl }).getAccess(user.uid);
} catch (error) {
console.error(`[access] ${error.message}`);
}
const hosts = [...seen.values()];
const hosts = resources.filter(isCatalogHost);
cache.set(user.uid, { at: Date.now(), hosts });
return hosts;
}
+44
View File
@@ -0,0 +1,44 @@
'use strict';
const conf = require('@simpleworkjs/conf');
/**
* Requests a signed SSH certificate from the SSO Manager's OpenBao/Vault proxy.
*
* @param {string} publicKey - The jump host's public key (e.g. 'ssh-rsa AAAAB3...')
* @param {string} targetUid - The username the cert should be valid for
* @returns {Promise<string>} - The signed SSH certificate
*/
async function getSignedCert(publicKey, targetUid) {
const sso = conf.sso || {};
const pkiConfig = conf.ssh?.pki || {};
const vaultRole = pkiConfig.role || 'jump-host-role';
const endpoint = `${sso.url}/api/vault/ssh/sign/${vaultRole}`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${sso.apiToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
public_key: publicKey,
valid_principals: targetUid
})
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`Failed to sign SSH cert (status ${response.status}): ${errText}`);
}
const data = await response.json();
if (!data.data || !data.data.signed_key) {
throw new Error('Vault response missing signed_key');
}
return data.data.signed_key;
}
module.exports = { getSignedCert };
+25
View File
@@ -0,0 +1,25 @@
<%- include('top') %>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 text-center">
<div class="mb-4">
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
</div>
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
<p class="text-muted mb-4">
<% if (error.status === 404) { %>
The page you are looking for doesn't exist or has been moved.
<% } else { %>
An unexpected error occurred. Please try again later.
<% } %>
</p>
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
<i class="fa-solid fa-house me-2"></i>Return to Home
</a>
</div>
</div>
</div>
<%- include('bottom') %>
+1 -1
View File
@@ -90,7 +90,7 @@
<hr />
<div class="d-grid">
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
<i class="fa-solid fa-id-badge"></i> Log in with SSO
<i class="fa-solid fa-id-badge"></i> Log in with Jump
</a>
</div>
<% } %>
+1 -1
View File
@@ -49,7 +49,7 @@
</ul>
<div class="form-inline mt-2 mt-md-0">
<% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<% } else { %>