Files
wmantly 36e9d5b0b3 feat: initial jump-host — SSH jump host for the theta42 stack
An SSH jump host that authenticates users against the shared LDAP
directory, authorizes them from the SSO Manager's inventory graph, and
bridges them to downstream hosts — auditing everything.

- Username-grammar routing (uid_-_target@jump) + interactive TUI picker
- Inbound LDAP auth (publickey / password with off|local|all policy)
- Directory-driven access (LDAP groups x /api/discovery/resources?group=)
- Per-user key injection into sshPublicKey, connects downstream as the user
- Shell / exec / SFTP-subsystem bridging (WinSCP works)
- Web UI + HTTP API (:3002) for audit + metrics; LDAP-admin gated
- Packaged like proxy: ops/install.sh + systemd, all-in-one Docker, compose
- Tests: 23 unit + 3 integration (node --test), all green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:48:49 -04:00

103 lines
3.0 KiB
JavaScript

'use strict';
// Audit trail of every connection attempt/session. Stored as one redis hash
// per event plus a sorted-set index (score = timestamp) for paged reads,
// trimmed to conf.audit.maxEvents. Raw redis (not the Table model) because we
// want the zset index and cheap range reads.
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const { getRedis } = require('./index');
const P = () => conf.redis.prefix;
const idxKey = () => `${P()}audit_index`;
const evtKey = (id) => `${P()}audit_${id}`;
// A live event: create() returns a handle you finish() when the session ends.
async function create(fields) {
const id = crypto.randomUUID();
const ts = Date.now();
const event = {
id, ts,
uid: '', authMethod: '', mode: '',
targetSlug: '', targetAddr: '', targetPort: '',
channel: '', clientIp: '',
success: false, failReason: '',
hostKeyFp: '', startedAt: ts, endedAt: '', durationMs: '',
bytesIn: 0, bytesOut: 0,
...fields,
};
await write(event);
return {
id,
event,
async patch(update) {
Object.assign(event, update);
await write(event);
},
async finish(update = {}) {
Object.assign(event, update, {
endedAt: Date.now(),
durationMs: Date.now() - event.startedAt,
});
await write(event);
},
};
}
async function write(event) {
const redis = await getRedis();
await redis.hSet(evtKey(event.id), serialize(event));
await redis.zAdd(idxKey(), { score: event.ts, value: event.id });
// Trim oldest beyond the cap.
const max = (conf.audit && conf.audit.maxEvents) || 50000;
const count = await redis.zCard(idxKey());
if (count > max) {
const stale = await redis.zRange(idxKey(), 0, count - max - 1);
if (stale.length) {
await redis.zRem(idxKey(), stale);
await redis.del(stale.map(evtKey));
}
}
}
function serialize(event) {
const out = {};
for (const [k, v] of Object.entries(event)) {
out[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v == null ? '' : v);
}
return out;
}
function deserialize(h) {
if (!h || !h.id) return null;
return {
...h,
ts: Number(h.ts),
success: h.success === '1',
bytesIn: Number(h.bytesIn || 0),
bytesOut: Number(h.bytesOut || 0),
durationMs: h.durationMs === '' ? null : Number(h.durationMs),
};
}
// Newest-first paged read with optional filters.
async function list({ page = 0, pageSize = 50, uid, target, status } = {}) {
const redis = await getRedis();
const ids = await redis.zRange(idxKey(), 0, -1, { REV: true });
const events = [];
for (const id of ids) {
const e = deserialize(await redis.hGetAll(evtKey(id)));
if (!e) continue;
if (uid && e.uid !== uid) continue;
if (target && e.targetSlug !== target && e.targetAddr !== target) continue;
if (status === 'success' && !e.success) continue;
if (status === 'fail' && e.success) continue;
events.push(e);
}
const start = page * pageSize;
return { total: events.length, page, pageSize, results: events.slice(start, start + pageSize) };
}
module.exports = { create, list };