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:
@@ -0,0 +1,232 @@
|
||||
'use strict';
|
||||
|
||||
// End-to-end standalone SSH test: a real downstream sshd, the full jump host
|
||||
// SSH server (ssh_server.js), and an SSH client. Authentication and host
|
||||
// discovery use the ORM-backed standalone stores (temp file SQLite).
|
||||
//
|
||||
// Follows the same hermetic pattern as ssh_bridge.test.js but exercises the
|
||||
// full stack: conf → ORM → user_ldap facade → ssh_server → bridge.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { Server, Client, utils } = require('ssh2');
|
||||
const bcrypt = require('bcrypt');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
// ── Conf must be set BEFORE any module that checks conf.standalone.enabled ──
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-standalone-'));
|
||||
const dbPath = path.join(tmpDir, 'test.sqlite');
|
||||
|
||||
conf.standalone = { enabled: true };
|
||||
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
|
||||
conf.ssh = {
|
||||
listenHost: '127.0.0.1',
|
||||
listenPort: 0,
|
||||
hostKeyPath: path.join(tmpDir, 'keys'),
|
||||
passwordAuth: 'all',
|
||||
keyComment: 'jump-host-test',
|
||||
defaultPort: 22,
|
||||
connectTimeoutMs: 5000,
|
||||
maxSessions: 10,
|
||||
};
|
||||
conf.redis = { prefix: 'jump_host_test_standalone_' };
|
||||
conf.audit = { maxEvents: 100 };
|
||||
|
||||
// ── Require models/index FIRST so it initializes the ORM exactly once.
|
||||
// This also registers the standalone models. We await ormReady before
|
||||
// seeding data, then start the SSH server. ──
|
||||
|
||||
const models = require('../../models');
|
||||
const StandaloneUser = require('../../models/standalone_user');
|
||||
const StandaloneHost = require('../../models/standalone_host');
|
||||
|
||||
let downstream, downstreamPort, jump, jumpPort;
|
||||
let testUserKey;
|
||||
|
||||
function startDownstream() {
|
||||
return new Promise((resolve) => {
|
||||
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
|
||||
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
|
||||
client.on('authentication', (ctx) => ctx.accept());
|
||||
client.on('ready', () => {
|
||||
client.on('session', (accept) => {
|
||||
const session = accept();
|
||||
session.on('pty', (a) => a && a());
|
||||
session.on('shell', (a) => {
|
||||
const ch = a();
|
||||
ch.write('downstream-shell-ready\n');
|
||||
ch.on('data', (d) => ch.write('echo:' + d));
|
||||
});
|
||||
session.on('exec', (a, r, info) => {
|
||||
const ch = a();
|
||||
ch.write(`ran:${info.command}`);
|
||||
ch.exit(0);
|
||||
ch.end();
|
||||
});
|
||||
session.on('subsystem', (a, r, info) => {
|
||||
if (info.name !== 'sftp') return r && r();
|
||||
const ch = a();
|
||||
ch.on('data', (d) => ch.write(Buffer.concat([Buffer.from('sftp:'), d])));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
srv.listen(0, '127.0.0.1', () => resolve(srv));
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
// 1. Start downstream.
|
||||
downstream = await startDownstream();
|
||||
downstreamPort = downstream.address().port;
|
||||
|
||||
// 2. Wait for the ORM to finish syncing tables (init was called by models/index
|
||||
// at require time — we just need the tables to exist before seeding).
|
||||
await models.ormReady;
|
||||
|
||||
// 3. Seed test data.
|
||||
const userKeyPair = utils.generateKeyPairSync('ed25519');
|
||||
testUserKey = userKeyPair.private;
|
||||
const userPubKey = utils.parseKey(userKeyPair.private);
|
||||
const userPubLine = `${userPubKey.type} ${userPubKey.getPublicSSH().toString('base64')} testuser@test`;
|
||||
|
||||
const passwordHash = await bcrypt.hash('testpass', 4);
|
||||
|
||||
await StandaloneUser.create({
|
||||
uid: 'testuser',
|
||||
passwordHash,
|
||||
sshPublicKeys: [userPubLine],
|
||||
groups: ['admin'],
|
||||
});
|
||||
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_test',
|
||||
displayName: 'Test Downstream',
|
||||
kind: 'host',
|
||||
metadata: { address: `ssh://127.0.0.1:${downstreamPort}`, ip: '127.0.0.1', sshPort: downstreamPort },
|
||||
});
|
||||
|
||||
// 4. Start the jump host SSH server.
|
||||
const sshServer = require('../../services/ssh_server');
|
||||
jump = sshServer.start();
|
||||
await new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const addr = jump.address();
|
||||
if (addr) { jumpPort = addr.port; resolve(); }
|
||||
else setTimeout(check, 10);
|
||||
};
|
||||
check();
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { downstream && downstream.close(); } catch (_) {}
|
||||
try { jump && jump.close(); } catch (_) {}
|
||||
try { models.redisClient.destroy(); } catch (_) {}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', () => {});
|
||||
|
||||
function connectJump(opts = {}) {
|
||||
const conn = new Client();
|
||||
const connectOpts = {
|
||||
host: '127.0.0.1',
|
||||
port: jumpPort,
|
||||
username: opts.username || 'testuser_-_host_test',
|
||||
...opts,
|
||||
};
|
||||
return {
|
||||
conn,
|
||||
ready: new Promise((res, rej) => {
|
||||
conn.on('ready', res).on('error', rej).connect(connectOpts);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
test('public key auth + grammar mode exec', async () => {
|
||||
const { conn, ready } = connectJump({ privateKey: testUserKey });
|
||||
await ready;
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
conn.exec('hello-world', (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = '';
|
||||
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /ran:hello-world/);
|
||||
});
|
||||
|
||||
test('public key auth + grammar mode shell', async () => {
|
||||
const { conn, ready } = connectJump({ privateKey: testUserKey });
|
||||
await ready;
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
conn.shell((err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = '';
|
||||
stream.on('data', (d) => {
|
||||
buf += d;
|
||||
if (buf.includes('echo:ping')) resolve(buf);
|
||||
});
|
||||
setTimeout(() => stream.write('ping'), 150);
|
||||
setTimeout(() => resolve(buf), 5000);
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /downstream-shell-ready/);
|
||||
assert.match(out, /echo:ping/);
|
||||
});
|
||||
|
||||
test('password auth + grammar mode exec', async () => {
|
||||
const { conn, ready } = connectJump({
|
||||
username: 'testuser_-_host_test',
|
||||
password: 'testpass',
|
||||
});
|
||||
await ready;
|
||||
const out = await new Promise((resolve, reject) => {
|
||||
conn.exec('pw-test', (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = '';
|
||||
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /ran:pw-test/);
|
||||
});
|
||||
|
||||
test('password auth denied with wrong password', async () => {
|
||||
const conn = new Client();
|
||||
const result = await new Promise((resolve) => {
|
||||
conn.on('ready', () => resolve('unexpected-ready'));
|
||||
conn.on('error', () => resolve('auth-failed'));
|
||||
conn.connect({
|
||||
host: '127.0.0.1', port: jumpPort,
|
||||
username: 'testuser_-_host_test',
|
||||
password: 'wrongpass',
|
||||
});
|
||||
});
|
||||
assert.strictEqual(result, 'auth-failed');
|
||||
});
|
||||
|
||||
test('unknown user rejected', async () => {
|
||||
const conn = new Client();
|
||||
const result = await new Promise((resolve) => {
|
||||
conn.on('ready', () => resolve('unexpected-ready'));
|
||||
conn.on('error', () => resolve('auth-failed'));
|
||||
conn.connect({
|
||||
host: '127.0.0.1', port: jumpPort,
|
||||
username: 'nobody_-_host_test',
|
||||
password: 'testpass',
|
||||
});
|
||||
});
|
||||
assert.strictEqual(result, 'auth-failed');
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
// Unit tests for the ORM-backed host inventory (utils/hosts_file.js).
|
||||
// Uses a temp file SQLite database — no external services needed.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const StandaloneHost = require('../../models/standalone_host');
|
||||
|
||||
let tmpDir;
|
||||
let hostsFile; // required after ORM init
|
||||
|
||||
before(async () => {
|
||||
// Unique temp DB so this test file doesn't collide with other ORM tests.
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-hostfile-'));
|
||||
const dbPath = path.join(tmpDir, 'test.sqlite');
|
||||
|
||||
conf.standalone = { enabled: true };
|
||||
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
|
||||
|
||||
await init({ conf: { orm: conf.orm }, models: [StandaloneHost] });
|
||||
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_web01',
|
||||
displayName: 'Web Server 01',
|
||||
kind: 'host',
|
||||
metadata: { address: 'ssh://10.0.0.10:22', ip: '10.0.0.10', sshPort: 22 },
|
||||
});
|
||||
await StandaloneHost.create({
|
||||
slug: 'host_db',
|
||||
displayName: 'Database Server',
|
||||
kind: 'host',
|
||||
metadata: { address: 'ssh://10.0.0.20:22', ip: '10.0.0.20', sshPort: 22 },
|
||||
});
|
||||
await StandaloneHost.create({
|
||||
slug: 'app_gitea',
|
||||
displayName: 'Gitea',
|
||||
kind: 'service',
|
||||
metadata: { url: 'https://gitea.internal' },
|
||||
});
|
||||
|
||||
hostsFile = require('../../utils/hosts_file');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
test('accessibleHosts returns all hosts', async () => {
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
assert.strictEqual(hosts.length, 2);
|
||||
const slugs = hosts.map((h) => h.slug).sort();
|
||||
assert.deepStrictEqual(slugs, ['host_db', 'host_web01']);
|
||||
});
|
||||
|
||||
test('accessibleHosts filters to kind=host', async () => {
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
const kinds = [...new Set(hosts.map((h) => h.kind))];
|
||||
assert.deepStrictEqual(kinds, ['host']);
|
||||
});
|
||||
|
||||
test('accessibleHosts returns host resources with expected shape', async () => {
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
const web = hosts.find((h) => h.slug === 'host_web01');
|
||||
assert.ok(web);
|
||||
assert.strictEqual(web.id, 'host_web01');
|
||||
assert.strictEqual(web.displayName, 'Web Server 01');
|
||||
assert.strictEqual(web.metadata.ip, '10.0.0.10');
|
||||
assert.strictEqual(web.metadata.sshPort, 22);
|
||||
});
|
||||
|
||||
test('accessibleHosts returns empty array when no hosts exist', async () => {
|
||||
// Delete all hosts and verify empty result.
|
||||
const all = await StandaloneHost.list();
|
||||
for (const h of all) {
|
||||
await h.delete({ force: true });
|
||||
}
|
||||
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
|
||||
assert.deepStrictEqual(hosts, []);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
// Unit tests for the ORM-backed user store (models/user_file.js).
|
||||
// Uses a temp file SQLite database — no external services needed.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const bcrypt = require('bcrypt');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const StandaloneUser = require('../../models/standalone_user');
|
||||
|
||||
let testPasswordHash;
|
||||
let tmpDir;
|
||||
let userFile; // required after ORM init
|
||||
|
||||
before(async () => {
|
||||
// Unique temp DB so this test file doesn't collide with other ORM tests.
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-userfile-'));
|
||||
const dbPath = path.join(tmpDir, 'test.sqlite');
|
||||
|
||||
conf.standalone = { enabled: true };
|
||||
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
|
||||
|
||||
await init({ conf: { orm: conf.orm }, models: [StandaloneUser] });
|
||||
|
||||
testPasswordHash = await bcrypt.hash('testpass', 4);
|
||||
|
||||
await StandaloneUser.create({
|
||||
uid: 'alice',
|
||||
passwordHash: testPasswordHash,
|
||||
sshPublicKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop'],
|
||||
groups: ['admin', 'developers'],
|
||||
});
|
||||
|
||||
// Now that the ORM is initialized and conf.standalone is set, require the
|
||||
// facade. It checks conf.standalone.enabled at require time.
|
||||
userFile = require('../../models/user_file');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
||||
});
|
||||
|
||||
test('getUser returns user with synthesized dn and keys', async () => {
|
||||
const user = await userFile.getUser('alice');
|
||||
assert.ok(user);
|
||||
assert.strictEqual(user.uid, 'alice');
|
||||
assert.strictEqual(user.dn, 'uid=alice,ou=people,dc=standalone,dc=local');
|
||||
assert.deepStrictEqual(user.sshPublicKeys, ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop']);
|
||||
});
|
||||
|
||||
test('getUser returns null for unknown uid', async () => {
|
||||
const user = await userFile.getUser('nobody');
|
||||
assert.strictEqual(user, null);
|
||||
});
|
||||
|
||||
test('getGroups returns user groups', async () => {
|
||||
const groups = await userFile.getGroups('uid=alice,ou=people,dc=standalone,dc=local');
|
||||
assert.deepStrictEqual(groups, ['admin', 'developers']);
|
||||
});
|
||||
|
||||
test('getGroups returns empty array for unknown dn', async () => {
|
||||
const groups = await userFile.getGroups('uid=nobody,ou=people,dc=standalone,dc=local');
|
||||
assert.deepStrictEqual(groups, []);
|
||||
});
|
||||
|
||||
test('getGroups returns empty array for malformed dn', async () => {
|
||||
const groups = await userFile.getGroups('not-a-dn');
|
||||
assert.deepStrictEqual(groups, []);
|
||||
});
|
||||
|
||||
test('checkPassword returns true for correct password', async () => {
|
||||
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'testpass');
|
||||
assert.strictEqual(ok, true);
|
||||
});
|
||||
|
||||
test('checkPassword returns false for wrong password', async () => {
|
||||
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'wrongpass');
|
||||
assert.strictEqual(ok, false);
|
||||
});
|
||||
|
||||
test('checkPassword returns false for unknown user', async () => {
|
||||
const ok = await userFile.checkPassword('uid=nobody,ou=people,dc=standalone,dc=local', 'testpass');
|
||||
assert.strictEqual(ok, false);
|
||||
});
|
||||
|
||||
test('addSshKey appends a new key', async () => {
|
||||
const newKey = 'ssh-rsa AAAAB3NzaC1yc2E... bob@desktop';
|
||||
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', newKey);
|
||||
|
||||
const user = await StandaloneUser.get('alice');
|
||||
assert.ok(user.sshPublicKeys.includes(newKey));
|
||||
});
|
||||
|
||||
test('addSshKey is idempotent', async () => {
|
||||
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop';
|
||||
const userBefore = await StandaloneUser.get('alice');
|
||||
const countBefore = userBefore.sshPublicKeys.length;
|
||||
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', key);
|
||||
const userAfter = await StandaloneUser.get('alice');
|
||||
assert.strictEqual(userAfter.sshPublicKeys.length, countBefore);
|
||||
});
|
||||
|
||||
test('addSshKey is a no-op for unknown user', async () => {
|
||||
// Should not throw.
|
||||
await userFile.addSshKey('uid=nobody,ou=people,dc=standalone,dc=local', 'ssh-rsa AAA...');
|
||||
});
|
||||
Reference in New Issue
Block a user