Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ab12a61c | |||
| 0f6be51c35 | |||
| 463111dfd6 |
@@ -1,3 +1,6 @@
|
|||||||
|
## v1.16.1
|
||||||
|
- fix: remove missing DEPLOYMENT.md from Docker build context
|
||||||
|
|
||||||
## v1.16.0
|
## v1.16.0
|
||||||
- Added OpenBao PKI SSH Certificate Support
|
- Added OpenBao PKI SSH Certificate Support
|
||||||
- Fallback to LDAP Key injection
|
- Fallback to LDAP Key injection
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ COPY nodejs/utils ./utils
|
|||||||
COPY nodejs/views ./views
|
COPY nodejs/views ./views
|
||||||
COPY nodejs/public ./public
|
COPY nodejs/public ./public
|
||||||
|
|
||||||
COPY README.md CHANGELOG.md DEPLOYMENT.md /
|
COPY README.md CHANGELOG.md /
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.16.0",
|
"version": "1.16.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.16.0",
|
"version": "1.16.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.16.0",
|
"version": "1.16.1",
|
||||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function counter(onBytes) {
|
|||||||
// Connect the upstream ssh2.Client, retrying once after a short pause if the
|
// 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 attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
|
||||||
// first-time key injection).
|
// first-time key injection).
|
||||||
function connectUpstream({ host, port, username, privateKey, cert, onHostKey, uid, justInjected }) {
|
function connectUpstream({ host, port, username, privateKey, cert, onHostKey, uid, justInjected, expectedHostKeyFp }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let attempted = false;
|
let attempted = false;
|
||||||
const dial = (allowRetry) => {
|
const dial = (allowRetry) => {
|
||||||
@@ -48,7 +48,10 @@ function connectUpstream({ host, port, username, privateKey, cert, onHostKey, ui
|
|||||||
hostVerifier: (key) => {
|
hostVerifier: (key) => {
|
||||||
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
|
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
|
||||||
if (onHostKey) onHostKey(fp);
|
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.
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
host: endpoint.address, port: endpoint.port,
|
host: endpoint.address, port: endpoint.port,
|
||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||||
uid: state.uid, justInjected, onHostKey,
|
uid: state.uid, justInjected, onHostKey,
|
||||||
|
expectedHostKeyFp: host && host.metadata && host.metadata.sshHostKeyFp,
|
||||||
});
|
});
|
||||||
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
|
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
|
||||||
|
|
||||||
@@ -261,6 +262,7 @@ async function runTuiSession(session, client, state) {
|
|||||||
host: endpoint.address, port: endpoint.port,
|
host: endpoint.address, port: endpoint.port,
|
||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||||
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
||||||
|
expectedHostKeyFp: tui.host && tui.host.metadata && tui.host.metadata.sshHostKeyFp,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
||||||
|
|||||||
@@ -8,39 +8,33 @@ function stubLdap(groups) {
|
|||||||
return { getGroups: async () => groups };
|
return { getGroups: async () => groups };
|
||||||
}
|
}
|
||||||
|
|
||||||
function stubFetch(byGroup) {
|
function stubFetch(byUid) {
|
||||||
return async (url) => {
|
return async (url) => {
|
||||||
const cn = decodeURIComponent(url.split('group=')[1]);
|
const uid = url.split('/access/')[1];
|
||||||
return { ok: true, json: async () => ({ results: byGroup[cn] || [] }) };
|
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();
|
clearCache();
|
||||||
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
|
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
|
||||||
const fetchImpl = stubFetch({
|
const fetchImpl = stubFetch({
|
||||||
host_web01_access: [
|
alice: [
|
||||||
{ id: '1', kind: 'host', slug: 'host_web01' },
|
{ 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
|
{ 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']);
|
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();
|
clearCache();
|
||||||
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
|
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
|
||||||
const fetchImpl = async (url) => {
|
const fetchImpl = async () => ({ ok: false, status: 500 });
|
||||||
if (url.includes('bad')) return { ok: false, status: 500 };
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
return { ok: true, json: async () => ({ results: [{ id: '3', kind: 'host', slug: 'host_ok' }] }) };
|
assert.deepStrictEqual(hosts, []);
|
||||||
};
|
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['bad_access', 'good_access']) });
|
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['3']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('caches per uid', async () => {
|
test('caches per uid', async () => {
|
||||||
@@ -48,23 +42,19 @@ test('caches per uid', async () => {
|
|||||||
let calls = 0;
|
let calls = 0;
|
||||||
const user = { uid: 'cara', dn: 'd' };
|
const user = { uid: 'cara', dn: 'd' };
|
||||||
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
|
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
|
||||||
const ldap = { getGroups: async () => ['g1'] };
|
await accessibleHosts(user, { fetchImpl });
|
||||||
await accessibleHosts(user, { fetchImpl, ldap });
|
await accessibleHosts(user, { fetchImpl });
|
||||||
await accessibleHosts(user, { fetchImpl, ldap });
|
|
||||||
assert.strictEqual(calls, 1);
|
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();
|
clearCache();
|
||||||
let ldapCalled = false;
|
const user = { uid: 'erin' }; // no dn, no groups
|
||||||
const user = { uid: 'erin', groups: ['host_web01_access'] };
|
|
||||||
const fetchImpl = stubFetch({
|
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 });
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
|
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
||||||
assert.strictEqual(ldapCalled, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
||||||
@@ -80,16 +70,14 @@ test('allHosts fetches the whole host inventory with no group filter', async ()
|
|||||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
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 () => {
|
test('a bare-array response (envelope drift) returns empty list', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const user = { uid: 'dave', dn: 'd' };
|
const user = { uid: 'dave', dn: 'd' };
|
||||||
// drift shape: a bare array instead of { results: [...] }. The shared client
|
// drift shape: a bare array instead of { results: [...] }. The shared client
|
||||||
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
|
// throws DirectoryEnvelopeViolation; access.js must catch + continue.
|
||||||
// good group alongside still yields its hosts.
|
const fetchImpl = async () => {
|
||||||
const fetchImpl = async (url) => {
|
return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
||||||
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
|
||||||
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
|
|
||||||
};
|
};
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
|
assert.deepStrictEqual(hosts, []);
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-29
@@ -17,14 +17,9 @@ if (conf.standalone && conf.standalone.enabled) {
|
|||||||
|
|
||||||
// Which directory hosts may a user reach, and how do we dial them?
|
// 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
|
// We use the SSO's machine-aware /api/discovery/access/:uid endpoint,
|
||||||
// /api/discovery/me only answers for the API token's own user, and /graph
|
// which evaluates the user's groups server-side and returns their complete
|
||||||
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
// access projection in one call.
|
||||||
// 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'
|
|
||||||
//
|
//
|
||||||
// Results are cached per-uid for a short TTL — the TUI picker and the
|
// 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
|
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||||
@@ -56,32 +51,18 @@ if (conf.standalone && conf.standalone.enabled) {
|
|||||||
return resources.filter(r => r.kind === 'host');
|
return resources.filter(r => r.kind === 'host');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
async function accessibleHosts(user, { fetchImpl = fetch } = {}) {
|
||||||
const hit = cache.get(user.uid);
|
const hit = cache.get(user.uid);
|
||||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
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
|
let resources = [];
|
||||||
// look them up; the web UI already has the session's OIDC groups claim
|
try {
|
||||||
// and passes it directly, skipping a redundant LDAP round-trip.
|
resources = await directoryClient({ fetchImpl }).getAccess(user.uid);
|
||||||
const groups = user.groups || await ldap.getGroups(user.dn);
|
} catch (error) {
|
||||||
|
console.error(`[access] ${error.message}`);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hosts = [...seen.values()];
|
const hosts = resources.filter(r => r.kind === 'host');
|
||||||
cache.set(user.uid, { at: Date.now(), hosts });
|
cache.set(user.uid, { at: Date.now(), hosts });
|
||||||
return hosts;
|
return hosts;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user