Add standalone mode with @simpleworkjs/orm-backed user/host stores

- Add standalone.enabled config flag to switch between LDAP+SSO and
  ORM-backed backends without changing the production code path
- New ORM models: StandaloneUser (uid, passwordHash, sshPublicKeys,
  groups) and StandaloneHost (slug, displayName, kind, metadata)
- user_file.js and hosts_file.js implement the same interfaces as the
  LDAP client and accessibleHosts() respectively
- models/user_ldap.js and utils/access.js become conditional facades
  that delegate based on conf.standalone.enabled at require time
- Zero changes to ssh_server.js core logic, bridge.js, key_inject.js,
  tui_picker.js, or any other consumer
- Fix ssh_server.js: use ?? instead of || for listenPort (0 is falsy)
- Fix ssh_server.js: register session listeners before awaiting
  audit.create() so client exec/shell requests aren't rejected
- Patch StringField.toSequelize() and IntegerField.toSequelize() to
  pass through primaryKey (the ORM's UUIDField already does this)
- 47 tests pass (24 existing + 15 new unit + 3 existing integration
  + 5 new standalone integration)
- Defaults to SQLite; any Sequelize dialect works via conf.orm

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-26 21:28:19 -04:00
parent da274a3ced
commit 4879769cc7
15 changed files with 1583 additions and 80 deletions
+13 -5
View File
@@ -147,12 +147,20 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
async function runGrammar(session, client, state) {
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
// Deferred upstream — attach the bridge NOW, resolve/reject after connect.
// Register session listeners IMMEDIATELY — before any async work.
// The client sends exec/shell requests right after opening the session;
// if we await audit.create() first, those requests arrive before the
// listeners are registered and ssh2 rejects them with CHANNEL_FAILURE.
let resolveUp, rejectUp;
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
attachSession(session, upstreamPromise, record);
const dummyAudit = { patch() {}, finish() {}, event: {} };
attachSession(session, upstreamPromise, dummyAudit);
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
// Wire the real audit record into the already-attached session.
dummyAudit.patch = (...a) => record.patch(...a);
dummyAudit.finish = (...a) => record.finish(...a);
Object.defineProperty(dummyAudit, 'event', { get: () => record.event });
try {
const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
@@ -280,7 +288,7 @@ function start() {
}
);
const port = (conf.ssh && conf.ssh.listenPort) || 2222;
const port = (conf.ssh && conf.ssh.listenPort) ?? 2222;
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
server.listen(port, host, () => {
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);