Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eef7852b69 | |||
| 0ac0c045ec | |||
| dfb819a715 | |||
| d486fb946b | |||
| 39db290265 | |||
| 964ca6dd02 | |||
| 4e388a411e | |||
| e9310a1e5c | |||
| f70419bbc4 | |||
| 358231532f | |||
| d8bd338814 | |||
| 0a3bdbef06 | |||
| 611f1a3318 | |||
| e313697bfd | |||
| 2c3ec4e967 | |||
| b6a82d58d5 | |||
| 18da6582ed | |||
| b1739ec965 | |||
| 7bc6f47070 | |||
| a0964ce350 | |||
| 0d3ee3e2ee | |||
| b95cb08c41 |
@@ -1,3 +1,23 @@
|
|||||||
|
# v2.7.0 - 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **OpenLDAP N-way multi-master replication auto-config.** `SiteSpoke.ldapServerId` is now auto-assigned at registration (next free from 2 upward, 1 reserved for the master -- same pattern as jump-host's mesh index), and each site's LDAP URL is derived from its already-known HTTP(S) endpoint rather than a separately-configured field. New `utils/ldap_replication.js`, `GET /api/site/ldap-peers` (spoke-facing, Bearer site join key) and `GET /directory-admin/ldap-replication-config` (master-local). Operators no longer hand-maintain `LDAP_SERVER_ID`/`LDAP_REPLICATION_HOSTS` for a `theta-suite`-joined cluster (see `theta-suite`'s `bootstrap/site-ldap-register.js`). Verified against real running containers (`docker-compose.multisite-e2e.yml`).
|
||||||
|
|
||||||
|
# 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
|
||||||
|
- **No-inbound relay automation.** A spoke with no public IP of its own can now register as such (`noInbound`/`meshIp`/`publicHost` on `POST /api/site/spokes`, forwarded through `POST /api/site/join` for the real operator join flow), and the master auto-creates/updates the relay route on its own `theta-proxy` via `utils/proxy_client.js` — a new self-service `prx_...` API token client, reusing `theta-proxy`'s existing token system rather than inventing a new credential type. Verified against a real running `theta-proxy` container (`GET /api/host/:item`'s actual `{item, results: {...}}` response shape, not the flat shape first assumed).
|
||||||
|
- **Replication traffic prefers the mesh.** `utils/site_replicate.js`'s fire-and-forget resync push now tries a registered spoke's `meshIp` first (falling back to its public `endpoint` on failure) — cross-component routing over the gateway-to-gateway WireGuard mesh instead of the open internet, for any spoke that's registered one.
|
||||||
|
- `POST /api/site/join` surfaces the resulting relay status in its response (`relay.note`), and `theta-suite`'s bootstrap flow (`CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST`, `bootstrap/site-relay-register.js`) drives all of this from the real operator-facing setup script, not just the API.
|
||||||
|
|
||||||
# v2.4.0 - 2026-08-10
|
# v2.4.0 - 2026-08-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+27
-8
@@ -23,32 +23,51 @@ In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
To enable replication, you must pass two environment variables to the `sso-manager` container:
|
The container's entrypoint reads two environment variables to configure this
|
||||||
|
-- `LDAP_SERVER_ID` (a unique integer for this node) and
|
||||||
|
`LDAP_REPLICATION_HOSTS` (a space-separated list of every **other** node's
|
||||||
|
LDAP URL) -- and, when both are set, automatically loads the `syncprov`
|
||||||
|
module, enables `mirrormode`, and generates the necessary `syncrepl` blocks
|
||||||
|
in `/etc/openldap/slapd.conf`.
|
||||||
|
|
||||||
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
|
**If you're using `theta-suite`'s `setup.sh`, you don't set these by hand.**
|
||||||
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
|
The master assigns each spoke a unique `LDAP_SERVER_ID` at join time (the
|
||||||
|
same way it assigns a WireGuard mesh index), and `LDAP_REPLICATION_HOSTS` is
|
||||||
|
derived automatically from every site's already-known HTTPS endpoint
|
||||||
|
(`ldaps://<same-host>:636`) -- see `GET /api/site/ldap-peers` (spoke) and
|
||||||
|
`GET /api/directory-admin/ldap-replication-config` (master), and
|
||||||
|
`theta-suite`'s `bootstrap/site-ldap-register.js`, which re-checks on every
|
||||||
|
`setup.sh` run since the peer list changes as new spokes join.
|
||||||
|
|
||||||
### Example using `theta-env` / Docker Compose
|
Setting the two env vars directly still works (e.g. a non-`theta-suite`
|
||||||
|
deployment) -- example using three manually-configured nodes:
|
||||||
|
|
||||||
**Site 1 (`setup.env` or `docker-compose.yml`)**
|
**Site 1**
|
||||||
```env
|
```env
|
||||||
LDAP_SERVER_ID=1
|
LDAP_SERVER_ID=1
|
||||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Site 2 (`setup.env` or `docker-compose.yml`)**
|
**Site 2**
|
||||||
```env
|
```env
|
||||||
LDAP_SERVER_ID=2
|
LDAP_SERVER_ID=2
|
||||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Site 3 (`setup.env` or `docker-compose.yml`)**
|
**Site 3**
|
||||||
```env
|
```env
|
||||||
LDAP_SERVER_ID=3
|
LDAP_SERVER_ID=3
|
||||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
||||||
```
|
```
|
||||||
|
|
||||||
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
|
**A known limitation of the automatic path**: the *master's* own
|
||||||
|
`LDAP_REPLICATION_HOSTS` only gets recomputed when its `setup.sh` is
|
||||||
|
re-run (or the operator re-applies it directly) -- there's no live push
|
||||||
|
telling the master's already-running container about a spoke that joined
|
||||||
|
five minutes ago. A spoke's own config, by contrast, is re-checked and
|
||||||
|
applied on every `setup.sh` run there, which is the common/recurring event.
|
||||||
|
Re-run `setup.sh` on the master after bringing up a new spoke to pick up the
|
||||||
|
new peer and restart replication with it.
|
||||||
|
|
||||||
## User Locations
|
## User Locations
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
const { Model } = require('@simpleworkjs/orm');
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
const { Group } = require('./group_ldap');
|
const { Group } = require('./group_ldap');
|
||||||
@@ -228,6 +229,18 @@ class ResourceGroup extends Model {
|
|||||||
groupCn: { type: 'string', isRequired: true },
|
groupCn: { type: 'string', isRequired: true },
|
||||||
accessLevel: { 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 = {
|
module.exports = {
|
||||||
|
|||||||
@@ -29,7 +29,25 @@ class SiteSpoke extends Model {
|
|||||||
siteSlug: { type: 'string' },
|
siteSlug: { type: 'string' },
|
||||||
pushToken: { type: 'string', isRequired: true },
|
pushToken: { type: 'string', isRequired: true },
|
||||||
created_on: { type: 'integer' },
|
created_on: { type: 'integer' },
|
||||||
last_seen_on: { type: 'integer' }
|
last_seen_on: { type: 'integer' },
|
||||||
|
// No-inbound relay (MULTI_SITE_SPEC.md): a spoke with no public IP of
|
||||||
|
// its own reports its WG mesh IP + the public hostname it wants
|
||||||
|
// reached at; the master then best-effort creates a matching relay
|
||||||
|
// route on its own theta-proxy (utils/proxy_client.js). relayNote
|
||||||
|
// records what happened for visibility in the UI -- this automation
|
||||||
|
// is optional/best-effort, never a join requirement.
|
||||||
|
noInbound: { type: 'boolean', default: false },
|
||||||
|
meshIp: { type: 'string' },
|
||||||
|
publicHost: { type: 'string' },
|
||||||
|
relayNote: { type: 'string' },
|
||||||
|
// OpenLDAP multi-master replication (docs/replication.md): a unique
|
||||||
|
// small integer this spoke's slapd.conf ServerID must use. Assigned
|
||||||
|
// once at registration (see api_site.js's nextFreeLdapServerId),
|
||||||
|
// reused on re-registration -- a spoke that re-registers after a
|
||||||
|
// restart must not get bumped to a new ID, same reasoning as
|
||||||
|
// jump-host's meshIndex. The master reserves 1 for itself, never
|
||||||
|
// assigned here.
|
||||||
|
ldapServerId: { type: 'integer' }
|
||||||
};
|
};
|
||||||
|
|
||||||
toPublic() {
|
toPublic() {
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-theta-directory",
|
"name": "t42-theta-directory",
|
||||||
"version": "2.4.0",
|
"version": "2.7.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./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 --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 tests/ldap_replication.test.js --forceExit"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
|
|||||||
@@ -88,9 +88,28 @@ module.exports = {
|
|||||||
var msg = (error && error.message) || String(error);
|
var msg = (error && error.message) || String(error);
|
||||||
if (/nmap.*not found|command location/i.test(msg)) {
|
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)'));
|
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
|
||||||
} else {
|
return;
|
||||||
reject(error);
|
|
||||||
}
|
}
|
||||||
|
// 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();
|
scan.startScan();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { projectResources } = require('@simpleworkjs/directory-schema');
|
|||||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||||
const groups = require('../utils/groups');
|
const groups = require('../utils/groups');
|
||||||
const meshReplicate = require('../utils/site_replicate');
|
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
|
// 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
|
// 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 /
|
// naive create on every Directory self-heal (which runs ensureSiteGroups /
|
||||||
// provisionResourceGroups on each load) was accumulating duplicate links -- the
|
// provisionResourceGroups on each load) was accumulating duplicate links -- the
|
||||||
// "groups appear 3x under a resource" bug. Always check first.
|
// "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) {
|
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
|
||||||
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
|
return ResourceGroup.ensure(resourceId, groupCn, accessLevel);
|
||||||
if (existing.length) return existing[0];
|
|
||||||
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provision the site-level groups + the aggregates the per-resource groups nest
|
// 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
|
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||||
// the wire; projectResources strips it unconditionally.
|
// the wire; projectResources strips it unconditionally.
|
||||||
|
//
|
||||||
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
|
// Group-model self-heal (docs/GROUPS.md) used to run here, on every GET --
|
||||||
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
|
// idempotent per-call, but the fan-out (ensureSiteGroups per site +
|
||||||
// aggregates, and every host/app resource has its per-resource groups nested
|
// provisionResourceGroups per resource, each several sequential LDAP
|
||||||
// into them. Idempotent, so this is a cheap no-op once present -- it's what
|
// round-trips) ran unconditionally on every single list, which is what
|
||||||
// backfills a directory seeded by an older release without a rebuild.
|
// made this route slow/unresponsive once a directory had more than a
|
||||||
// Never fails the list.
|
// handful of resources. Healing now happens where resources actually
|
||||||
const sites = resources.filter(r => r.kind === 'site');
|
// change instead: POST /resources, PUT /resources/:id (see below), and
|
||||||
await Promise.all(sites.map(site =>
|
// POST /discovery/promote/:slug. See POST /resources/heal-groups for an
|
||||||
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
// on-demand equivalent of what this GET used to do implicitly, for
|
||||||
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
|
// backfilling a directory seeded before this change.
|
||||||
));
|
|
||||||
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));
|
|
||||||
}));
|
|
||||||
|
|
||||||
const projected = projectResources(resources, { fullMetadata: true }).map(r => {
|
const projected = projectResources(resources, { fullMetadata: true }).map(r => {
|
||||||
r.hasSecret = !!(r.metadata?.hasSecret || (r.metadata?.secretKeys && r.metadata.secretKeys.length > 0));
|
r.hasSecret = !!(r.metadata?.hasSecret || (r.metadata?.secretKeys && r.metadata.secretKeys.length > 0));
|
||||||
@@ -290,6 +273,40 @@ router.use((req, res, next) => {
|
|||||||
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) => {
|
router.post('/resources', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!req.body.hostId && req.body.parentSlug) {
|
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' });
|
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 });
|
res.json({ results: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -928,6 +962,7 @@ const siteConfig = require('../utils/site_config');
|
|||||||
const { siteIsFresh } = require('../utils/site_join');
|
const { siteIsFresh } = require('../utils/site_join');
|
||||||
const { Agent } = require('../models/agent');
|
const { Agent } = require('../models/agent');
|
||||||
const { SiteSpoke } = require('../models/site_spoke');
|
const { SiteSpoke } = require('../models/site_spoke');
|
||||||
|
const { ldapHostFor } = require('../utils/ldap_replication');
|
||||||
|
|
||||||
// probeMasterHealth checks whether this (spoke) node can reach its master over
|
// probeMasterHealth checks whether this (spoke) node can reach its master over
|
||||||
// the site join key. The master's /api/site/ping is deliberately lightweight.
|
// the site join key. The master's /api/site/ping is deliberately lightweight.
|
||||||
@@ -954,8 +989,6 @@ async function probeMasterHealth(cfg) {
|
|||||||
router.get('/site-status', async (req, res, next) => {
|
router.get('/site-status', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const sites = await Resource.list({ where: { kind: 'site' } });
|
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 cfg = siteConfig.get();
|
||||||
const wanConnected = await probeMasterHealth(cfg);
|
const wanConnected = await probeMasterHealth(cfg);
|
||||||
@@ -970,6 +1003,12 @@ router.get('/site-status', async (req, res, next) => {
|
|||||||
// fully joined but silently stuck on the one-time snapshot, which was
|
// fully joined but silently stuck on the one-time snapshot, which was
|
||||||
// otherwise invisible anywhere in the UI.
|
// otherwise invisible anywhere in the UI.
|
||||||
const registeredSpokesCount = cfg.isMaster ? await SiteSpoke.list().then(l => l.length).catch(() => 0) : 0;
|
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({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
config: {
|
config: {
|
||||||
@@ -984,11 +1023,36 @@ router.get('/site-status', async (req, res, next) => {
|
|||||||
},
|
},
|
||||||
sitesCount: sites.length,
|
sitesCount: sites.length,
|
||||||
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
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); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OpenLDAP multi-master replication config for THIS node (docs/replication.md).
|
||||||
|
// Master-only: the master already has every registered spoke's info locally
|
||||||
|
// (SiteSpoke), so it can compute its own ServerID (always 1) + full peer
|
||||||
|
// list without an HTTP round-trip. A spoke gets its config from the master
|
||||||
|
// directly instead (GET /api/site/ldap-peers -- see bootstrap/
|
||||||
|
// site-ldap-register.js in theta-suite, which calls whichever of the two
|
||||||
|
// applies to this node's role).
|
||||||
|
router.get('/ldap-replication-config', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
if (!cfg.isMaster) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'this node is a spoke -- fetch replication config from the master via GET /api/site/ldap-peers instead' });
|
||||||
|
}
|
||||||
|
const spokes = await SiteSpoke.list();
|
||||||
|
const peers = [];
|
||||||
|
for (const s of spokes) {
|
||||||
|
if (!s.ldapServerId) continue;
|
||||||
|
const host = ldapHostFor(s.endpoint);
|
||||||
|
if (host) peers.push({ ldapServerId: s.ldapServerId, ldapHost: host });
|
||||||
|
}
|
||||||
|
res.json({ status: 'ok', ldapServerId: 1, peers });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/site-promote', async (req, res, next) => {
|
router.post('/site-promote', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
// god_admin privilege check. This used to read req.user.groups, which
|
// god_admin privilege check. This used to read req.user.groups, which
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ function logAudit(action, details) {
|
|||||||
console.log(JSON.stringify({ timestamp: new Date().toISOString(), component: 'site', action, ...details }));
|
console.log(JSON.stringify({ timestamp: new Date().toISOString(), component: 'site', action, ...details }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { nextFreeLdapServerId, ldapHostFor } = require('../utils/ldap_replication');
|
||||||
|
|
||||||
// slurpLdif dumps the local LDAP tree with slapcat (the sso-manager container
|
// slurpLdif dumps the local LDAP tree with slapcat (the sso-manager container
|
||||||
// carries an OpenLDAP build with slapcat on PATH).
|
// carries an OpenLDAP build with slapcat on PATH).
|
||||||
async function slurpLdif() {
|
async function slurpLdif() {
|
||||||
@@ -120,27 +122,85 @@ router.post('/spokes', async (req, res, next) => {
|
|||||||
const key = await SiteJoinKey.authenticate(rawKey);
|
const key = await SiteJoinKey.authenticate(rawKey);
|
||||||
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||||
|
|
||||||
const { endpoint, siteSlug } = req.body || {};
|
const { endpoint, siteSlug, noInbound, meshIp, publicHost } = req.body || {};
|
||||||
if (!endpoint || !/^https?:\/\//.test(endpoint)) {
|
if (!endpoint || !/^https?:\/\//.test(endpoint)) {
|
||||||
return res.status(400).json({ status: 'error', message: 'a valid http(s) endpoint is required' });
|
return res.status(400).json({ status: 'error', message: 'a valid http(s) endpoint is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
let spoke = (await SiteSpoke.list({ where: { endpoint } }))[0];
|
let spoke = (await SiteSpoke.list({ where: { endpoint } }))[0];
|
||||||
|
const patch = { siteSlug: siteSlug || (spoke && spoke.siteSlug) || null, last_seen_on: now, noInbound: !!noInbound, meshIp: meshIp || '', publicHost: publicHost || '' };
|
||||||
if (spoke) {
|
if (spoke) {
|
||||||
await spoke.update({ siteSlug: siteSlug || spoke.siteSlug, last_seen_on: now });
|
await spoke.update(patch);
|
||||||
} else {
|
} else {
|
||||||
spoke = await SiteSpoke.create({
|
spoke = await SiteSpoke.create({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
endpoint,
|
endpoint,
|
||||||
siteSlug: siteSlug || null,
|
|
||||||
pushToken: SiteSpoke.generatePushToken(),
|
pushToken: SiteSpoke.generatePushToken(),
|
||||||
created_on: now,
|
created_on: now,
|
||||||
last_seen_on: now
|
ldapServerId: await nextFreeLdapServerId(),
|
||||||
|
...patch
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
logAudit('spoke_registered', { endpoint, siteSlug: spoke.siteSlug });
|
|
||||||
res.json({ status: 'ok', pushToken: spoke.pushToken });
|
// No-inbound relay automation: best-effort, never blocks registration.
|
||||||
|
// See utils/proxy_client.js for why this reuses theta-proxy's existing
|
||||||
|
// API token system rather than a new credential type.
|
||||||
|
let relayNote = 'not applicable (spoke has inbound access)';
|
||||||
|
if (noInbound) {
|
||||||
|
if (meshIp && publicHost) {
|
||||||
|
const proxyClient = require('../utils/proxy_client');
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: publicHost, ip: meshIp, targetPort: 3001 });
|
||||||
|
relayNote = result.note;
|
||||||
|
} else {
|
||||||
|
relayNote = 'skipped: noInbound set but meshIp/publicHost missing';
|
||||||
|
}
|
||||||
|
await spoke.update({ relayNote });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit('spoke_registered', { endpoint, siteSlug: spoke.siteSlug, noInbound: !!noInbound, relayNote });
|
||||||
|
res.json({ status: 'ok', pushToken: spoke.pushToken, relay: { note: relayNote } });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── LDAP replication peer list (SPOKE-callable, Bearer site join key) ───────
|
||||||
|
// OpenLDAP multi-master replication (docs/replication.md) needs each site to
|
||||||
|
// know its own ServerID plus every OTHER site's LDAPS URL. The master
|
||||||
|
// coordinates ID assignment (nextFreeLdapServerId, above); this is how a
|
||||||
|
// spoke asks "what's my ID, and who are my peers" -- called by
|
||||||
|
// theta-suite's bootstrap/site-ldap-register.js on every setup.sh run, not
|
||||||
|
// just once at join time, since the peer list changes as other spokes join.
|
||||||
|
// Same join-key auth as /spokes (a spoke already has this stored from its
|
||||||
|
// own join). `endpoint` identifies the CALLER so it can be excluded from its
|
||||||
|
// own peer list -- same identity SiteSpoke.list() keys registration on.
|
||||||
|
router.get('/ldap-peers', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||||
|
const key = await SiteJoinKey.authenticate(rawKey);
|
||||||
|
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||||
|
|
||||||
|
const callerEndpoint = req.query.endpoint;
|
||||||
|
if (!callerEndpoint) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'endpoint query param is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
const masterHost = ldapHostFor(cfg.masterUrl || req.protocol + '://' + req.get('host'));
|
||||||
|
const spokes = await SiteSpoke.list();
|
||||||
|
const caller = spokes.find((s) => s.endpoint === callerEndpoint);
|
||||||
|
if (!caller || !caller.ldapServerId) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'this endpoint is not a registered spoke -- register via POST /api/site/spokes first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const peers = [{ ldapServerId: 1, ldapHost: masterHost }];
|
||||||
|
for (const s of spokes) {
|
||||||
|
if (s.endpoint === callerEndpoint || !s.ldapServerId) continue;
|
||||||
|
const host = ldapHostFor(s.endpoint);
|
||||||
|
if (host) peers.push({ ldapServerId: s.ldapServerId, ldapHost: host });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'ok', ldapServerId: caller.ldapServerId, peers });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -354,7 +414,7 @@ async function adoptFromMaster({ masterUrl, joinKey }) {
|
|||||||
|
|
||||||
router.post('/join', async (req, res, next) => {
|
router.post('/join', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { masterUrl, joinKey, selfUrl } = req.body || {};
|
const { masterUrl, joinKey, selfUrl, noInbound, meshIp, publicHost } = req.body || {};
|
||||||
if (!masterUrl || !joinKey) {
|
if (!masterUrl || !joinKey) {
|
||||||
return res.status(400).json({ status: 'error', message: 'masterUrl and joinKey are required' });
|
return res.status(400).json({ status: 'error', message: 'masterUrl and joinKey are required' });
|
||||||
}
|
}
|
||||||
@@ -388,17 +448,29 @@ router.post('/join', async (req, res, next) => {
|
|||||||
// snapshot for that spoke, not a hard failure).
|
// snapshot for that spoke, not a hard failure).
|
||||||
let replicationPushToken = null;
|
let replicationPushToken = null;
|
||||||
let replicationNote = 'not registered (no selfUrl given)';
|
let replicationNote = 'not registered (no selfUrl given)';
|
||||||
|
let relayNote = noInbound ? 'not attempted (registration did not run)' : 'not applicable (this spoke has inbound access)';
|
||||||
if (selfUrl) {
|
if (selfUrl) {
|
||||||
try {
|
try {
|
||||||
const regResp = await fetch(base + '/api/site/spokes', {
|
const regResp = await fetch(base + '/api/site/spokes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
|
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ endpoint: selfUrl, siteSlug: exportData.siteSlug || cfg.siteSlug })
|
// noInbound/meshIp/publicHost: this spoke has no public IP of its
|
||||||
|
// own; forwarded so the master can best-effort auto-create a relay
|
||||||
|
// route on its own theta-proxy (utils/proxy_client.js). Previously
|
||||||
|
// accepted by /spokes but never actually reachable from here --
|
||||||
|
// nothing forwarded them, so the automation existed but no real
|
||||||
|
// join flow could ever trigger it.
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: selfUrl,
|
||||||
|
siteSlug: exportData.siteSlug || cfg.siteSlug,
|
||||||
|
...(noInbound ? { noInbound: true, meshIp, publicHost } : {})
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (regResp.ok) {
|
if (regResp.ok) {
|
||||||
const regBody = await regResp.json();
|
const regBody = await regResp.json();
|
||||||
replicationPushToken = regBody.pushToken;
|
replicationPushToken = regBody.pushToken;
|
||||||
replicationNote = 'registered for live replication';
|
replicationNote = 'registered for live replication';
|
||||||
|
if (regBody.relay) relayNote = regBody.relay.note;
|
||||||
} else {
|
} else {
|
||||||
replicationNote = 'registration failed: HTTP ' + regResp.status;
|
replicationNote = 'registration failed: HTTP ' + regResp.status;
|
||||||
}
|
}
|
||||||
@@ -438,7 +510,8 @@ router.post('/join', async (req, res, next) => {
|
|||||||
resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount },
|
resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount },
|
||||||
ldap: { note: ldapNote },
|
ldap: { note: ldapNote },
|
||||||
signingKey: { note: signingKeyNote },
|
signingKey: { note: signingKeyNote },
|
||||||
replication: { note: replicationNote, live: !!replicationPushToken }
|
replication: { note: replicationNote, live: !!replicationPushToken },
|
||||||
|
relay: { note: relayNote }
|
||||||
});
|
});
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -169,20 +169,14 @@ router.post('/promote/:slug', async (req, res, next) => {
|
|||||||
else throw e;
|
else throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link them
|
// Link them. ensure(), not create(): a re-submitted/retried Promote
|
||||||
const crypto = require('crypto');
|
// click (or the modal being saved twice) had no existence check here,
|
||||||
await ResourceGroup.create({
|
// so repeated promotion attempts on the same resource accumulated
|
||||||
id: crypto.randomUUID(),
|
// duplicate access/admin group rows -- see ResourceGroup.ensure()'s
|
||||||
resourceId: resource.id,
|
// comment on models/resource.js for why this can't rely on a DB
|
||||||
groupCn: accessGroup,
|
// constraint instead.
|
||||||
accessLevel: 'user'
|
await ResourceGroup.ensure(resource.id, accessGroup, 'user');
|
||||||
});
|
await ResourceGroup.ensure(resource.id, adminGroup, 'admin');
|
||||||
await ResourceGroup.create({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
resourceId: resource.id,
|
|
||||||
groupCn: adminGroup,
|
|
||||||
accessLevel: 'admin'
|
|
||||||
});
|
|
||||||
|
|
||||||
const meta = resource.metadata || {};
|
const meta = resource.metadata || {};
|
||||||
meta.managed = true;
|
meta.managed = true;
|
||||||
|
|||||||
@@ -320,8 +320,14 @@ class DiscoveryReconciler {
|
|||||||
await Group.get(adminGroup).catch(async (e) => {
|
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' });
|
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(() => {});
|
// ensure(), not create(): reconcile() runs on every discovery pass
|
||||||
await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: adminGroup, accessLevel: 'admin' }).catch(() => {});
|
// (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) {
|
} catch (err) {
|
||||||
console.error(`[DiscoveryReconciler] autoPromote failed for ${res.slug}:`, err.message);
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
require('./setup');
|
||||||
|
const { SiteSpoke } = require('../models/site_spoke');
|
||||||
|
const { nextFreeLdapServerId, ldapHostFor } = require('../utils/ldap_replication');
|
||||||
|
|
||||||
|
describe('ldap_replication', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const all = await SiteSpoke.list();
|
||||||
|
for (const s of all) await s.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ldapHostFor', () => {
|
||||||
|
test('derives ldaps://<host>:636 from an http(s) endpoint, ignoring its own port', () => {
|
||||||
|
expect(ldapHostFor('https://sso.site2.example.com')).toBe('ldaps://sso.site2.example.com:636');
|
||||||
|
expect(ldapHostFor('https://sso.site2.example.com:8443')).toBe('ldaps://sso.site2.example.com:636');
|
||||||
|
expect(ldapHostFor('http://sso.site3.example.com')).toBe('ldaps://sso.site3.example.com:636');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for an unparseable endpoint', () => {
|
||||||
|
expect(ldapHostFor('not-a-url')).toBeNull();
|
||||||
|
expect(ldapHostFor('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextFreeLdapServerId', () => {
|
||||||
|
test('starts at 2 (1 is reserved for the master) when no spokes are registered', async () => {
|
||||||
|
await expect(nextFreeLdapServerId()).resolves.toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks the lowest free id, not just the next highest', async () => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await SiteSpoke.create({ id: 'a', endpoint: 'https://a.example.com', pushToken: 'tok-a', created_on: now, ldapServerId: 2 });
|
||||||
|
await SiteSpoke.create({ id: 'b', endpoint: 'https://b.example.com', pushToken: 'tok-b', created_on: now, ldapServerId: 4 });
|
||||||
|
|
||||||
|
await expect(nextFreeLdapServerId()).resolves.toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores spokes with no ldapServerId assigned yet', async () => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await SiteSpoke.create({ id: 'c', endpoint: 'https://c.example.com', pushToken: 'tok-c', created_on: now });
|
||||||
|
|
||||||
|
await expect(nextFreeLdapServerId()).resolves.toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ jest.mock('node-nmap', () => {
|
|||||||
this.targetRange = targetRange;
|
this.targetRange = targetRange;
|
||||||
this.customFlags = customFlags;
|
this.customFlags = customFlags;
|
||||||
this.command = ['-oX', '-', ...(customFlags || []), targetRange];
|
this.command = ['-oX', '-', ...(customFlags || []), targetRange];
|
||||||
|
this.rawData = '';
|
||||||
}
|
}
|
||||||
startScan() {
|
startScan() {
|
||||||
setImmediate(() => {
|
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 {
|
return {
|
||||||
NmapScan: MockNmapScan,
|
NmapScan: MockNmapScan,
|
||||||
@@ -43,4 +53,42 @@ describe('nmap discovery plugin', () => {
|
|||||||
expect(result.resources[0].name).toBe('host-10');
|
expect(result.resources[0].name).toBe('host-10');
|
||||||
expect(result.edges).toHaveLength(1);
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'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('proxy_client', () => {
|
||||||
|
let proxyClient;
|
||||||
|
let originalFetch;
|
||||||
|
let mockFetchImpl;
|
||||||
|
let calls;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
mockBaoStore = new Map();
|
||||||
|
calls = [];
|
||||||
|
mockFetchImpl = async () => ({ ok: true, status: 404 });
|
||||||
|
originalFetch = global.fetch;
|
||||||
|
global.fetch = (...args) => { calls.push(args); return mockFetchImpl(...args); };
|
||||||
|
proxyClient = require('../utils/proxy_client');
|
||||||
|
proxyClient._reset();
|
||||||
|
delete process.env.PROXY_INTERNAL_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips cleanly when required fields are missing', async () => {
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: '', ip: '', targetPort: 0 });
|
||||||
|
expect(result.note).toMatch(/required/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips cleanly when PROXY_INTERNAL_URL is not configured', async () => {
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toMatch(/PROXY_INTERNAL_URL/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips cleanly when no token is stored in OpenBao', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toMatch(/no proxy API token/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates the route when the host does not already exist', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
|
||||||
|
mockFetchImpl = async (url, opts) => {
|
||||||
|
if (opts.method === undefined) return { ok: true, status: 404 }; // GET lookup
|
||||||
|
if (opts.method === 'POST') return { ok: true, status: 200 };
|
||||||
|
throw new Error('unexpected method ' + opts.method);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toBe('created');
|
||||||
|
|
||||||
|
const postCall = calls.find((c) => c[1].method === 'POST');
|
||||||
|
expect(postCall[0]).toBe('https://proxy.internal/api/host');
|
||||||
|
expect(postCall[1].headers.Authorization).toBe('Bearer prx_test_token');
|
||||||
|
expect(JSON.parse(postCall[1].body)).toEqual({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates the route when it exists but points somewhere else', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
|
||||||
|
mockFetchImpl = async (url, opts) => {
|
||||||
|
if (!opts.method) return { ok: true, status: 200, json: async () => ({ results: { ip: '172.24.9.9', targetPort: 3001 } }) };
|
||||||
|
if (opts.method === 'PUT') return { ok: true, status: 200 };
|
||||||
|
throw new Error('unexpected method ' + opts.method);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a no-op when the route already matches', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
|
||||||
|
mockFetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ results: { ip: '172.24.5.1', targetPort: 3001 } }) });
|
||||||
|
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toBe('already up to date');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a network failure without throwing', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
|
||||||
|
mockFetchImpl = async () => { throw new Error('connection refused'); };
|
||||||
|
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toMatch(/failed: connection refused/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
require('./setup');
|
require('./setup');
|
||||||
const { Resource } = require('../models/resource');
|
const { Resource, ResourceGroup } = require('../models/resource');
|
||||||
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
|
|
||||||
describe('DiscoveryReconciler', () => {
|
describe('DiscoveryReconciler', () => {
|
||||||
@@ -72,4 +72,27 @@ describe('DiscoveryReconciler', () => {
|
|||||||
expect(merged.metadata.interfaces).toHaveLength(1);
|
expect(merged.metadata.interfaces).toHaveLength(1);
|
||||||
expect(merged.metadata.interfaces[0].ip).toBe('10.0.0.6'); // Updated IP
|
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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,45 @@ describe('site_replicate', () => {
|
|||||||
expect(JSON.parse(optsA.body).reason).toBe('catalog-changed');
|
expect(JSON.parse(optsA.body).reason).toBe('catalog-changed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prefers the mesh IP over the public endpoint when the spoke reported one', async () => {
|
||||||
|
SiteSpoke._seed([
|
||||||
|
{ endpoint: 'https://spoke-a.example.com:8443', pushToken: 'token-a', meshIp: '172.24.5.1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await siteReplicate.replicateToSpokes('catalog-changed');
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
expect(mockFetchCalls.length).toBe(1);
|
||||||
|
expect(mockFetchCalls[0][0]).toBe('http://172.24.5.1:8443/api/site/resync');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the public endpoint if the mesh attempt fails', async () => {
|
||||||
|
SiteSpoke._seed([
|
||||||
|
{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a', meshIp: '172.24.5.1' }
|
||||||
|
]);
|
||||||
|
mockFetchImpl = async (url) => {
|
||||||
|
if (url.startsWith('http://172.24.5.1')) throw new Error('mesh unreachable');
|
||||||
|
return { ok: true, status: 200 };
|
||||||
|
};
|
||||||
|
|
||||||
|
await siteReplicate.replicateToSpokes('catalog-changed');
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
expect(mockFetchCalls.length).toBe(2);
|
||||||
|
expect(mockFetchCalls[0][0]).toMatch(/^http:\/\/172\.24\.5\.1/);
|
||||||
|
expect(mockFetchCalls[1][0]).toBe('https://spoke-a.example.com/api/site/resync');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a spoke with no meshIp only ever tries the public endpoint', async () => {
|
||||||
|
SiteSpoke._seed([{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a' }]);
|
||||||
|
|
||||||
|
await siteReplicate.replicateToSpokes('catalog-changed');
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
expect(mockFetchCalls.length).toBe(1);
|
||||||
|
expect(mockFetchCalls[0][0]).toBe('https://spoke-a.example.com/api/site/resync');
|
||||||
|
});
|
||||||
|
|
||||||
test('no known spokes: resolves cleanly, no fetch calls', async () => {
|
test('no known spokes: resolves cleanly, no fetch calls', async () => {
|
||||||
await siteReplicate.replicateToSpokes('catalog-changed');
|
await siteReplicate.replicateToSpokes('catalog-changed');
|
||||||
await new Promise((r) => setImmediate(r));
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// OpenLDAP multi-master replication (docs/replication.md) config derivation,
|
||||||
|
// shared between routes/api_site.js (the spoke-facing side: assigns a
|
||||||
|
// ServerID at registration, serves GET /api/site/ldap-peers) and
|
||||||
|
// routes/api_directory_admin.js (the master-local side: GET
|
||||||
|
// /directory-admin/ldap-replication-config computes the master's own
|
||||||
|
// replication config from the same SiteSpoke registry, no HTTP round-trip
|
||||||
|
// needed since it already has the data).
|
||||||
|
|
||||||
|
const { SiteSpoke } = require('../models/site_spoke');
|
||||||
|
|
||||||
|
// The master reserves ServerID 1 for itself; every spoke gets the lowest
|
||||||
|
// free ID from 2 upward, assigned once at registration and reused across
|
||||||
|
// re-registrations (SiteSpoke.ldapServerId is only ever set on first
|
||||||
|
// create). Small max, matching mesh_gateway.js's mesh index -- nothing in
|
||||||
|
// the OpenLDAP protocol requires a small ServerID, but this deployment's
|
||||||
|
// docs/examples always have.
|
||||||
|
const MAX_LDAP_SERVER_ID = 4094;
|
||||||
|
|
||||||
|
async function nextFreeLdapServerId() {
|
||||||
|
const spokes = await SiteSpoke.list();
|
||||||
|
const used = new Set(spokes.map((s) => s.ldapServerId).filter(Boolean));
|
||||||
|
for (let i = 2; i <= MAX_LDAP_SERVER_ID; i++) {
|
||||||
|
if (!used.has(i)) return i;
|
||||||
|
}
|
||||||
|
throw new Error(`LDAP server ID space exhausted (max ${MAX_LDAP_SERVER_ID} spokes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A site's LDAP replication URL, derived from its already-known HTTP(S)
|
||||||
|
// endpoint rather than requiring a separately-configured field: same
|
||||||
|
// hostname, LDAPS port 636 -- exactly the convention docs/replication.md's
|
||||||
|
// own worked examples already use (ldaps://sso.site2.com:636 alongside
|
||||||
|
// https://sso.site2.com). No new config an operator has to keep in sync.
|
||||||
|
function ldapHostFor(endpoint) {
|
||||||
|
try {
|
||||||
|
const host = new URL(endpoint).hostname;
|
||||||
|
return `ldaps://${host}:636`;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { MAX_LDAP_SERVER_ID, nextFreeLdapServerId, ldapHostFor };
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Service-to-service client for theta-proxy's Host management API --
|
||||||
|
// MULTI_SITE_SPEC.md's "no-inbound relay automation" (a master creating a
|
||||||
|
// relay route so a spoke with zero inbound path of its own is reachable).
|
||||||
|
//
|
||||||
|
// Deliberately does NOT invent a new credential type. theta-proxy already
|
||||||
|
// has a self-service API token system (models/api_token.js, `prx_<id>_<secret>`
|
||||||
|
// bearer tokens that authenticate as their creator's user + group snapshot --
|
||||||
|
// same pattern this app and jump-host both already have their own copy of).
|
||||||
|
// The "service-to-service auth" gap was never "no credential type exists" --
|
||||||
|
// it's that nothing wired one of these tokens into an actual inter-service
|
||||||
|
// call. This is that wiring, using the credential type that was already
|
||||||
|
// there. The token itself is operator-provisioned (minted on theta-proxy by
|
||||||
|
// an admin with Host-management rights) and stored in OpenBao, same as the
|
||||||
|
// agent-signing key in agent_keys.js.
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
|
||||||
|
const PATH = 'integrations/theta-proxy'; // 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(`[proxy_client] could not read ${PATH} from OpenBao: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!stored || !stored.token) return null;
|
||||||
|
cachedToken = stored.token;
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyBaseUrl() {
|
||||||
|
// Not OpenBao -- this is where the proxy's admin API lives, not a secret.
|
||||||
|
// No safe default: relaying to a guessed host would be worse than
|
||||||
|
// refusing, so this must be explicitly configured.
|
||||||
|
return process.env.PROXY_INTERNAL_URL || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates the relay Host route if missing, updates its target IP if it
|
||||||
|
// already exists and points somewhere else. Idempotent -- safe to call
|
||||||
|
// again for the same host on every spoke resync.
|
||||||
|
//
|
||||||
|
// Returns { note } describing what happened (created/updated/skipped/failed)
|
||||||
|
// rather than throwing on a missing token or base URL -- callers (spoke
|
||||||
|
// registration) must never fail the whole registration just because this
|
||||||
|
// automation isn't configured yet; it's an enhancement layered on top of a
|
||||||
|
// working join, not a requirement of one.
|
||||||
|
async function ensureRelayRoute({ host, ip, targetPort }) {
|
||||||
|
if (!host || !ip || !targetPort) {
|
||||||
|
return { note: 'skipped: host, ip, and targetPort are all required' };
|
||||||
|
}
|
||||||
|
const base = proxyBaseUrl();
|
||||||
|
if (!base) {
|
||||||
|
return { note: 'skipped: PROXY_INTERNAL_URL not configured' };
|
||||||
|
}
|
||||||
|
const token = await loadToken();
|
||||||
|
if (!token) {
|
||||||
|
return { note: `skipped: no proxy API token at OpenBao ${PATH} -- mint one on theta-proxy and store it there` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const existing = await fetch(base.replace(/\/+$/, '') + '/api/host/' + encodeURIComponent(host), {
|
||||||
|
headers, signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.status === 200) {
|
||||||
|
// GET /api/host/:item wraps the record in { item, results }, not
|
||||||
|
// flat -- confirmed against a real running proxy (this check
|
||||||
|
// silently always "updated" instead of no-op'ing until fixed).
|
||||||
|
const body = await existing.json();
|
||||||
|
const current = body.results || body;
|
||||||
|
if (current.ip === ip && Number(current.targetPort) === Number(targetPort)) {
|
||||||
|
return { note: 'already up to date' };
|
||||||
|
}
|
||||||
|
const put = await fetch(base.replace(/\/+$/, '') + '/api/host/' + encodeURIComponent(host), {
|
||||||
|
method: 'PUT', headers, body: JSON.stringify({ ip, targetPort }), signal: controller.signal
|
||||||
|
});
|
||||||
|
return put.ok ? { note: 'updated' } : { note: `update failed: HTTP ${put.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = await fetch(base.replace(/\/+$/, '') + '/api/host', {
|
||||||
|
method: 'POST', headers, body: JSON.stringify({ host, ip, targetPort }), signal: controller.signal
|
||||||
|
});
|
||||||
|
return create.ok ? { note: 'created' } : { note: `create failed: HTTP ${create.status}` };
|
||||||
|
} catch (err) {
|
||||||
|
return { note: `failed: ${err.message}` };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test seam.
|
||||||
|
function _reset() { cachedToken = null; }
|
||||||
|
|
||||||
|
module.exports = { ensureRelayRoute, _reset, PATH };
|
||||||
@@ -37,21 +37,48 @@ function replicateToSpokes(reason) {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pingOne(spoke, reason) {
|
// Cross-component routing (MULTI_SITE_SPEC.md): if this spoke reported a WG
|
||||||
const url = String(spoke.endpoint).replace(/\/+$/, '') + '/api/site/resync';
|
// mesh IP when it registered (utils/proxy_client.js's no-inbound relay path
|
||||||
const controller = new AbortController();
|
// populates the same field), prefer sending the resync push over the mesh
|
||||||
const timer = setTimeout(() => controller.abort(), RESYNC_TIMEOUT_MS);
|
// tunnel instead of the open internet -- plain HTTP is fine here since the
|
||||||
try {
|
// WG tunnel itself is already encrypted, same reasoning as the no-inbound
|
||||||
const resp = await fetch(url, {
|
// relay terminating at the master. Falls back to the spoke's public endpoint
|
||||||
method: 'POST',
|
// if the mesh attempt fails (mesh IP set but that particular tunnel isn't
|
||||||
headers: { Authorization: 'Bearer ' + spoke.pushToken, 'Content-Type': 'application/json' },
|
// actually up yet, or unreachable for any other reason) -- never let a
|
||||||
body: JSON.stringify({ reason: reason || 'catalog-changed' }),
|
// mesh-routing preference turn into "spoke never gets updates."
|
||||||
signal: controller.signal
|
function resyncUrls(spoke) {
|
||||||
});
|
const urls = [];
|
||||||
if (!resp.ok) throw new Error('status ' + resp.status);
|
if (spoke.meshIp) {
|
||||||
} finally {
|
let port = '3001';
|
||||||
clearTimeout(timer);
|
try { port = new URL(spoke.endpoint).port || port; } catch (_) { /* keep default */ }
|
||||||
|
urls.push(`http://${spoke.meshIp}:${port}/api/site/resync`);
|
||||||
}
|
}
|
||||||
|
urls.push(String(spoke.endpoint).replace(/\/+$/, '') + '/api/site/resync');
|
||||||
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { replicateToSpokes };
|
async function pingOne(spoke, reason) {
|
||||||
|
const urls = resyncUrls(spoke);
|
||||||
|
let lastErr;
|
||||||
|
for (const url of urls) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), RESYNC_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + spoke.pushToken, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason: reason || 'catalog-changed' }),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('status ' + resp.status);
|
||||||
|
return; // success -- don't try the next (fallback) URL
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { replicateToSpokes, resyncUrls };
|
||||||
|
|||||||
@@ -3332,7 +3332,11 @@
|
|||||||
'<span>' + (isMaster ? '👑 <strong>Master Site Node</strong>' : '⚡ <strong>Spoke Site Node</strong>') + '</span>' +
|
'<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>' +
|
'<span class="badge bg-' + (isMaster ? 'warning text-dark' : 'info text-dark') + '">' + esc(cfg.siteMode || 'master') + '</span>' +
|
||||||
'</h5>' +
|
'</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">' +
|
'<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>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>' +
|
'<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>' : '') +
|
: '<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>' : '') +
|
(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>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>' +
|
'</table>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|||||||
@@ -205,6 +205,30 @@ async function main() {
|
|||||||
if (spokeCfg.config.isMaster !== false) fail(`spoke should be isMaster:false after join, got ${JSON.stringify(spokeCfg.config)}`);
|
if (spokeCfg.config.isMaster !== false) fail(`spoke should be isMaster:false after join, got ${JSON.stringify(spokeCfg.config)}`);
|
||||||
if (!spokeCfg.config.masterUrl) fail('spoke should have masterUrl set after join');
|
if (!spokeCfg.config.masterUrl) fail('spoke should have masterUrl set after join');
|
||||||
|
|
||||||
|
step('Verifying the master computed its own LDAP replication config (ServerID 1 + the new spoke as a peer)');
|
||||||
|
const { body: masterLdapCfg } = await api(MASTER_URL, '/api/directory-admin/ldap-replication-config', { token: masterToken });
|
||||||
|
if (masterLdapCfg.ldapServerId !== 1) fail(`master's own ldapServerId should be 1, got ${JSON.stringify(masterLdapCfg)}`);
|
||||||
|
const spokePeer = (masterLdapCfg.peers || []).find(p => p.ldapHost === 'ldaps://spoke:636');
|
||||||
|
if (!spokePeer || typeof spokePeer.ldapServerId !== 'number') {
|
||||||
|
fail(`master's peer list should include the spoke at ldaps://spoke:636 with an assigned ldapServerId, got ${JSON.stringify(masterLdapCfg.peers)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
step('Verifying the spoke can fetch its own assigned LDAP ServerID + peer list from the master');
|
||||||
|
const spokeLdapPeersResp = await fetch(`${MASTER_URL}/api/site/ldap-peers?endpoint=${encodeURIComponent('http://spoke:3001')}`, {
|
||||||
|
headers: { Authorization: 'Bearer ' + joinKey }
|
||||||
|
});
|
||||||
|
const spokeLdapCfg = await spokeLdapPeersResp.json();
|
||||||
|
if (spokeLdapPeersResp.status !== 200) fail(`GET /api/site/ldap-peers failed: ${spokeLdapPeersResp.status} ${JSON.stringify(spokeLdapCfg)}`);
|
||||||
|
if (spokeLdapCfg.ldapServerId !== spokePeer.ldapServerId) {
|
||||||
|
fail(`spoke's own reported ldapServerId (${spokeLdapCfg.ldapServerId}) should match what the master's peer list assigned it (${spokePeer.ldapServerId})`);
|
||||||
|
}
|
||||||
|
const masterAsPeer = (spokeLdapCfg.peers || []).find(p => p.ldapServerId === 1);
|
||||||
|
if (!masterAsPeer || masterAsPeer.ldapHost !== 'ldaps://master:636') {
|
||||||
|
fail(`spoke's peer list should include the master (ServerID 1, ldaps://master:636), got ${JSON.stringify(spokeLdapCfg.peers)}`);
|
||||||
|
}
|
||||||
|
const selfInOwnPeerList = (spokeLdapCfg.peers || []).some(p => p.ldapServerId === spokeLdapCfg.ldapServerId);
|
||||||
|
if (selfInOwnPeerList) fail(`spoke's own peer list should not include itself, got ${JSON.stringify(spokeLdapCfg.peers)}`);
|
||||||
|
|
||||||
step('Verifying the spoke adopted the master\'s pre-join catalog');
|
step('Verifying the spoke adopted the master\'s pre-join catalog');
|
||||||
const spokeResources = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
|
const spokeResources = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
|
||||||
const adopted = (spokeResources.body.results || spokeResources.body.resources || spokeResources.body || []);
|
const adopted = (spokeResources.body.results || spokeResources.body.resources || spokeResources.body || []);
|
||||||
|
|||||||
Reference in New Issue
Block a user