Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39db290265 | |||
| 964ca6dd02 | |||
| 4e388a411e | |||
| e9310a1e5c | |||
| f70419bbc4 | |||
| 358231532f | |||
| d8bd338814 | |||
| 0a3bdbef06 | |||
| 611f1a3318 | |||
| e313697bfd | |||
| 2c3ec4e967 |
@@ -1,3 +1,11 @@
|
||||
# v2.6.0 - 2026-08-11
|
||||
|
||||
### Fixed
|
||||
- **Duplicate access/admin groups on repeated resource promotion.** Three independent copies of the same bug (`routes/discovery.js`'s `POST /discovery/promote/:slug` -- the actual "Promote" button in the UI -- and `services/discovery_reconciler.js`'s `autoPromote` path both called `ResourceGroup.create()` directly with no existence check, unlike `routes/api_directory_admin.js`'s own `ensureResourceGroup`, which already carried a comment describing this exact bug). A resource promoted more than once (a retried click, or the same LXC discovered from multiple Proxmox cluster nodes) silently accumulated duplicate rows every time. Consolidated into `ResourceGroup.ensure()` on the model, used everywhere.
|
||||
- **`GET /api/directory-admin/resources` ran a full LDAP group self-heal fan-out on every single list** (`ensureSiteGroups` per site + `provisionResourceGroups` per resource, each several sequential LDAP round-trips), unconditionally -- confirmed as the actual bottleneck once a directory has more than a handful of resources, not data volume. Moved healing to where resources actually change instead (`POST`/`PUT /resources`, `POST /discovery/promote/:slug` -- `PUT` had none at all before this), and added `POST /resources/heal-groups` as an explicit on-demand equivalent for backfilling a directory seeded before this change.
|
||||
- **An nmap discovery scan that completed successfully could be reported as a failed run with zero hosts found.** `node-nmap` (the vendored library) treats any stderr output from the nmap binary as fatal -- including nmap's own harmless RTT-calibration warning ("RTTVAR has grown to over N seconds..."), which it prints *during* a scan that goes on to complete normally, discarding valid results already sitting in the library's `rawData`. Our plugin now recognizes this specific benign message and manually completes the scan from the data that's already there; any other error still rejects as before.
|
||||
- **The Multi-Site modal's "Theta Gateways" count was measuring the wrong subsystem.** It counted this app's own unrelated WireGuard roaming-client/exit-node Resources, not jump-host's actual gateway-to-gateway mesh registry. New `utils/jump_client.js` (same self-service-token pattern as `utils/proxy_client.js`) queries jump-host's real `GET /api/mesh/gateways`, reporting a distinct "unknown" state instead of a misleading 0 when the integration isn't configured. Also added help links to the published multi-site/mesh docs on the modal.
|
||||
|
||||
# v2.5.0 - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const crypto = require('crypto');
|
||||
const { Model } = require('@simpleworkjs/orm');
|
||||
|
||||
const { Group } = require('./group_ldap');
|
||||
@@ -228,6 +229,18 @@ class ResourceGroup extends Model {
|
||||
groupCn: { type: 'string', isRequired: true },
|
||||
accessLevel: { type: 'string', isRequired: true }
|
||||
};
|
||||
|
||||
// No DB-level unique constraint on (resourceId, groupCn) exists, so callers
|
||||
// MUST check-then-create rather than relying on a constraint violation to
|
||||
// catch a dupe. A caller that skips this (raw ResourceGroup.create()) and
|
||||
// runs more than once for the same resource -- e.g. discovery reconciling
|
||||
// the same LXC from multiple Proxmox cluster nodes -- silently accumulates
|
||||
// duplicate access/admin rows every pass, with no error to notice it by.
|
||||
static async ensure(resourceId, groupCn, accessLevel) {
|
||||
const existing = await this.list({ where: { resourceId, groupCn } });
|
||||
if (existing.length) return existing[0];
|
||||
return this.create({ id: crypto.randomUUID(), resourceId, groupCn, accessLevel });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-theta-directory",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -11,7 +11,7 @@
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js tests/site_replicate.test.js tests/proxy_client.test.js --forceExit"
|
||||
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js tests/site_replicate.test.js tests/proxy_client.test.js tests/reconciler.test.js tests/nmap_plugin.test.js tests/jump_client.test.js --forceExit"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
|
||||
@@ -88,9 +88,28 @@ module.exports = {
|
||||
var msg = (error && error.message) || String(error);
|
||||
if (/nmap.*not found|command location/i.test(msg)) {
|
||||
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
|
||||
} else {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
// node-nmap (node_modules/node-nmap/index.js) treats ANY stderr
|
||||
// output from the nmap binary as a fatal scan error -- including
|
||||
// nmap's own benign RTT timing-calibration warnings ("RTTVAR has
|
||||
// grown to over N seconds, decreasing to M"), which it prints
|
||||
// *during* a scan that goes on to complete normally. That means a
|
||||
// scan that actually succeeded (valid XML already sitting in
|
||||
// scan.rawData) got thrown away and reported as a failed run with
|
||||
// zero hosts discovered -- not just a noisy log line. Recover by
|
||||
// manually re-running node-nmap's own XML-parse-then-complete path
|
||||
// (rawDataHandler -> scanComplete -> the 'complete' listener above)
|
||||
// when the "error" is this specific known-benign nmap message and
|
||||
// there's actually output to parse. A genuine XML parse failure
|
||||
// re-emits 'error' with a different message, which falls through to
|
||||
// reject() below same as before -- this only widens the recovery
|
||||
// path, it doesn't swallow real failures.
|
||||
if (/RTTVAR has grown/i.test(msg) && scan.rawData) {
|
||||
scan.rawDataHandler(scan.rawData);
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
scan.startScan();
|
||||
|
||||
@@ -11,6 +11,7 @@ const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||
const groups = require('../utils/groups');
|
||||
const meshReplicate = require('../utils/site_replicate');
|
||||
const jumpClient = require('../utils/jump_client');
|
||||
|
||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||
@@ -68,10 +69,10 @@ async function ensureGroup(name, ownerDn, description) {
|
||||
// naive create on every Directory self-heal (which runs ensureSiteGroups /
|
||||
// provisionResourceGroups on each load) was accumulating duplicate links -- the
|
||||
// "groups appear 3x under a resource" bug. Always check first.
|
||||
// (services/discovery_reconciler.js's autoPromote path had the same bug via
|
||||
// its own raw ResourceGroup.create() -- both now share ResourceGroup.ensure().)
|
||||
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
|
||||
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
|
||||
if (existing.length) return existing[0];
|
||||
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
|
||||
return ResourceGroup.ensure(resourceId, groupCn, accessLevel);
|
||||
}
|
||||
|
||||
// Provision the site-level groups + the aggregates the per-resource groups nest
|
||||
@@ -215,35 +216,17 @@ router.get('/resources', async (req, res, next) => {
|
||||
});
|
||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||
// the wire; projectResources strips it unconditionally.
|
||||
|
||||
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
|
||||
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
|
||||
// aggregates, and every host/app resource has its per-resource groups nested
|
||||
// into them. Idempotent, so this is a cheap no-op once present -- it's what
|
||||
// backfills a directory seeded by an older release without a rebuild.
|
||||
// Never fails the list.
|
||||
const sites = resources.filter(r => r.kind === 'site');
|
||||
await Promise.all(sites.map(site =>
|
||||
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
||||
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
|
||||
));
|
||||
const siteByResource = new Map();
|
||||
for (const site of sites) siteByResource.set(site.id, site.slug);
|
||||
const siteOf = async (r) => {
|
||||
const direct = siteByResource.get(r.id);
|
||||
if (direct) return direct;
|
||||
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
|
||||
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
|
||||
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
|
||||
};
|
||||
await Promise.all(resources.map(async (r) => {
|
||||
const gKind = groupKind(r);
|
||||
if (!gKind) return;
|
||||
const siteSlug = await siteOf(r);
|
||||
if (!siteSlug) return;
|
||||
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
|
||||
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||
}));
|
||||
//
|
||||
// Group-model self-heal (docs/GROUPS.md) used to run here, on every GET --
|
||||
// idempotent per-call, but the fan-out (ensureSiteGroups per site +
|
||||
// provisionResourceGroups per resource, each several sequential LDAP
|
||||
// round-trips) ran unconditionally on every single list, which is what
|
||||
// made this route slow/unresponsive once a directory had more than a
|
||||
// handful of resources. Healing now happens where resources actually
|
||||
// change instead: POST /resources, PUT /resources/:id (see below), and
|
||||
// POST /discovery/promote/:slug. See POST /resources/heal-groups for an
|
||||
// on-demand equivalent of what this GET used to do implicitly, for
|
||||
// backfilling a directory seeded before this change.
|
||||
|
||||
const projected = projectResources(resources, { fullMetadata: true }).map(r => {
|
||||
r.hasSecret = !!(r.metadata?.hasSecret || (r.metadata?.secretKeys && r.metadata.secretKeys.length > 0));
|
||||
@@ -290,6 +273,40 @@ router.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// On-demand equivalent of the group-model self-heal that GET /resources used
|
||||
// to run implicitly on every list (see the comment there). Same fan-out,
|
||||
// same idempotent ensure()-based helpers -- just explicit and admin-
|
||||
// triggered instead of hidden in every page load, for backfilling a
|
||||
// directory whose resources predate write-time healing.
|
||||
router.post('/resources/heal-groups', async (req, res, next) => {
|
||||
try {
|
||||
const resources = await Resource.list();
|
||||
const sites = resources.filter(r => r.kind === 'site');
|
||||
await Promise.all(sites.map(site =>
|
||||
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
||||
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
|
||||
));
|
||||
const siteByResource = new Map();
|
||||
for (const site of sites) siteByResource.set(site.id, site.slug);
|
||||
const siteOf = async (r) => {
|
||||
const direct = siteByResource.get(r.id);
|
||||
if (direct) return direct;
|
||||
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
|
||||
};
|
||||
let healed = 0;
|
||||
await Promise.all(resources.map(async (r) => {
|
||||
const gKind = groupKind(r);
|
||||
if (!gKind) return;
|
||||
const siteSlug = await siteOf(r);
|
||||
if (!siteSlug) return;
|
||||
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
|
||||
.then(() => { healed += 1; })
|
||||
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||
}));
|
||||
res.json({ status: 'ok', sitesHealed: sites.length, resourcesHealed: healed });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/resources', async (req, res, next) => {
|
||||
try {
|
||||
if (!req.body.hostId && req.body.parentSlug) {
|
||||
@@ -407,7 +424,24 @@ router.put('/resources/:id', async (req, res, next) => {
|
||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: updated.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Group provisioning (docs/GROUPS.md), same as POST /resources -- an
|
||||
// update can be what first makes a resource group-eligible (e.g. a
|
||||
// manual `metadata.managed` edit, or a reparent moving it under a
|
||||
// different site), and this route never provisioned groups at all
|
||||
// before. Never fails the update: groups are repairable via
|
||||
// POST /resources/heal-groups if this best-effort attempt fails.
|
||||
const gKind = groupKind(updated);
|
||||
if (gKind) {
|
||||
const ancestorSite = await Resource.findAncestorSiteSlug(updated.id).catch(() => null);
|
||||
if (ancestorSite) {
|
||||
await ensureSiteGroups(ancestorSite, req.user.dn, updated.name)
|
||||
.catch(err => console.error(`ensureSiteGroups(${ancestorSite}) failed:`, err.message));
|
||||
await provisionResourceGroups(updated, gKind, ancestorSite, req.user.dn)
|
||||
.catch(err => console.error(`provisionResourceGroups(${updated.slug}) failed:`, err.message));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results: updated });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -954,8 +988,6 @@ async function probeMasterHealth(cfg) {
|
||||
router.get('/site-status', async (req, res, next) => {
|
||||
try {
|
||||
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||
const allResources = await Resource.list();
|
||||
const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard');
|
||||
|
||||
const cfg = siteConfig.get();
|
||||
const wanConnected = await probeMasterHealth(cfg);
|
||||
@@ -970,6 +1002,12 @@ router.get('/site-status', async (req, res, next) => {
|
||||
// fully joined but silently stuck on the one-time snapshot, which was
|
||||
// otherwise invisible anywhere in the UI.
|
||||
const registeredSpokesCount = cfg.isMaster ? await SiteSpoke.list().then(l => l.length).catch(() => 0) : 0;
|
||||
// Real gateway-to-gateway mesh peer count from jump-host's own registry
|
||||
// (utils/jump_client.js), not this app's unrelated WireGuard
|
||||
// roaming-client Resources. count is null (not 0) when the query
|
||||
// couldn't run at all -- the UI distinguishes "0 gateways" from "can't
|
||||
// tell" instead of showing a misleading zero.
|
||||
const gateways = await jumpClient.getGatewayCount();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
config: {
|
||||
@@ -984,7 +1022,8 @@ router.get('/site-status', async (req, res, next) => {
|
||||
},
|
||||
sitesCount: sites.length,
|
||||
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
||||
gatewaysCount: gateResources.length
|
||||
gatewaysCount: gateways.count,
|
||||
gatewaysNote: gateways.note
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
@@ -169,20 +169,14 @@ router.post('/promote/:slug', async (req, res, next) => {
|
||||
else throw e;
|
||||
}
|
||||
|
||||
// Link them
|
||||
const crypto = require('crypto');
|
||||
await ResourceGroup.create({
|
||||
id: crypto.randomUUID(),
|
||||
resourceId: resource.id,
|
||||
groupCn: accessGroup,
|
||||
accessLevel: 'user'
|
||||
});
|
||||
await ResourceGroup.create({
|
||||
id: crypto.randomUUID(),
|
||||
resourceId: resource.id,
|
||||
groupCn: adminGroup,
|
||||
accessLevel: 'admin'
|
||||
});
|
||||
// Link them. ensure(), not create(): a re-submitted/retried Promote
|
||||
// click (or the modal being saved twice) had no existence check here,
|
||||
// so repeated promotion attempts on the same resource accumulated
|
||||
// duplicate access/admin group rows -- see ResourceGroup.ensure()'s
|
||||
// comment on models/resource.js for why this can't rely on a DB
|
||||
// constraint instead.
|
||||
await ResourceGroup.ensure(resource.id, accessGroup, 'user');
|
||||
await ResourceGroup.ensure(resource.id, adminGroup, 'admin');
|
||||
|
||||
const meta = resource.metadata || {};
|
||||
meta.managed = true;
|
||||
|
||||
@@ -320,8 +320,14 @@ class DiscoveryReconciler {
|
||||
await Group.get(adminGroup).catch(async (e) => {
|
||||
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${res.name}`, owner: 'cn=admin' });
|
||||
});
|
||||
await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: accessGroup, accessLevel: 'user' }).catch(() => {});
|
||||
await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: adminGroup, accessLevel: 'admin' }).catch(() => {});
|
||||
// ensure(), not create(): reconcile() runs on every discovery pass
|
||||
// (e.g. once per Proxmox cluster node reporting the same LXC), and
|
||||
// a raw create() here had no existence check, so a resource ended
|
||||
// up with the same access/admin group rows duplicated once per
|
||||
// pass -- see ResourceGroup.ensure()'s comment for why this can't
|
||||
// rely on a DB constraint instead.
|
||||
await ResourceGroup.ensure(res._actualId, accessGroup, 'user').catch(() => {});
|
||||
await ResourceGroup.ensure(res._actualId, adminGroup, 'admin').catch(() => {});
|
||||
} catch (err) {
|
||||
console.error(`[DiscoveryReconciler] autoPromote failed for ${res.slug}:`, err.message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
|
||||
let mockBaoStore = new Map();
|
||||
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||
get: jest.fn(async (path) => mockBaoStore.get(path) || null),
|
||||
set: jest.fn(async (path, value) => { mockBaoStore.set(path, value); })
|
||||
}));
|
||||
|
||||
describe('jump_client', () => {
|
||||
let jumpClient;
|
||||
let originalFetch;
|
||||
let mockFetchImpl;
|
||||
let calls;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
mockBaoStore = new Map();
|
||||
calls = [];
|
||||
mockFetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ status: 'ok', gateways: [] }) });
|
||||
originalFetch = global.fetch;
|
||||
global.fetch = (...args) => { calls.push(args); return mockFetchImpl(...args); };
|
||||
jumpClient = require('../utils/jump_client');
|
||||
jumpClient._reset();
|
||||
delete process.env.JUMP_INTERNAL_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test('reports null count (not zero) when JUMP_INTERNAL_URL is not configured', async () => {
|
||||
const result = await jumpClient.getGatewayCount();
|
||||
expect(result.count).toBeNull();
|
||||
expect(result.note).toMatch(/JUMP_INTERNAL_URL/);
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('reports null count when no token is stored in OpenBao', async () => {
|
||||
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||
const result = await jumpClient.getGatewayCount();
|
||||
expect(result.count).toBeNull();
|
||||
expect(result.note).toMatch(/no jump-host API token/);
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('returns the real gateway count on success', async () => {
|
||||
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
|
||||
mockFetchImpl = async () => ({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ status: 'ok', gateways: [{ siteSlug: '(self)' }, { siteSlug: 'site-b' }] })
|
||||
});
|
||||
|
||||
const result = await jumpClient.getGatewayCount();
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.note).toBe('ok');
|
||||
expect(calls[0][0]).toBe('http://jump-host.internal/api/mesh/gateways');
|
||||
expect(calls[0][1].headers.Authorization).toBe('Bearer jmp_test_token');
|
||||
});
|
||||
|
||||
test('reports null count on a non-2xx response', async () => {
|
||||
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
|
||||
mockFetchImpl = async () => ({ ok: false, status: 403 });
|
||||
|
||||
const result = await jumpClient.getGatewayCount();
|
||||
expect(result.count).toBeNull();
|
||||
expect(result.note).toMatch(/HTTP 403/);
|
||||
});
|
||||
|
||||
test('reports a network failure without throwing', async () => {
|
||||
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
|
||||
mockFetchImpl = async () => { throw new Error('connection refused'); };
|
||||
|
||||
const result = await jumpClient.getGatewayCount();
|
||||
expect(result.count).toBeNull();
|
||||
expect(result.note).toMatch(/failed: connection refused/);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ jest.mock('node-nmap', () => {
|
||||
this.targetRange = targetRange;
|
||||
this.customFlags = customFlags;
|
||||
this.command = ['-oX', '-', ...(customFlags || []), targetRange];
|
||||
this.rawData = '';
|
||||
}
|
||||
startScan() {
|
||||
setImmediate(() => {
|
||||
@@ -18,6 +19,15 @@ jest.mock('node-nmap', () => {
|
||||
]);
|
||||
});
|
||||
}
|
||||
// Real node-nmap's rawDataHandler XML-parses this.rawData then calls
|
||||
// this.scanComplete(results), which emits 'complete' -- the mock skips
|
||||
// straight to emitting the same shape so the RTTVAR-recovery test below
|
||||
// exercises the exact call our plugin code makes.
|
||||
rawDataHandler() {
|
||||
this.emit('complete', [
|
||||
{ ip: '192.168.1.20', hostname: 'host-20', openPorts: [] }
|
||||
]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
NmapScan: MockNmapScan,
|
||||
@@ -43,4 +53,42 @@ describe('nmap discovery plugin', () => {
|
||||
expect(result.resources[0].name).toBe('host-10');
|
||||
expect(result.edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('recovers a scan that completed despite nmap\'s benign RTTVAR stderr warning', async () => {
|
||||
// Regression: node-nmap treats ANY stderr output as fatal, including
|
||||
// nmap's own harmless RTT-calibration message -- which discards a scan
|
||||
// that actually succeeded. Simulate that by emitting 'error' with the
|
||||
// RTTVAR text instead of 'complete', with rawData present.
|
||||
const nmapModule = require('node-nmap');
|
||||
const originalStartScan = nmapModule.NmapScan.prototype.startScan;
|
||||
nmapModule.NmapScan.prototype.startScan = function () {
|
||||
this.rawData = '<nmaprun>...</nmaprun>';
|
||||
setImmediate(() => {
|
||||
this.emit('error', new Error('RTTVAR has grown to over 2.3 seconds, decreasing to 2.0'));
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await nmapPlugin.discover({ targetRange: '192.168.1.0/24' });
|
||||
expect(result.resources.some((r) => r.name === 'host-20')).toBe(true);
|
||||
} finally {
|
||||
nmapModule.NmapScan.prototype.startScan = originalStartScan;
|
||||
}
|
||||
});
|
||||
|
||||
test('still rejects a genuine error even when the message differs from RTTVAR', async () => {
|
||||
const nmapModule = require('node-nmap');
|
||||
const originalStartScan = nmapModule.NmapScan.prototype.startScan;
|
||||
nmapModule.NmapScan.prototype.startScan = function () {
|
||||
setImmediate(() => {
|
||||
this.emit('error', new Error('nmap: permission denied'));
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(nmapPlugin.discover({ targetRange: '192.168.1.0/24' })).rejects.toThrow('permission denied');
|
||||
} finally {
|
||||
nmapModule.NmapScan.prototype.startScan = originalStartScan;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
require('./setup');
|
||||
const { Resource } = require('../models/resource');
|
||||
const { Resource, ResourceGroup } = require('../models/resource');
|
||||
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||
|
||||
describe('DiscoveryReconciler', () => {
|
||||
@@ -72,4 +72,27 @@ describe('DiscoveryReconciler', () => {
|
||||
expect(merged.metadata.interfaces).toHaveLength(1);
|
||||
expect(merged.metadata.interfaces[0].ip).toBe('10.0.0.6'); // Updated IP
|
||||
});
|
||||
|
||||
it('does not duplicate access/admin groups across repeated autoPromote passes', async () => {
|
||||
// Regression: autoPromote used to call ResourceGroup.create() directly
|
||||
// with no existence check, so reconciling the same managed resource
|
||||
// more than once (e.g. a Proxmox cluster reporting one LXC from
|
||||
// multiple nodes) accumulated duplicate access/admin rows every pass.
|
||||
const payload = {
|
||||
resources: [{
|
||||
kind: 'host',
|
||||
name: 'LXC 127',
|
||||
slug: 'lxc-127',
|
||||
metadata: { interfaces: [{ mac: '00:11:22:33:44:99', ip: '10.0.0.99' }] }
|
||||
}]
|
||||
};
|
||||
|
||||
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
|
||||
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
|
||||
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
|
||||
|
||||
const resource = (await Resource.list()).find((r) => r.slug === 'lxc-127');
|
||||
const groups = await ResourceGroup.list({ where: { resourceId: resource.id } });
|
||||
expect(groups.map((g) => g.groupCn).sort()).toEqual(['lxc-127_access', 'lxc-127_admin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
// Service-to-service client for jump-host's mesh registry -- used by the
|
||||
// Directory's Multi-Site & Network Gateway Status modal to show the real
|
||||
// number of gateway-to-gateway WireGuard mesh peers (see MULTI_SITE_SPEC.md),
|
||||
// instead of counting the unrelated older WireGuard roaming-client/exit-node
|
||||
// Resources in this app's own catalog (a different subsystem entirely --
|
||||
// api_directory_admin.js used to filter Resource.list() for
|
||||
// metadata.subType === 'wireguard', which has nothing to do with the mesh).
|
||||
//
|
||||
// Same pattern as utils/proxy_client.js: reuses jump-host's existing
|
||||
// self-service API token system (models/api_token.js, `jmp_<id>_<secret>`
|
||||
// bearer tokens) rather than inventing a new credential type. The token must
|
||||
// be minted by a jump-admin user (GET /api/mesh/gateways requires
|
||||
// requireJumpAdmin, which checks the token's creator's username/groups, not
|
||||
// anything the token itself carries) and stored in OpenBao.
|
||||
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
|
||||
const PATH = 'integrations/theta-jump'; // baoConf adds the secret/data prefix
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
let cachedToken = null;
|
||||
|
||||
async function loadToken() {
|
||||
if (cachedToken) return cachedToken;
|
||||
let stored;
|
||||
try {
|
||||
stored = await baoConf.get(PATH);
|
||||
} catch (err) {
|
||||
console.error(`[jump_client] could not read ${PATH} from OpenBao: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
if (!stored || !stored.token) return null;
|
||||
cachedToken = stored.token;
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
function jumpBaseUrl() {
|
||||
// Not OpenBao -- this is where jump-host's admin API lives, not a secret.
|
||||
return process.env.JUMP_INTERNAL_URL || '';
|
||||
}
|
||||
|
||||
// Returns { count, note }. count is null (not 0) when the query couldn't run
|
||||
// at all (not configured, unreachable, unauthorized) -- the modal shows a
|
||||
// count of gateways it could actually see, not a misleading "0" that reads
|
||||
// as "you have no mesh peers" when the truth is "this isn't wired up yet".
|
||||
async function getGatewayCount() {
|
||||
const base = jumpBaseUrl();
|
||||
if (!base) {
|
||||
return { count: null, note: 'skipped: JUMP_INTERNAL_URL not configured' };
|
||||
}
|
||||
const token = await loadToken();
|
||||
if (!token) {
|
||||
return { count: null, note: `skipped: no jump-host API token at OpenBao ${PATH} -- mint one on jump-host (as a jump-admin user) and store it there` };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const resp = await fetch(base.replace(/\/+$/, '') + '/api/mesh/gateways', {
|
||||
headers: { Authorization: 'Bearer ' + token },
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!resp.ok) {
|
||||
return { count: null, note: `failed: HTTP ${resp.status}` };
|
||||
}
|
||||
const body = await resp.json();
|
||||
const gateways = Array.isArray(body.gateways) ? body.gateways : [];
|
||||
return { count: gateways.length, note: 'ok' };
|
||||
} catch (err) {
|
||||
return { count: null, note: `failed: ${err.message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Test seam.
|
||||
function _reset() { cachedToken = null; }
|
||||
|
||||
module.exports = { getGatewayCount, _reset, PATH };
|
||||
@@ -3332,7 +3332,11 @@
|
||||
'<span>' + (isMaster ? '👑 <strong>Master Site Node</strong>' : '⚡ <strong>Spoke Site Node</strong>') + '</span>' +
|
||||
'<span class="badge bg-' + (isMaster ? 'warning text-dark' : 'info text-dark') + '">' + esc(cfg.siteMode || 'master') + '</span>' +
|
||||
'</h5>' +
|
||||
'<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node.</p>' +
|
||||
'<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node. ' +
|
||||
'<a href="https://theta42.github.io/theta-suite/sso/multi-site.html" target="_blank" rel="noopener"><i class="fa-solid fa-book me-1"></i>Directory join docs</a>' +
|
||||
' · ' +
|
||||
'<a href="https://theta42.github.io/theta-suite/jump-host/mesh.html" target="_blank" rel="noopener"><i class="fa-solid fa-book me-1"></i>Gateway mesh docs</a>' +
|
||||
'</p>' +
|
||||
'<table class="table table-sm text-start mb-0">' +
|
||||
'<tr><th>Local Site Slug:</th><td><code>' + esc(cfg.siteSlug || 'site-default') + '</code></td></tr>' +
|
||||
'<tr><th>Master Authority URL:</th><td>' + (cfg.masterUrl ? ('<code>' + esc(cfg.masterUrl) + '</code>') : '<em>(This Node is Master)</em>') + '</td></tr>' +
|
||||
@@ -3344,7 +3348,9 @@
|
||||
: '<span class="badge bg-warning text-dark"><i class="fa-solid fa-triangle-exclamation me-1"></i> Snapshot only (re-join to register for live updates)</span>') + '</td></tr>' : '') +
|
||||
(isMaster ? '<tr><th>Registered Spokes:</th><td><span class="badge bg-success">' + (cfg.registeredSpokesCount || 0) + ' receiving live updates</span></td></tr>' : '') +
|
||||
'<tr><th>Registered Sites:</th><td><span class="badge bg-primary">' + (res.sitesCount || 0) + ' sites</span></td></tr>' +
|
||||
'<tr><th>Theta Gateways:</th><td><span class="badge bg-dark">' + (res.gatewaysCount || 0) + ' active gateways</span></td></tr>' +
|
||||
'<tr><th>Theta Gateways:</th><td>' + (res.gatewaysCount == null
|
||||
? '<span class="badge bg-secondary" title="' + esc(res.gatewaysNote || 'not configured') + '"><i class="fa-solid fa-question me-1"></i> Unknown (jump-host integration not configured)</span>'
|
||||
: '<span class="badge bg-dark">' + res.gatewaysCount + ' active gateway' + (res.gatewaysCount === 1 ? '' : 's') + '</span>') + '</td></tr>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
Reference in New Issue
Block a user