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:
@@ -98,6 +98,21 @@ module.exports = {
|
|||||||
maxEvents: 50000,
|
maxEvents: 50000,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Standalone mode: run without LDAP or SSO Manager. When enabled, user
|
||||||
|
// authentication and host discovery use @simpleworkjs/orm-backed stores
|
||||||
|
// (Sequelize, defaulting to SQLite) instead of the directory services.
|
||||||
|
standalone: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ORM config for standalone mode. Passed through to Sequelize — any dialect
|
||||||
|
// works. Defaults to SQLite for zero-dependency local dev.
|
||||||
|
orm: {
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: './data/standalone.sqlite',
|
||||||
|
logging: false,
|
||||||
|
},
|
||||||
|
|
||||||
// Orchestrator-only keys (ignored by the app, read by theta-env).
|
// Orchestrator-only keys (ignored by the app, read by theta-env).
|
||||||
stack: {},
|
stack: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,4 +4,12 @@ module.exports = {
|
|||||||
ssh: {
|
ssh: {
|
||||||
hostKeyPath: './data/keys',
|
hostKeyPath: './data/keys',
|
||||||
},
|
},
|
||||||
|
standalone: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
orm: {
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: './data/standalone.sqlite',
|
||||||
|
logging: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+15
-1
@@ -49,4 +49,18 @@ module.exports.authRouter = oidcClient.router;
|
|||||||
require('./audit_event');
|
require('./audit_event');
|
||||||
|
|
||||||
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
||||||
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
|
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
|
||||||
|
|
||||||
|
// Standalone mode: initialize @simpleworkjs/orm for local user/host stores.
|
||||||
|
// The ORM must be loaded before any code calls user_ldap or access — both of
|
||||||
|
// which check conf.standalone.enabled at require time and may delegate to the
|
||||||
|
// ORM-backed wrappers. Model registration is synchronous; table sync is async
|
||||||
|
// but the first query will implicitly wait (Sequelize.sync is in-flight).
|
||||||
|
// Export the promise so integration tests can await it before seeding data.
|
||||||
|
let ormReady = Promise.resolve();
|
||||||
|
if (conf.standalone && conf.standalone.enabled) {
|
||||||
|
const { init } = require('@simpleworkjs/orm');
|
||||||
|
const ormConf = conf.orm || { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false };
|
||||||
|
ormReady = init({ conf: { orm: ormConf }, models: [require('./standalone_user'), require('./standalone_host')] });
|
||||||
|
}
|
||||||
|
module.exports.ormReady = ormReady;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ORM model for standalone-mode hosts. Stored in the configured SQL database
|
||||||
|
// (default SQLite) when conf.standalone.enabled is true. The hosts_file.js
|
||||||
|
// wrapper translates between this model and the accessibleHosts() interface
|
||||||
|
// that ssh_server.js expects.
|
||||||
|
|
||||||
|
const { Model, fields } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// Patch StringField.toSequelize() to pass through primaryKey (same fix as in
|
||||||
|
// standalone_user.js — see that file for details).
|
||||||
|
if (!fields.StringField.prototype.toSequelize.toString().includes('primaryKey')) {
|
||||||
|
const orig = fields.StringField.prototype.toSequelize;
|
||||||
|
fields.StringField.prototype.toSequelize = function () {
|
||||||
|
const def = orig.call(this);
|
||||||
|
if (this.primaryKey) def.primaryKey = true;
|
||||||
|
return def;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class StandaloneHost extends Model {
|
||||||
|
static fields = {
|
||||||
|
slug: { type: 'string', primaryKey: true },
|
||||||
|
displayName: { type: 'string' },
|
||||||
|
kind: { type: 'string', default: 'host' },
|
||||||
|
metadata: { type: 'json', default: {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = StandaloneHost;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ORM model for standalone-mode users. Stored in the configured SQL database
|
||||||
|
// (default SQLite) when conf.standalone.enabled is true. The user_file.js
|
||||||
|
// wrapper translates between this model and the LDAP-client interface that
|
||||||
|
// ssh_server.js and key_inject.js expect.
|
||||||
|
|
||||||
|
const { Model, fields } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// Patch: StringField.toSequelize() and IntegerField.toSequelize() don't pass
|
||||||
|
// through primaryKey / autoIncrement (unlike UUIDField which does). Fix them
|
||||||
|
// so string and int primary keys work.
|
||||||
|
const origStringToSeq = fields.StringField.prototype.toSequelize;
|
||||||
|
fields.StringField.prototype.toSequelize = function () {
|
||||||
|
const def = origStringToSeq.call(this);
|
||||||
|
if (this.primaryKey) def.primaryKey = true;
|
||||||
|
return def;
|
||||||
|
};
|
||||||
|
const origIntToSeq = fields.IntegerField.prototype.toSequelize;
|
||||||
|
fields.IntegerField.prototype.toSequelize = function () {
|
||||||
|
const def = origIntToSeq.call(this);
|
||||||
|
if (this.primaryKey) def.primaryKey = true;
|
||||||
|
if (this.autoIncrement) def.autoIncrement = true;
|
||||||
|
return def;
|
||||||
|
};
|
||||||
|
|
||||||
|
class StandaloneUser extends Model {
|
||||||
|
static fields = {
|
||||||
|
uid: { type: 'string', primaryKey: true },
|
||||||
|
passwordHash: { type: 'string', isPrivate: true },
|
||||||
|
sshPublicKeys: { type: 'json', default: [] },
|
||||||
|
groups: { type: 'json', default: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = StandaloneUser;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ORM-backed user store for standalone mode. Implements the same interface as
|
||||||
|
// the @simpleworkjs/ldap client so ssh_server.js and key_inject.js work
|
||||||
|
// unchanged: getUser(uid), getGroups(dn), checkPassword(dn, pw), addSshKey(dn, keyLine).
|
||||||
|
//
|
||||||
|
// Users are stored via the StandaloneUser ORM model (Sequelize, any dialect).
|
||||||
|
// DNs are synthetic: uid=<uid>,ou=people,dc=standalone,dc=local — the real
|
||||||
|
// identity is the uid; the DN exists only for interface compatibility with
|
||||||
|
// callers that thread user.dn through to checkPassword / addSshKey.
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const StandaloneUser = require('./standalone_user');
|
||||||
|
|
||||||
|
const DN_PREFIX = 'uid=';
|
||||||
|
const DN_SUFFIX = ',ou=people,dc=standalone,dc=local';
|
||||||
|
|
||||||
|
function dnFor(uid) {
|
||||||
|
return `${DN_PREFIX}${uid}${DN_SUFFIX}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uidFromDn(dn) {
|
||||||
|
if (!dn || typeof dn !== 'string') return null;
|
||||||
|
const m = dn.match(/^uid=([^,]+)/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUser(uid) {
|
||||||
|
const user = await StandaloneUser.get(uid);
|
||||||
|
if (!user) return null;
|
||||||
|
return {
|
||||||
|
dn: dnFor(user.uid),
|
||||||
|
uid: user.uid,
|
||||||
|
sshPublicKeys: user.sshPublicKeys || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroups(dn) {
|
||||||
|
const uid = uidFromDn(dn);
|
||||||
|
if (!uid) return [];
|
||||||
|
const user = await StandaloneUser.get(uid);
|
||||||
|
if (!user) return [];
|
||||||
|
return user.groups || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPassword(dn, pw) {
|
||||||
|
const uid = uidFromDn(dn);
|
||||||
|
if (!uid) return false;
|
||||||
|
const user = await StandaloneUser.get(uid);
|
||||||
|
if (!user || !user.passwordHash) return false;
|
||||||
|
return bcrypt.compare(pw, user.passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSshKey(dn, keyLine) {
|
||||||
|
const uid = uidFromDn(dn);
|
||||||
|
if (!uid) return;
|
||||||
|
const user = await StandaloneUser.get(uid);
|
||||||
|
if (!user) return;
|
||||||
|
const keys = [...(user.sshPublicKeys || [])];
|
||||||
|
if (keys.includes(keyLine)) return; // idempotent
|
||||||
|
keys.push(keyLine);
|
||||||
|
await user.update({ sshPublicKeys: keys });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getUser, getGroups, checkPassword, addSshKey };
|
||||||
+17
-16
@@ -1,22 +1,23 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the
|
// User authentication backend — LDAP in production, ORM-backed file store in
|
||||||
// shared @simpleworkjs/ldap package:
|
// standalone mode. Both export the same interface:
|
||||||
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
|
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
|
||||||
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
|
// getGroups(dn) -> [cn, ...]
|
||||||
// checkPassword(dn, pw) -> bool (simple bind as the user)
|
// checkPassword(dn, pw) -> bool
|
||||||
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
|
// addSshKey(dn, keyLine) -> void (idempotent)
|
||||||
//
|
|
||||||
// Behavior is unchanged from the previous in-tree implementation: posixAccount
|
|
||||||
// user filter, groupOfNames group filter, bind-as-user password check,
|
|
||||||
// TypeOrValueExists treated as success on key add, and the same loose TLS
|
|
||||||
// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions).
|
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { createLdapClient } = require('@simpleworkjs/ldap');
|
|
||||||
|
|
||||||
const ldapConf = conf.ldap || {};
|
if (conf.standalone && conf.standalone.enabled) {
|
||||||
module.exports = createLdapClient({
|
// Standalone mode: use the ORM-backed user store.
|
||||||
...ldapConf,
|
module.exports = require('./user_file');
|
||||||
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
|
} else {
|
||||||
});
|
// Production mode: use the LDAP directory.
|
||||||
|
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||||
|
const ldapConf = conf.ldap || {};
|
||||||
|
module.exports = createLdapClient({
|
||||||
|
...ldapConf,
|
||||||
|
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+850
-1
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
|||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
|
|||||||
@@ -147,12 +147,20 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
|
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
|
||||||
|
|
||||||
async function runGrammar(session, client, state) {
|
async function runGrammar(session, client, state) {
|
||||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
|
// Register session listeners IMMEDIATELY — before any async work.
|
||||||
|
// The client sends exec/shell requests right after opening the session;
|
||||||
// Deferred upstream — attach the bridge NOW, resolve/reject after connect.
|
// if we await audit.create() first, those requests arrive before the
|
||||||
|
// listeners are registered and ssh2 rejects them with CHANNEL_FAILURE.
|
||||||
let resolveUp, rejectUp;
|
let resolveUp, rejectUp;
|
||||||
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
|
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 {
|
try {
|
||||||
const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
|
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';
|
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
|
||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
|
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
|
||||||
|
|||||||
@@ -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...');
|
||||||
|
});
|
||||||
+71
-57
@@ -1,70 +1,84 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Which directory hosts may a user reach, and how do we dial them?
|
// Host discovery — SSO Manager API in production, ORM-backed inventory in
|
||||||
//
|
// standalone mode. Both export the same interface:
|
||||||
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
// accessibleHosts(user) -> [host resources]
|
||||||
// /api/discovery/me only answers for the API token's own user, and /graph
|
// clearCache(uid?) -> void
|
||||||
// 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'
|
|
||||||
//
|
|
||||||
// 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
|
|
||||||
// unit testing.
|
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
|
||||||
const userLdap = require('../models/user_ldap');
|
|
||||||
|
|
||||||
const CACHE_TTL_MS = 30 * 1000;
|
if (conf.standalone && conf.standalone.enabled) {
|
||||||
const cache = new Map(); // uid -> {at, hosts}
|
// Standalone mode: use the ORM-backed host inventory.
|
||||||
|
const { accessibleHosts } = require('./hosts_file');
|
||||||
|
module.exports = { accessibleHosts, clearCache: () => {} };
|
||||||
|
} else {
|
||||||
|
// Production mode: LDAP groups + SSO API (unchanged).
|
||||||
|
|
||||||
// Build a directory client bound to conf.sso. fetchImpl is injectable so the
|
// Which directory hosts may a user reach, and how do we dial them?
|
||||||
// unit tests can stub the transport; the shared client validates the
|
//
|
||||||
// `{ results }` envelope on every call (turns the old bare-array drift into a
|
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
||||||
// thrown error instead of a silent `[]`).
|
// /api/discovery/me only answers for the API token's own user, and /graph
|
||||||
function directoryClient({ fetchImpl = fetch } = {}) {
|
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
||||||
const sso = conf.sso || {};
|
// directly) with per-group resource lookups:
|
||||||
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
|
//
|
||||||
}
|
// 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
|
||||||
|
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||||
|
// unit testing.
|
||||||
|
|
||||||
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
||||||
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
const userLdap = require('../models/user_ldap');
|
||||||
}
|
|
||||||
|
|
||||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
const CACHE_TTL_MS = 30 * 1000;
|
||||||
const hit = cache.get(user.uid);
|
const cache = new Map(); // uid -> {at, hosts}
|
||||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
|
||||||
|
|
||||||
const groups = await ldap.getGroups(user.dn);
|
// Build a directory client bound to conf.sso. fetchImpl is injectable so the
|
||||||
|
// unit tests can stub the transport; the shared client validates the
|
||||||
const seen = new Map();
|
// `{ results }` envelope on every call (turns the old bare-array drift into a
|
||||||
for (const cn of groups) {
|
// thrown error instead of a silent `[]`).
|
||||||
let resources;
|
function directoryClient({ fetchImpl = fetch } = {}) {
|
||||||
try {
|
const sso = conf.sso || {};
|
||||||
resources = await fetchResourcesByGroup(cn, { fetchImpl });
|
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: 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()];
|
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||||
cache.set(user.uid, { at: Date.now(), hosts });
|
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
||||||
return hosts;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function clearCache(uid) {
|
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
||||||
if (uid) cache.delete(uid);
|
const hit = cache.get(user.uid);
|
||||||
else cache.clear();
|
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
|
const 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hosts = [...seen.values()];
|
||||||
|
cache.set(user.uid, { at: Date.now(), hosts });
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache(uid) {
|
||||||
|
if (uid) cache.delete(uid);
|
||||||
|
else cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ORM-backed host inventory for standalone mode. Implements the same interface
|
||||||
|
// as utils/access.js so ssh_server.js works unchanged: accessibleHosts(user)
|
||||||
|
// returns an array of host resources the user may reach.
|
||||||
|
//
|
||||||
|
// In standalone mode all hosts in the inventory are accessible to every
|
||||||
|
// authenticated user — there is no group-based filtering. The _user parameter
|
||||||
|
// is accepted for interface compatibility but ignored.
|
||||||
|
|
||||||
|
const StandaloneHost = require('../models/standalone_host');
|
||||||
|
|
||||||
|
async function accessibleHosts(_user) {
|
||||||
|
const hosts = await StandaloneHost.list({ where: { kind: 'host' } });
|
||||||
|
// The ORM returns model instances; map to plain objects matching the shape
|
||||||
|
// that target_match.js and tui_picker.js expect.
|
||||||
|
return hosts.map((h) => ({
|
||||||
|
id: h.slug, // slug doubles as the stable id in standalone mode
|
||||||
|
kind: h.kind,
|
||||||
|
slug: h.slug,
|
||||||
|
displayName: h.displayName,
|
||||||
|
metadata: h.metadata || {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
// No cache in standalone mode — every call reads from the DB.
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { accessibleHosts, clearCache };
|
||||||
Reference in New Issue
Block a user