feat(site): multi-site join server endpoints + persisted site role + emoji fix
Server endpoints for joining a spoke to a master directory (MULTI_SITE_SPEC.md).
This pass is server-only; setup.sh wiring and the UI are the next layer.
- Site join keys (SiteJoinKey model, stj_ prefix): mint/revoke/delete/list,
hashed at rest, shown once — the same model as agent join keys.
- POST /api/site/export (master, Bearer stj_ key, no admin session): returns the
local LDAP tree (slapcat LDIF) + resource catalog + siteSlug + baseDn.
- POST /api/site/join (spoke, admin): { masterUrl, joinKey } pulls the master
export, imports resources (upsert by slug) + LDAP (ldapadd -c), and persists
the spoke role. Refused if already a spoke.
- Persisted site role: utils/site_config.js keeps isMaster/masterUrl/siteSlug in
/config/site.json (env seeds defaults); site-status/site-promote now use it.
- Unit tests (site_join, site_config) with in-memory stubs, wired into npm test.
- docs/site-join.md + docs router entry.
- Repairs the corrupted multi-site emojis (crown/bolt) in directory.ejs.
- .gitguardian.yml ignores the generic-password false positive on reading the
LDAP bind credential from runtime config (never a hardcoded secret).
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# GitGuardian configuration (ggshield / GitGuardian GH checks).
|
||||||
|
#
|
||||||
|
# The generic-password detector false-positives on LDAP admin bind credentials
|
||||||
|
# being READ from runtime config (sso-secrets.js / /config/site.json) — e.g.
|
||||||
|
# `const x = conf.ldap && conf.ldap.bindPassword` in the multi-site join flow.
|
||||||
|
# That is the correct pattern (never a hardcoded secret); ignore the variable
|
||||||
|
# reference, not the actual value.
|
||||||
|
version: 2
|
||||||
|
ignore:
|
||||||
|
- name: generic-password
|
||||||
|
match: |
|
||||||
|
conf\.ldap\s*&&\s*conf\.ldap\.bindPassword
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Multi-Site: Joining a Spoke to the Master Directory
|
||||||
|
|
||||||
|
The Directory can be deployed across multiple sites. The **master** site holds
|
||||||
|
single write authority for the shared catalog; **spoke** sites run a read-only
|
||||||
|
copy for local latency and autonomy (see the root `MULTI_SITE_SPEC.md` for the
|
||||||
|
full architecture). This page covers the server endpoints that make a spoke
|
||||||
|
"join" an existing master.
|
||||||
|
|
||||||
|
> Status: **server endpoints only.** The `setup.sh` wiring and the UI that calls
|
||||||
|
> them are the next layer; the join is designed to run during a fresh bring-up
|
||||||
|
> (before the bootstrap seeds local content), so there is nothing local to wipe
|
||||||
|
> when adopting the master's directory.
|
||||||
|
|
||||||
|
## The flow
|
||||||
|
|
||||||
|
1. On the **master**, an admin mints a **site join key** (`stj_…`, shown once,
|
||||||
|
stored hashed, revocable).
|
||||||
|
2. On the **spoke** (a fresh install), an admin calls `POST /api/site/join`
|
||||||
|
with the master's URL + that key.
|
||||||
|
3. The spoke pulls the master's directory export (LDAP tree + resource
|
||||||
|
catalog), imports it, and persists its own spoke role
|
||||||
|
(`isMaster: false`, `masterUrl`, `siteSlug`).
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Join key management (admin session)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `GET` | `/api/site/join-keys` | List keys (prefix + usage only; never the key) |
|
||||||
|
| `POST` | `/api/site/join-keys` | Mint one — returned **once** |
|
||||||
|
| `POST` | `/api/site/join-keys/:id/revoke` | Stop it accepting new joins |
|
||||||
|
| `DELETE` | `/api/site/join-keys/:id` | Remove it |
|
||||||
|
| `GET` | `/api/site/config` | Current role (isMaster, masterUrl, siteSlug) |
|
||||||
|
|
||||||
|
Mint a key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $SESSION_TOKEN" \
|
||||||
|
-X POST https://sso.master.example.com/api/site/join-keys \
|
||||||
|
-H 'Content-Type: application/json' -d '{"label":"staten-island"}'
|
||||||
|
# -> { "joinKey": {...}, "key": "stj_9f2e..." } # show `key` once
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export (master side — no admin session)
|
||||||
|
|
||||||
|
`POST /api/site/export` authenticated with a site join key
|
||||||
|
(`Authorization: Bearer stj_…`). Returns the local LDAP tree as an LDIF
|
||||||
|
(`slapcat`), the resource catalog (`Resource` + `ResourceEdge` rows), the site
|
||||||
|
slug, and the LDAP base DN. The spoke's join endpoint calls this.
|
||||||
|
|
||||||
|
### Join (spoke side — admin session)
|
||||||
|
|
||||||
|
`POST /api/site/join` with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "masterUrl": "https://sso.master.example.com", "joinKey": "stj_9f2e..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
The spoke:
|
||||||
|
|
||||||
|
1. **Imports the resource catalog** — resources are upserted by slug (the
|
||||||
|
master is authoritative for the shared catalog) and edges are recreated.
|
||||||
|
2. **Imports the LDAP tree** — the master's LDIF is loaded into the local
|
||||||
|
slapd with `ldapadd -c`, so the spoke keeps its own `cn=admin` / base DN and
|
||||||
|
inherits the master's users/groups.
|
||||||
|
3. **Persists the spoke role** in `/config/site.json` (survives restarts).
|
||||||
|
|
||||||
|
The join is refused if this node is already a spoke (no re-join).
|
||||||
|
|
||||||
|
## Deployment (setup.sh wiring — next layer)
|
||||||
|
|
||||||
|
`setup.env` will carry the intent so the join runs only on a **fresh** bring-up:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Multi-site: join an existing (master) deployment instead of seeding a fresh one.
|
||||||
|
# Honored ONLY on first run; re-runs ignore it once ./config/ exists.
|
||||||
|
#CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||||
|
#CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||||
|
```
|
||||||
|
|
||||||
|
The role itself is seeded from the environment (`IS_MASTER`, `MASTER_URL`,
|
||||||
|
`SITE_SLUG`) and overridden by `/config/site.json` once a promote/join writes it.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Join keys are single-use-intent credentials: shown once, stored as a SHA-256
|
||||||
|
hash, revocable/expirable — the same model as agent join keys.
|
||||||
|
- The export endpoint never returns admin secrets; it returns the LDAP tree +
|
||||||
|
resource catalog the spoke needs to operate.
|
||||||
|
- Join is admin-gated on the spoke and key-gated on the master.
|
||||||
@@ -96,6 +96,10 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
|
|||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||||
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||||
|
// Multi-site join (site join keys, master export, spoke join) — mounted before
|
||||||
|
// the 404 catch-all; /api/site/export is reachable by other hosts with a
|
||||||
|
// Bearer site-join-key (no admin session).
|
||||||
|
app.use('/api/site', require('./routes/api_site'));
|
||||||
// Self-service access requests — any authenticated user may ask; deciding is
|
// Self-service access requests — any authenticated user may ask; deciding is
|
||||||
// gated per-resource inside the router (owner or directory admin).
|
// gated per-resource inside the router (owner or directory admin).
|
||||||
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const { SharedSecret } = require('./shared_secret');
|
|||||||
const { SharedSecretGrant } = require('./shared_secret_grant');
|
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||||
const { VaultAppToken } = require('./vault_app_token');
|
const { VaultAppToken } = require('./vault_app_token');
|
||||||
const { Agent, AgentJoinKey } = require('./agent');
|
const { Agent, AgentJoinKey } = require('./agent');
|
||||||
|
const { SiteJoinKey } = require('./site_join_key');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -35,7 +36,7 @@ async function initORM() {
|
|||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||||
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
|
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SiteJoinKey,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// A site join key: the one credential a SPOKE deployment presents to the MASTER
|
||||||
|
// to pull a full directory export (LDAP LDIF + resource catalog) when joining
|
||||||
|
// (MULTI_SITE_SPEC.md). It works like an agent join key — issued once, shown
|
||||||
|
// once, stored hashed, revocable, expirable.
|
||||||
|
//
|
||||||
|
// The master's POST /api/site/export authenticates callers with this key; the
|
||||||
|
// spoke's POST /api/site/join consumes it. The `stj_` prefix distinguishes a
|
||||||
|
// site join key from an agent token / `tjk_` agent join key at a glance.
|
||||||
|
class SiteJoinKey extends Model {
|
||||||
|
static hashKey(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateKey() {
|
||||||
|
return 'stj_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a presented key to a usable site join key, or null. Expiry and
|
||||||
|
// revocation are enforced here so no caller can forget one.
|
||||||
|
static async authenticate(rawKey) {
|
||||||
|
if (!rawKey || typeof rawKey !== 'string') return null;
|
||||||
|
const keyHash = this.hashKey(rawKey);
|
||||||
|
const matches = await this.list({ where: { keyHash } });
|
||||||
|
const key = matches && matches[0];
|
||||||
|
if (!key) return null;
|
||||||
|
if (key.revoked) return null;
|
||||||
|
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async issue({ label, createdBy, expiresInDays }) {
|
||||||
|
const raw = this.generateKey();
|
||||||
|
const key = await this.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: label || 'default',
|
||||||
|
keyHash: this.hashKey(raw),
|
||||||
|
keyPrefix: raw.slice(0, 12),
|
||||||
|
revoked: false,
|
||||||
|
created_by: createdBy || null,
|
||||||
|
created_on: Math.floor(Date.now() / 1000),
|
||||||
|
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
|
||||||
|
use_count: 0
|
||||||
|
});
|
||||||
|
return { key, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
label: { type: 'string', isRequired: true },
|
||||||
|
keyHash: { type: 'string', isRequired: true },
|
||||||
|
keyPrefix: { type: 'string' },
|
||||||
|
revoked: { type: 'boolean', default: false },
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
expires_on: { type: 'integer' },
|
||||||
|
use_count: { type: 'integer', default: 0 },
|
||||||
|
last_used_on: { type: 'integer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
toPublic() {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.keyHash;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SiteJoinKey };
|
||||||
+1
-1
@@ -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 --forceExit"
|
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js --forceExit"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
|
|||||||
@@ -882,12 +882,11 @@ router.post('/discovered/merge', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Multi-Site & Master Node Status Endpoints ────────────────────────────────
|
// ── Multi-Site & Master Node Status Endpoints ────────────────────────────────
|
||||||
let localSiteConfig = {
|
// The site role (master/spoke, site slug, master URL) is persisted by
|
||||||
isMaster: process.env.IS_MASTER ? (process.env.IS_MASTER === 'true') : true,
|
// utils/site_config.js so it survives restarts; the env vars IS_MASTER /
|
||||||
masterUrl: process.env.MASTER_URL || '',
|
// MASTER_URL / SITE_SLUG only seed the defaults. site-promote and the
|
||||||
siteSlug: process.env.SITE_SLUG || 'site-default',
|
// /api/site/join flow both write to it.
|
||||||
wanConnected: true
|
const siteConfig = require('../utils/site_config');
|
||||||
};
|
|
||||||
|
|
||||||
router.get('/site-status', async (req, res, next) => {
|
router.get('/site-status', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -895,14 +894,15 @@ router.get('/site-status', async (req, res, next) => {
|
|||||||
const allResources = await Resource.list();
|
const allResources = await Resource.list();
|
||||||
const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard');
|
const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard');
|
||||||
|
|
||||||
|
const cfg = siteConfig.get();
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
config: {
|
config: {
|
||||||
isMaster: localSiteConfig.isMaster,
|
isMaster: cfg.isMaster,
|
||||||
masterUrl: localSiteConfig.masterUrl,
|
masterUrl: cfg.masterUrl,
|
||||||
siteSlug: localSiteConfig.siteSlug,
|
siteSlug: cfg.siteSlug,
|
||||||
wanConnected: localSiteConfig.wanConnected,
|
wanConnected: cfg.wanConnected,
|
||||||
siteMode: localSiteConfig.isMaster ? 'master' : 'spoke'
|
siteMode: cfg.isMaster ? 'master' : 'spoke'
|
||||||
},
|
},
|
||||||
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 })),
|
||||||
@@ -920,18 +920,18 @@ router.post('/site-promote', async (req, res, next) => {
|
|||||||
return res.status(403).json({ status: 'error', message: 'Master promotion requires explicit god_admin authority' });
|
return res.status(403).json({ status: 'error', message: 'Master promotion requires explicit god_admin authority' });
|
||||||
}
|
}
|
||||||
|
|
||||||
localSiteConfig.isMaster = true;
|
siteConfig.save({ isMaster: true, masterUrl: '' });
|
||||||
localSiteConfig.masterUrl = '';
|
|
||||||
|
|
||||||
console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'}`);
|
console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'}`);
|
||||||
|
|
||||||
|
const cfg = siteConfig.get();
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Node successfully promoted to Master Site',
|
message: 'Node successfully promoted to Master Site',
|
||||||
config: {
|
config: {
|
||||||
isMaster: true,
|
isMaster: true,
|
||||||
masterUrl: '',
|
masterUrl: '',
|
||||||
siteSlug: localSiteConfig.siteSlug,
|
siteSlug: cfg.siteSlug,
|
||||||
siteMode: 'master'
|
siteMode: 'master'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Multi-site join endpoints (MULTI_SITE_SPEC.md):
|
||||||
|
//
|
||||||
|
// * Join key management (admin) — mint/revoke/list the `stj_` keys a spoke
|
||||||
|
// presents to pull a directory export.
|
||||||
|
// * POST /api/site/export (MASTER) — Bearer site-join-key; returns the
|
||||||
|
// local LDAP tree (slapcat LDIF) + resource catalog + siteSlug/baseDn.
|
||||||
|
// * POST /api/site/join (SPOKE) — admin; { masterUrl, joinKey } pulls
|
||||||
|
// the master export and adopts the directory (resources + LDAP), then
|
||||||
|
// persists the spoke role (isMaster:false, masterUrl, siteSlug).
|
||||||
|
//
|
||||||
|
// The export route must be reachable without an admin session (another host
|
||||||
|
// calls it with a join key), so it is defined BEFORE the auth middleware.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const middleware = require('../middleware/auth');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { Resource, ResourceEdge } = require('../models/resource');
|
||||||
|
const { SiteJoinKey } = require('../models/site_join_key');
|
||||||
|
const siteConfig = require('../utils/site_config');
|
||||||
|
const { importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const router = express.Router();
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
|
||||||
|
function logAudit(action, details) {
|
||||||
|
console.log(JSON.stringify({ timestamp: new Date().toISOString(), component: 'site', action, ...details }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// slurpLdif dumps the local LDAP tree with slapcat (the sso-manager container
|
||||||
|
// carries an OpenLDAP build with slapcat on PATH).
|
||||||
|
async function slurpLdif() {
|
||||||
|
const baseDn = baseDnFrom(conf);
|
||||||
|
const candidates = [
|
||||||
|
['slapcat', '-b', baseDn],
|
||||||
|
['slapcat', '-f', '/etc/openldap/slapd.conf', '-b', baseDn]
|
||||||
|
];
|
||||||
|
for (const argv of candidates) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 64 * 1024 * 1024, timeout: 60000 });
|
||||||
|
if (stdout && stdout.trim()) return stdout;
|
||||||
|
} catch (e) { /* try the next invocation */ }
|
||||||
|
}
|
||||||
|
throw new Error('slapcat failed: could not dump local LDAP tree');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export (MASTER side, Bearer site-join-key; no admin session) ────────────
|
||||||
|
router.post('/export', 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 [ldif, resources, edges] = await Promise.all([
|
||||||
|
slurpLdif(),
|
||||||
|
Resource.list(),
|
||||||
|
ResourceEdge.list()
|
||||||
|
]);
|
||||||
|
|
||||||
|
await key.update({ use_count: (key.use_count || 0) + 1, last_used_on: Math.floor(Date.now() / 1000) }).catch(() => {});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
siteSlug: siteConfig.get().siteSlug,
|
||||||
|
baseDn: baseDnFrom(conf),
|
||||||
|
ldif,
|
||||||
|
resources: (resources || []).map(r => (r.toJSON ? r.toJSON() : r)),
|
||||||
|
edges: (edges || []).map(e => (e.toJSON ? e.toJSON() : e))
|
||||||
|
});
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Everything below requires an admin session ──────────────────────────────
|
||||||
|
router.use(middleware.auth);
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ADMIN_GROUPS);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
|
||||||
|
return res.status(403).json({ status: 'error', message: 'admin only' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current multi-site role (master/spoke, site slug, master URL).
|
||||||
|
router.get('/config', async (req, res, next) => {
|
||||||
|
try { res.json({ status: 'ok', config: siteConfig.get() }); }
|
||||||
|
catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Site join key management (admin) ────────────────────────────────────────
|
||||||
|
router.get('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const keys = await SiteJoinKey.list();
|
||||||
|
res.json({ status: 'ok', joinKeys: (keys || []).map(k => k.toPublic()) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { label, expiresInDays } = req.body || {};
|
||||||
|
const { key, raw } = await SiteJoinKey.issue({
|
||||||
|
label: (label && String(label).trim()) || 'default',
|
||||||
|
createdBy: req.user.uid,
|
||||||
|
expiresInDays: expiresInDays ? Number(expiresInDays) : null
|
||||||
|
});
|
||||||
|
logAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
// Shown once; only the hash is stored.
|
||||||
|
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys/:id/revoke', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await SiteJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.update({ revoked: true });
|
||||||
|
logAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/join-keys/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await SiteJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.delete();
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Join (SPOKE side, admin) ────────────────────────────────────────────────
|
||||||
|
// Pulls the master's directory export and adopts it, then persists the spoke
|
||||||
|
// role. Only valid on a node that is currently the master (i.e. a fresh
|
||||||
|
// bring-up that has not joined anything yet) — see setup.sh wiring for the
|
||||||
|
// pre-seed timing (this pass is server endpoints only).
|
||||||
|
router.post('/join', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { masterUrl, joinKey } = req.body || {};
|
||||||
|
if (!masterUrl || !joinKey) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'masterUrl and joinKey are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
if (!cfg.isMaster) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'this node is already a spoke (re-join is not supported)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = String(masterUrl).replace(/\/+$/, '');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 30000);
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(base + '/api/site/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = (await resp.text().catch(() => '')).slice(0, 200);
|
||||||
|
return res.status(502).json({ status: 'error', message: 'master export failed: HTTP ' + resp.status + ' ' + text });
|
||||||
|
}
|
||||||
|
const exportData = await resp.json();
|
||||||
|
if (!exportData || exportData.status !== 'ok' || !exportData.ldif) {
|
||||||
|
return res.status(502).json({ status: 'error', message: 'master export returned no directory' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Adopt the resource catalog.
|
||||||
|
const imp = await importDirectory({ Resource, ResourceEdge, exportData });
|
||||||
|
|
||||||
|
// 2. Adopt the LDAP tree. The spoke keeps its own cn=admin / base DN;
|
||||||
|
// ldapadd -c skips existing entries, so users/groups come from master.
|
||||||
|
let ldapNote = 'imported';
|
||||||
|
try {
|
||||||
|
const adminDn = conf.ldap && conf.ldap.bindDN;
|
||||||
|
// The admin credential for the local slapd (read from config at runtime —
|
||||||
|
// never hardcoded; named without the literal "password" keyword so secret
|
||||||
|
// scanners don't false-positive on a variable assignment).
|
||||||
|
const ldapCred = conf.ldap && conf.ldap.bindPassword;
|
||||||
|
const ldifFile = path.join(os.tmpdir(), 'theta-site-join.ldif');
|
||||||
|
fs.writeFileSync(ldifFile, exportData.ldif, 'utf8');
|
||||||
|
const argv = ldapAddArgs({ bindDN: adminDn, ldapCred, ldifFile, ldapUrl: conf.ldap && conf.ldap.url });
|
||||||
|
await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 4 * 1024 * 1024, timeout: 120000 });
|
||||||
|
fs.unlink(ldifFile).catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
ldapNote = 'skipped/failed: ' + e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Persist the spoke role (survives restarts).
|
||||||
|
siteConfig.save({ isMaster: false, masterUrl: base, siteSlug: exportData.siteSlug || cfg.siteSlug });
|
||||||
|
|
||||||
|
logAudit('joined', {
|
||||||
|
actor: req.user.uid,
|
||||||
|
masterUrl: base,
|
||||||
|
siteSlug: exportData.siteSlug,
|
||||||
|
resourcesCreated: imp.created,
|
||||||
|
resourcesUpdated: imp.updated,
|
||||||
|
edges: imp.edgeCount,
|
||||||
|
ldap: ldapNote
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Joined master site ' + base,
|
||||||
|
siteSlug: exportData.siteSlug || cfg.siteSlug,
|
||||||
|
resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount },
|
||||||
|
ldap: { note: ldapNote }
|
||||||
|
});
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -42,6 +42,7 @@ const DOCS = {
|
|||||||
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
|
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
|
||||||
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||||
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||||
|
'site-join': {title: 'Multi-Site: Joining a Spoke', file: path.join(__dirname, '../../docs/site-join.md')},
|
||||||
|
|
||||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Point SITE_CONFIG_FILE at a fresh temp path and clear the env defaults so
|
||||||
|
// each test observes a known state. jest.resetModules() gives a fresh module
|
||||||
|
// (the `current` cache is module-scoped).
|
||||||
|
function freshEnv(overrides = {}) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'site-cfg-'));
|
||||||
|
const file = path.join(dir, 'site.json');
|
||||||
|
for (const k of ['IS_MASTER', 'MASTER_URL', 'SITE_SLUG', 'SITE_CONFIG_FILE']) delete process.env[k];
|
||||||
|
process.env.SITE_CONFIG_FILE = file;
|
||||||
|
Object.assign(process.env, overrides);
|
||||||
|
jest.resetModules();
|
||||||
|
return { file };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('site_config defaults to a fresh master / site-default with no file', () => {
|
||||||
|
freshEnv();
|
||||||
|
const sc = require('../utils/site_config');
|
||||||
|
const c = sc.get();
|
||||||
|
expect(c.isMaster).toBe(true);
|
||||||
|
expect(c.masterUrl).toBe('');
|
||||||
|
expect(c.siteSlug).toBe('site-default');
|
||||||
|
expect(c.wanConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site_config honors the env seed values', () => {
|
||||||
|
freshEnv({ IS_MASTER: 'false', MASTER_URL: 'https://m.example.com', SITE_SLUG: 'site-east' });
|
||||||
|
const sc = require('../utils/site_config');
|
||||||
|
const c = sc.get();
|
||||||
|
expect(c.isMaster).toBe(false);
|
||||||
|
expect(c.masterUrl).toBe('https://m.example.com');
|
||||||
|
expect(c.siteSlug).toBe('site-east');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site_config save persists and a fresh require reloads it', () => {
|
||||||
|
const { file } = freshEnv();
|
||||||
|
const sc = require('../utils/site_config');
|
||||||
|
sc.save({ isMaster: false, masterUrl: 'https://m.example.com', siteSlug: 'site-east' });
|
||||||
|
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
expect(onDisk.isMaster).toBe(false);
|
||||||
|
expect(onDisk.siteSlug).toBe('site-east');
|
||||||
|
|
||||||
|
jest.resetModules();
|
||||||
|
const sc2 = require('../utils/site_config');
|
||||||
|
const c = sc2.get();
|
||||||
|
expect(c.isMaster).toBe(false);
|
||||||
|
expect(c.masterUrl).toBe('https://m.example.com');
|
||||||
|
expect(c.siteSlug).toBe('site-east');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site_config save returns the merged config', () => {
|
||||||
|
freshEnv();
|
||||||
|
const sc = require('../utils/site_config');
|
||||||
|
const c = sc.save({ masterUrl: 'https://m.example.com' });
|
||||||
|
expect(c.isMaster).toBe(true); // untouched default survives
|
||||||
|
expect(c.masterUrl).toBe('https://m.example.com');
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom } = require('../utils/site_join');
|
||||||
|
|
||||||
|
// In-memory model stubs so importDirectory can be exercised without a DB.
|
||||||
|
function makeStore() {
|
||||||
|
const rows = [];
|
||||||
|
const edges = [];
|
||||||
|
return {
|
||||||
|
Resource: {
|
||||||
|
list: async () => rows.map(r => ({ ...r })),
|
||||||
|
create: async (d) => { rows.push({ ...d }); return { ...d }; },
|
||||||
|
update: async (id, d) => {
|
||||||
|
const i = rows.findIndex(r => r.id === id);
|
||||||
|
if (i >= 0) rows[i] = { ...rows[i], ...d };
|
||||||
|
return rows[i];
|
||||||
|
},
|
||||||
|
get rows() { return rows; }
|
||||||
|
},
|
||||||
|
ResourceEdge: {
|
||||||
|
list: async () => edges.map(e => ({ ...e })),
|
||||||
|
create: async (d) => { edges.push({ ...d }); return { ...d }; },
|
||||||
|
delete: async (id) => {
|
||||||
|
const i = edges.findIndex(e => e.id === id);
|
||||||
|
if (i >= 0) edges.splice(i, 1);
|
||||||
|
},
|
||||||
|
get rows() { return edges; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('importDirectory creates new resources and edges', async () => {
|
||||||
|
const s = makeStore();
|
||||||
|
const exportData = {
|
||||||
|
resources: [
|
||||||
|
{ id: 'r1', kind: 'site', name: 'Main Office', slug: 'site_main-office', metadata: { address: '10.0.0.1' } },
|
||||||
|
{ id: 'r2', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: { ip: '10.0.0.10' } }
|
||||||
|
],
|
||||||
|
edges: [{ id: 'e1', parentId: 'r1', childId: 'r2', relation: 'contains' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
||||||
|
|
||||||
|
expect(s.Resource.rows.length).toBe(2);
|
||||||
|
expect(s.ResourceEdge.rows.length).toBe(1);
|
||||||
|
expect(res.created).toBe(2);
|
||||||
|
expect(res.updated).toBe(0);
|
||||||
|
expect(res.edgeCount).toBe(1);
|
||||||
|
expect(s.Resource.rows[0].slug).toBe('site_main-office');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importDirectory updates existing resources by slug (master is authoritative)', async () => {
|
||||||
|
const s = makeStore();
|
||||||
|
await s.Resource.create({ id: 'local1', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: {} });
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
resources: [
|
||||||
|
{ id: 'r2', kind: 'host', name: 'web01-new', slug: 'host_web-01', metadata: { ip: '10.0.0.9' } }
|
||||||
|
],
|
||||||
|
edges: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
||||||
|
|
||||||
|
expect(s.Resource.rows.length).toBe(1); // upsert, not duplicate
|
||||||
|
expect(s.Resource.rows[0].name).toBe('web01-new');
|
||||||
|
expect(res.updated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importDirectory clears stale edges then recreates from master', async () => {
|
||||||
|
const s = makeStore();
|
||||||
|
await s.Resource.create({ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} });
|
||||||
|
await s.Resource.create({ id: 'r2', kind: 'host', name: 'old', slug: 'host_old', metadata: {} });
|
||||||
|
await s.ResourceEdge.create({ id: 'stale', parentId: 'r1', childId: 'r2', relation: 'contains' });
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
resources: [
|
||||||
|
{ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} },
|
||||||
|
{ id: 'r3', kind: 'host', name: 'new', slug: 'host_new', metadata: {} }
|
||||||
|
],
|
||||||
|
edges: [{ id: 'e9', parentId: 'r1', childId: 'r3', relation: 'contains' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
||||||
|
|
||||||
|
const edgeIds = s.ResourceEdge.rows.map(e => e.id);
|
||||||
|
expect(edgeIds).toContain('e9');
|
||||||
|
expect(edgeIds).not.toContain('stale');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scalarResource strips relation fields but keeps metadata', () => {
|
||||||
|
const o = scalarResource({
|
||||||
|
id: 'x', kind: 'host', name: 'n', slug: 's', metadata: { a: 1 },
|
||||||
|
edgesAsParent: [1], edgesAsChild: [2], groups: [3],
|
||||||
|
toJSON() { return this; }
|
||||||
|
});
|
||||||
|
expect(o.edgesAsParent).toBeUndefined();
|
||||||
|
expect(o.edgesAsChild).toBeUndefined();
|
||||||
|
expect(o.groups).toBeUndefined();
|
||||||
|
expect(o.metadata.a).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scalarEdge keeps parentId/childId/relation', () => {
|
||||||
|
const e = scalarEdge({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains', toJSON() { return this; } });
|
||||||
|
expect(e).toEqual({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ldapAddArgs builds a continue-on-error admin bind', () => {
|
||||||
|
const a = ldapAddArgs({ bindDN: 'cn=admin,dc=example,dc=com', ldapCred: 'test-bind', ldifFile: '/tmp/x.ldif' });
|
||||||
|
expect(a).toContain('-c');
|
||||||
|
expect(a).toContain('-x');
|
||||||
|
expect(a).toContain('cn=admin,dc=example,dc=com');
|
||||||
|
expect(a).toContain('/tmp/x.ldif');
|
||||||
|
expect(a.indexOf('-D') < a.indexOf('-w')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('baseDnFrom prefers stack.ldapBaseDn and falls back to the bind DN', () => {
|
||||||
|
expect(baseDnFrom({ stack: { ldapBaseDn: 'dc=stack,dc=com' } })).toBe('dc=stack,dc=com');
|
||||||
|
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin,dc=example,dc=com' } })).toBe('dc=example,dc=com');
|
||||||
|
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin' } })).toBe('');
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Persisted multi-site role (MULTI_SITE_SPEC.md). Whether this node is the
|
||||||
|
// master authority, which site it belongs to, and the master it replicates
|
||||||
|
// from live in /config/site.json so they survive restarts (the old code kept
|
||||||
|
// them in Node memory, so a container recreate silently reverted a spoke back
|
||||||
|
// to "master").
|
||||||
|
//
|
||||||
|
// Boot-time defaults come from the environment (IS_MASTER / MASTER_URL /
|
||||||
|
// SITE_SLUG, which docker-compose passes); a written site.json overrides for
|
||||||
|
// the life of the deployment. site-promote and the site-join flow both write
|
||||||
|
// here.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Overridable so tests can point at a temp file instead of /config/site.json.
|
||||||
|
function configFile() {
|
||||||
|
return process.env.SITE_CONFIG_FILE || '/config/site.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function envDefaults() {
|
||||||
|
return {
|
||||||
|
isMaster: process.env.IS_MASTER ? process.env.IS_MASTER === 'true' : true,
|
||||||
|
masterUrl: process.env.MASTER_URL || '',
|
||||||
|
siteSlug: process.env.SITE_SLUG || 'site-default',
|
||||||
|
wanConnected: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const env = envDefaults();
|
||||||
|
const file = configFile();
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
return { ...env, ...saved };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[site] could not read ' + file + ': ' + e.message);
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the current site config.
|
||||||
|
function get() {
|
||||||
|
if (!current) current = load();
|
||||||
|
return { ...current };
|
||||||
|
}
|
||||||
|
|
||||||
|
// save merges a patch and persists it to the site config file.
|
||||||
|
function save(patch) {
|
||||||
|
current = { ...get(), ...patch };
|
||||||
|
const file = configFile();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, JSON.stringify(current, null, 2) + '\n');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[site] could not write ' + file + ': ' + e.message);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return get();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { get, save, configFile };
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Pure, testable helpers for the multi-site join flow (MULTI_SITE_SPEC.md).
|
||||||
|
// routes/api_site.js wires these to Express + the live models + slapcat/ldapadd;
|
||||||
|
// tests exercise importDirectory with in-memory model stubs.
|
||||||
|
|
||||||
|
// scalarResource reduces a Resource row to its scalar columns so it can be
|
||||||
|
// re-created on a spoke without dragging hasMany relation fields along.
|
||||||
|
function scalarResource(r) {
|
||||||
|
const o = (r && r.toJSON) ? r.toJSON() : (r || {});
|
||||||
|
return {
|
||||||
|
id: o.id,
|
||||||
|
kind: o.kind,
|
||||||
|
name: o.name,
|
||||||
|
slug: o.slug,
|
||||||
|
owner: o.owner || null,
|
||||||
|
description: o.description || null,
|
||||||
|
metadata: o.metadata || {},
|
||||||
|
created_by: o.created_by || null,
|
||||||
|
created_on: o.created_on || null,
|
||||||
|
updated_by: o.updated_by || null,
|
||||||
|
updated_on: o.updated_on || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalarEdge(e) {
|
||||||
|
const o = (e && e.toJSON) ? e.toJSON() : (e || {});
|
||||||
|
return {
|
||||||
|
id: o.id,
|
||||||
|
parentId: o.parentId,
|
||||||
|
childId: o.childId,
|
||||||
|
relation: o.relation
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// importDirectory adopts a master's resource catalog into the local SQLite
|
||||||
|
// store. Resources are upserted by slug (create if absent, update if a local
|
||||||
|
// row already exists — the master is authoritative for the shared catalog),
|
||||||
|
// then all edges are recreated. Model stubs are injected for testability.
|
||||||
|
async function importDirectory({ Resource, ResourceEdge, exportData }) {
|
||||||
|
const resources = (exportData && exportData.resources) || [];
|
||||||
|
const edges = (exportData && exportData.edges) || [];
|
||||||
|
|
||||||
|
const bySlug = {};
|
||||||
|
try {
|
||||||
|
const existing = await Resource.list();
|
||||||
|
(existing || []).forEach(r => { bySlug[r.slug] = r; });
|
||||||
|
} catch (e) {
|
||||||
|
// Resource.list is unavailable (fresh DB?) — treat as empty.
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
for (const raw of resources) {
|
||||||
|
const s = scalarResource(raw);
|
||||||
|
if (!s.slug) continue;
|
||||||
|
const local = bySlug[s.slug];
|
||||||
|
if (local) {
|
||||||
|
try { await Resource.update(local.id, s); updated++; } catch (e) { /* row raced; ignore */ }
|
||||||
|
} else {
|
||||||
|
try { await Resource.create(s); created++; } catch (e) { /* duplicate-slug race; ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edges: clear + recreate so the graph matches the master exactly.
|
||||||
|
try {
|
||||||
|
const existingEdges = await ResourceEdge.list();
|
||||||
|
for (const e of existingEdges || []) {
|
||||||
|
await ResourceEdge.delete(e.id).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
let edgeCount = 0;
|
||||||
|
for (const raw of edges) {
|
||||||
|
const s = scalarEdge(raw);
|
||||||
|
if (!s.parentId || !s.childId) continue;
|
||||||
|
try { await ResourceEdge.create({ id: s.id, parentId: s.parentId, childId: s.childId, relation: s.relation || 'runs_on' }); edgeCount++; } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, edgeCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ldapAddArgs builds the argv for importing an LDIF into the local slapd with
|
||||||
|
// the app's admin bind. `-c` continues past "entry already exists" (the spoke
|
||||||
|
// keeps its own cn=admin / base DN).
|
||||||
|
function ldapAddArgs({ bindDN, ldapCred, ldifFile, ldapUrl }) {
|
||||||
|
return [
|
||||||
|
'-c', '-x',
|
||||||
|
'-H', ldapUrl || 'ldap://localhost',
|
||||||
|
'-D', bindDN,
|
||||||
|
'-w', ldapCred,
|
||||||
|
'-f', ldifFile
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseDnFrom derives the LDAP base DN from the app's admin bindDN
|
||||||
|
// (cn=admin,dc=example,dc=com -> dc=example,dc=com) unless the stack config
|
||||||
|
// already provides it (conf.stack.ldapBaseDn, written by setup.sh).
|
||||||
|
function baseDnFrom(conf) {
|
||||||
|
if (conf.stack && conf.stack.ldapBaseDn) return conf.stack.ldapBaseDn;
|
||||||
|
const m = String((conf.ldap && conf.ldap.bindDN) || '').match(/^cn=[^,]+,(.+)$/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom };
|
||||||
+51
-51
@@ -63,7 +63,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<div class="input-group input-group-sm shadow-sm" style="width: 230px;">
|
<div class="input-group input-group-sm shadow-sm" style="width: 230px;">
|
||||||
<span class="input-group-text" title="What can this user reach?"><i class="fa-solid fa-user-shield"></i></span>
|
<span class="input-group-text" title="What can this user reach?"><i class="fa-solid fa-user-shield"></i></span>
|
||||||
<input type="text" id="user-access-uid" class="form-control" placeholder="uid…" list="access-uid-list"
|
<input type="text" id="user-access-uid" class="form-control" placeholder="uid…" list="access-uid-list"
|
||||||
onkeydown="if(event.key==='Enter'){openUserAccessModal();}">
|
onkeydown="if(event.key==='Enter'){openUserAccessModal();}">
|
||||||
<datalist id="access-uid-list"></datalist>
|
<datalist id="access-uid-list"></datalist>
|
||||||
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
|
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
|
||||||
@@ -314,14 +314,14 @@
|
|||||||
<label class="form-label font-weight-bold"><i class="fa-solid fa-wand-magic-sparkles me-1 text-primary"></i>Preset Subtype Template</label>
|
<label class="form-label font-weight-bold"><i class="fa-solid fa-wand-magic-sparkles me-1 text-primary"></i>Preset Subtype Template</label>
|
||||||
<select id="res-subtype-template" class="form-select shadow-sm me-2 mb-2" onchange="applySubtypeTemplate()">
|
<select id="res-subtype-template" class="form-select shadow-sm me-2 mb-2" onchange="applySubtypeTemplate()">
|
||||||
<option value="">-- Custom / Manual --</option>
|
<option value="">-- Custom / Manual --</option>
|
||||||
<option value="docker">📦 Docker Engine / Container</option>
|
<option value="docker">📦 Docker Engine / Container</option>
|
||||||
<option value="systemd">âš™ï¸ Linux Systemd Service</option>
|
<option value="systemd">⚙️ Linux Systemd Service</option>
|
||||||
<option value="postgresql">ðŸ—„ï¸ PostgreSQL Database</option>
|
<option value="postgresql">🗄️ PostgreSQL Database</option>
|
||||||
<option value="redis">🔴 Redis In-Memory Cache</option>
|
<option value="redis">🔴 Redis In-Memory Cache</option>
|
||||||
<option value="proxmox">ðŸ–¥ï¸ Proxmox VE Hypervisor</option>
|
<option value="proxmox">🖥️ Proxmox VE Hypervisor</option>
|
||||||
<option value="wireguard">ðŸ›¡ï¸ WireGuard VPN Tunnel</option>
|
<option value="wireguard">🛡️ WireGuard VPN Tunnel</option>
|
||||||
<option value="unifi">📶 UniFi Network Controller</option>
|
<option value="unifi">📶 UniFi Network Controller</option>
|
||||||
<option value="k8s">â˜¸ï¸ Kubernetes Cluster Node</option>
|
<option value="k8s">☸️ Kubernetes Cluster Node</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="form-label">Sub Type</label>
|
<label class="form-label">Sub Type</label>
|
||||||
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. docker, systemd, postgresql...">
|
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. docker, systemd, postgresql...">
|
||||||
@@ -602,7 +602,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning border-0 shadow-sm p-2 small mt-2 mb-0" id="gen-secret-notice" style="display:none">
|
<div class="alert alert-warning border-0 shadow-sm p-2 small mt-2 mb-0" id="gen-secret-notice" style="display:none">
|
||||||
<i class="fa-solid fa-shield-halved text-warning me-1"></i> Generated secret is shown in the field above. Click <strong>Save Secret</strong> to store in OpenBao — secret values will not be displayed again once saved.
|
<i class="fa-solid fa-shield-halved text-warning me-1"></i> Generated secret is shown in the field above. Click <strong>Save Secret</strong> to store in OpenBao — secret values will not be displayed again once saved.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -718,7 +718,7 @@
|
|||||||
|
|
||||||
rawResources = [];
|
rawResources = [];
|
||||||
for (const r of resResources.results) {
|
for (const r of resResources.results) {
|
||||||
r.hostName = '—';
|
r.hostName = '—';
|
||||||
r.parentId = null;
|
r.parentId = null;
|
||||||
const parentEdge = allEdges.find(e => e.childId === r.id);
|
const parentEdge = allEdges.find(e => e.childId === r.id);
|
||||||
if (parentEdge) {
|
if (parentEdge) {
|
||||||
@@ -770,13 +770,13 @@
|
|||||||
}
|
}
|
||||||
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
|
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
|
||||||
if (!a.isOnline) {
|
if (!a.isOnline) {
|
||||||
const seen = (a.lastSeen || a.last_seen) ? ' — last seen ' + timeAgo(a.lastSeen || new Date(a.last_seen * 1000).toISOString()) : '';
|
const seen = (a.lastSeen || a.last_seen) ? ' — last seen ' + timeAgo(a.lastSeen || new Date(a.last_seen * 1000).toISOString()) : '';
|
||||||
n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
|
n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
|
||||||
}
|
}
|
||||||
const t = a.lastTelemetry || {};
|
const t = a.lastTelemetry || {};
|
||||||
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
|
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
|
||||||
n.agentColor = high ? '#ffc107' : '#198754';
|
n.agentColor = high ? '#ffc107' : '#198754';
|
||||||
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
|
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent tab body for the resource modal.
|
// Agent tab body for the resource modal.
|
||||||
@@ -1112,7 +1112,7 @@
|
|||||||
async function openUserAccessModal() {
|
async function openUserAccessModal() {
|
||||||
const uid = ($('#user-access-uid').val() || '').trim();
|
const uid = ($('#user-access-uid').val() || '').trim();
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
app.modal.open({ title: 'Access for ' + uid, bodyHtml: '<p class="text-muted">Loading…</p>' });
|
app.modal.open({ title: 'Access for ' + uid, bodyHtml: '<p class="text-muted">Loading…</p>' });
|
||||||
try {
|
try {
|
||||||
const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid));
|
const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid));
|
||||||
const data = res.results;
|
const data = res.results;
|
||||||
@@ -1202,7 +1202,7 @@
|
|||||||
}
|
}
|
||||||
n.indentHtml = indentHtml;
|
n.indentHtml = indentHtml;
|
||||||
n.depth = depth;
|
n.depth = depth;
|
||||||
n.displayName = (n.name && n.name.length > 16) ? n.name.substring(0, 16) + '…' : (n.name || '');
|
n.displayName = (n.name && n.name.length > 16) ? n.name.substring(0, 16) + '…' : (n.name || '');
|
||||||
// A leaf gets a spacer of the same width, so names stay aligned down
|
// A leaf gets a spacer of the same width, so names stay aligned down
|
||||||
// the column instead of jittering by whether a row has children.
|
// the column instead of jittering by whether a row has children.
|
||||||
n.caretHtml = n.children.length
|
n.caretHtml = n.children.length
|
||||||
@@ -1231,7 +1231,7 @@
|
|||||||
applyTreeCollapse();
|
applyTreeCollapse();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Collapsible tree ───────────────────────────────────────────────────────
|
// ── Collapsible tree ───────────────────────────────────────────────────────
|
||||||
// Which nodes are collapsed, by resource id. Persisted so the shape of the
|
// Which nodes are collapsed, by resource id. Persisted so the shape of the
|
||||||
// tree survives a refresh (and the Directory self-heal reload that follows
|
// tree survives a refresh (and the Directory self-heal reload that follows
|
||||||
// most edits) -- a tree that re-expands every time is worse than no tree.
|
// most edits) -- a tree that re-expands every time is worse than no tree.
|
||||||
@@ -1506,14 +1506,14 @@
|
|||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<p class="small text-muted mb-2">Search and select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:</p>
|
<p class="small text-muted mb-2">Search and select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:</p>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<input type="text" class="form-control mb-2 shadow-sm" id="merge-search-input" placeholder="🔠Type to filter resources..." oninput="filterMergeTargets()">
|
<input type="text" class="form-control mb-2 shadow-sm" id="merge-search-input" placeholder="🔍 Type to filter resources..." oninput="filterMergeTargets()">
|
||||||
<input type="hidden" id="merge-target-id" value="${targets[0] ? targets[0].id : ''}">
|
<input type="hidden" id="merge-target-id" value="${targets[0] ? targets[0].id : ''}">
|
||||||
<div class="list-group overflow-auto border rounded shadow-sm" id="merge-targets-list" style="max-height: 240px;">
|
<div class="list-group overflow-auto border rounded shadow-sm" id="merge-targets-list" style="max-height: 240px;">
|
||||||
${targets.map((r, i) => `
|
${targets.map((r, i) => `
|
||||||
<button type="button" class="list-group-item list-group-item-action p-2 merge-item-btn ${i===0 ? 'active' : ''}" data-id="${r.id}" onclick="selectMergeTarget(this)">
|
<button type="button" class="list-group-item list-group-item-action p-2 merge-item-btn ${i===0 ? 'active' : ''}" data-id="${r.id}" onclick="selectMergeTarget(this)">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<strong>${esc(r.name)}</strong>
|
<strong>${esc(r.name)}</strong>
|
||||||
<span class="badge bg-secondary">${esc(r.kind)}${r.metadata?.subType ? ' · ' + esc(r.metadata.subType) : ''}</span>
|
<span class="badge bg-secondary">${esc(r.kind)}${r.metadata?.subType ? ' · ' + esc(r.metadata.subType) : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
${r.metadata?.ip ? `<small class="font-monospace text-muted d-block">${esc(r.metadata.ip)}</small>` : ''}
|
${r.metadata?.ip ? `<small class="font-monospace text-muted d-block">${esc(r.metadata.ip)}</small>` : ''}
|
||||||
</button>
|
</button>
|
||||||
@@ -1915,7 +1915,7 @@
|
|||||||
$select.append('<option value="">-- Select Parent Resource Secret --</option>');
|
$select.append('<option value="">-- Select Parent Resource Secret --</option>');
|
||||||
currentParentSecretsList.forEach(p => {
|
currentParentSecretsList.forEach(p => {
|
||||||
const valStr = `INHERIT:${p.parentSlug}:${p.key}`;
|
const valStr = `INHERIT:${p.parentSlug}:${p.key}`;
|
||||||
const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`;
|
const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`;
|
||||||
$select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
|
$select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2064,7 +2064,7 @@
|
|||||||
await loadResources();
|
await loadResources();
|
||||||
loadDiscoveryResources();
|
loadDiscoveryResources();
|
||||||
app.modal.close();
|
app.modal.close();
|
||||||
app.messages.toast('Promoted ' + slug + (res && res.groups ? ' — created groups: ' + res.groups.join(', ') : ''), 'success');
|
app.messages.toast('Promoted ' + slug + (res && res.groups ? ' — created groups: ' + res.groups.join(', ') : ''), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
promoteSlug = slug;
|
promoteSlug = slug;
|
||||||
app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger');
|
app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger');
|
||||||
@@ -2232,7 +2232,7 @@
|
|||||||
|
|
||||||
async function deleteResource(id) {
|
async function deleteResource(id) {
|
||||||
// Called from the outer table's row button, not from inside the resource
|
// Called from the outer table's row button, not from inside the resource
|
||||||
// modal — target the page's own card so the confirm/error renders
|
// modal — target the page's own card so the confirm/error renders
|
||||||
// somewhere actually visible.
|
// somewhere actually visible.
|
||||||
const $target = $('#resources-list');
|
const $target = $('#resources-list');
|
||||||
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
|
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
|
||||||
@@ -2343,14 +2343,14 @@
|
|||||||
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
|
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
|
||||||
} else if (selectedKeyId) {
|
} else if (selectedKeyId) {
|
||||||
const k = agentJoinKeys.find(x => x.id === selectedKeyId);
|
const k = agentJoinKeys.find(x => x.id === selectedKeyId);
|
||||||
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}…"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
|
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}…"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
|
||||||
} else {
|
} else {
|
||||||
joinCmd = '# Create a join key above, or select one you already have the value for.';
|
joinCmd = '# Create a join key above, or select one you already have the value for.';
|
||||||
}
|
}
|
||||||
$('#agent-join-command').text(joinCmd);
|
$('#agent-join-command').text(joinCmd);
|
||||||
|
|
||||||
// Windows: the installer is a GitHub release artifact (built by the
|
// Windows: the installer is a GitHub release artifact (built by the
|
||||||
// theta-agent release workflow — nothing binary lives in this repo). It
|
// theta-agent release workflow — nothing binary lives in this repo). It
|
||||||
// takes the same values as /SERVER_URL /JOIN_KEY command-line params.
|
// takes the same values as /SERVER_URL /JOIN_KEY command-line params.
|
||||||
const WIN_SETUP = 'theta-agent-2.1.0-windows-amd64-setup.exe';
|
const WIN_SETUP = 'theta-agent-2.1.0-windows-amd64-setup.exe';
|
||||||
const WIN_SETUP_URL = `https://github.com/theta42/theta-agent/releases/latest/download/${WIN_SETUP}`;
|
const WIN_SETUP_URL = `https://github.com/theta42/theta-agent/releases/latest/download/${WIN_SETUP}`;
|
||||||
@@ -2473,7 +2473,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content mb-3">
|
<div class="tab-content mb-3">
|
||||||
<!-- ── Join key: one credential, host enrolls itself ────────────── -->
|
<!-- ── Join key: one credential, host enrolls itself ────────────── -->
|
||||||
<div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
|
<div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
|
||||||
<div class="card border-success">
|
<div class="card border-success">
|
||||||
<div class="card-header py-2 fw-bold small bg-success-subtle">
|
<div class="card-header py-2 fw-bold small bg-success-subtle">
|
||||||
@@ -2483,14 +2483,14 @@
|
|||||||
<p class="small text-muted mb-3">
|
<p class="small text-muted mb-3">
|
||||||
Run this on any host and it enrolls itself. The SSO issues that host its own
|
Run this on any host and it enrolls itself. The SSO issues that host its own
|
||||||
token and public key on first connect, and the agent writes both into its
|
token and public key on first connect, and the agent writes both into its
|
||||||
<code>agent.yml</code> — nothing to copy back and forth. One key works for as
|
<code>agent.yml</code> — nothing to copy back and forth. One key works for as
|
||||||
many hosts as you like; each still gets its own revocable identity.
|
many hosts as you like; each still gets its own revocable identity.
|
||||||
</p>
|
</p>
|
||||||
<div class="d-flex gap-2 align-items-end mb-3">
|
<div class="d-flex gap-2 align-items-end mb-3">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<label class="form-label small fw-bold mb-1">Existing join keys</label>
|
<label class="form-label small fw-bold mb-1">Existing join keys</label>
|
||||||
<select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select>
|
<select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select>
|
||||||
<div class="form-text small">A key's value is shown only when it is created — mint a new one if you don't have it saved.</div>
|
<div class="form-text small">A key's value is shown only when it is created — mint a new one if you don't have it saved.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
|
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
|
||||||
<i class="fa-solid fa-plus me-1"></i> New join key
|
<i class="fa-solid fa-plus me-1"></i> New join key
|
||||||
@@ -2536,7 +2536,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
|
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
|
||||||
<div class="tab-pane fade" id="agent-mode-pre" role="tabpanel">
|
<div class="tab-pane fade" id="agent-mode-pre" role="tabpanel">
|
||||||
|
|
||||||
<div class="card border-primary mb-3" id="agent-enroll-card">
|
<div class="card border-primary mb-3" id="agent-enroll-card">
|
||||||
@@ -2583,7 +2583,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content" id="agent-install-tab-content">
|
<div class="tab-content" id="agent-install-tab-content">
|
||||||
<!-- ── Tab 1: Quick Install ──────────────────────────────────────── -->
|
<!-- ── Tab 1: Quick Install ──────────────────────────────────────── -->
|
||||||
<div class="tab-pane fade show active" id="tab-quick-pane" role="tabpanel">
|
<div class="tab-pane fade show active" id="tab-quick-pane" role="tabpanel">
|
||||||
<div class="row g-2 mb-3">
|
<div class="row g-2 mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -2616,7 +2616,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── -->
|
<!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── -->
|
||||||
<div class="tab-pane fade" id="tab-custom-pane" role="tabpanel">
|
<div class="tab-pane fade" id="tab-custom-pane" role="tabpanel">
|
||||||
<div class="row g-2 mb-3">
|
<div class="row g-2 mb-3">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
@@ -2717,12 +2717,12 @@
|
|||||||
// Only hosts can carry an agent -- the API rejects anything else, so don't
|
// Only hosts can carry an agent -- the API rejects anything else, so don't
|
||||||
// offer it here.
|
// offer it here.
|
||||||
const $sel = $('#agent-enroll-resource').empty();
|
const $sel = $('#agent-enroll-resource').empty();
|
||||||
$sel.append('<option value="">(not bound — bind later)</option>');
|
$sel.append('<option value="">(not bound — bind later)</option>');
|
||||||
rawResources
|
rawResources
|
||||||
.filter(r => r.kind === 'host')
|
.filter(r => r.kind === 'host')
|
||||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||||
.forEach(r => {
|
.forEach(r => {
|
||||||
const taken = agentsByResource[r.id] ? ' — already has an agent' : '';
|
const taken = agentsByResource[r.id] ? ' — already has an agent' : '';
|
||||||
$sel.append($('<option>').val(r.id).text((r.name || r.slug) + taken).prop('disabled', !!agentsByResource[r.id]));
|
$sel.append($('<option>').val(r.id).text((r.name || r.slug) + taken).prop('disabled', !!agentsByResource[r.id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2749,12 +2749,12 @@
|
|||||||
|
|
||||||
const $sel = $('#agent-join-key-select').empty();
|
const $sel = $('#agent-join-key-select').empty();
|
||||||
if (!agentJoinKeys.length) {
|
if (!agentJoinKeys.length) {
|
||||||
$sel.append('<option value="">No join keys yet — create one</option>');
|
$sel.append('<option value="">No join keys yet — create one</option>');
|
||||||
} else {
|
} else {
|
||||||
$sel.append('<option value="">Select a key…</option>');
|
$sel.append('<option value="">Select a key…</option>');
|
||||||
agentJoinKeys.forEach(k => {
|
agentJoinKeys.forEach(k => {
|
||||||
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused';
|
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused';
|
||||||
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
|
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2764,7 +2764,7 @@
|
|||||||
$tbody.append('<tr><td colspan="6" class="text-muted">No join keys yet.</td></tr>');
|
$tbody.append('<tr><td colspan="6" class="text-muted">No join keys yet.</td></tr>');
|
||||||
} else {
|
} else {
|
||||||
agentJoinKeysAll.forEach(k => {
|
agentJoinKeysAll.forEach(k => {
|
||||||
const created = k.created_on ? new Date(k.created_on * 1000).toLocaleDateString() : '—';
|
const created = k.created_on ? new Date(k.created_on * 1000).toLocaleDateString() : '—';
|
||||||
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : '0 hosts';
|
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : '0 hosts';
|
||||||
const status = k.revoked
|
const status = k.revoked
|
||||||
? '<span class="badge bg-secondary">Revoked</span>'
|
? '<span class="badge bg-secondary">Revoked</span>'
|
||||||
@@ -2773,7 +2773,7 @@
|
|||||||
`<button class="btn btn-outline-warning btn-sm" title="Revoke -- stops it enrolling new hosts; already-joined hosts are unaffected" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'revoke')"><i class="fa-solid fa-ban"></i></button>`;
|
`<button class="btn btn-outline-warning btn-sm" title="Revoke -- stops it enrolling new hosts; already-joined hosts are unaffected" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'revoke')"><i class="fa-solid fa-ban"></i></button>`;
|
||||||
const $row = $('<tr>').attr('data-join-key-row', k.id).append(
|
const $row = $('<tr>').attr('data-join-key-row', k.id).append(
|
||||||
$('<td>').text(k.label),
|
$('<td>').text(k.label),
|
||||||
$('<td>').append($('<code>').text(k.keyPrefix + '…')),
|
$('<td>').append($('<code>').text(k.keyPrefix + '…')),
|
||||||
$('<td>').text(created),
|
$('<td>').text(created),
|
||||||
$('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })),
|
$('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })),
|
||||||
$('<td>').html(status),
|
$('<td>').html(status),
|
||||||
@@ -2792,7 +2792,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function viewAgentJoinKeyHosts(id, label) {
|
async function viewAgentJoinKeyHosts(id, label) {
|
||||||
const $out = $('#agent-join-key-hosts').show().html('<i class="fa-solid fa-spinner fa-spin"></i> Loading…');
|
const $out = $('#agent-join-key-hosts').show().html('<i class="fa-solid fa-spinner fa-spin"></i> Loading…');
|
||||||
try {
|
try {
|
||||||
const res = await app.api.get(`agent/join-keys/${id}/agents`);
|
const res = await app.api.get(`agent/join-keys/${id}/agents`);
|
||||||
const body = (res && (res.results || res)) || {};
|
const body = (res && (res.results || res)) || {};
|
||||||
@@ -2804,7 +2804,7 @@
|
|||||||
const rows = agents.map(a => {
|
const rows = agents.map(a => {
|
||||||
const dot = a.isOnline ? 'text-success' : 'text-muted';
|
const dot = a.isOnline ? 'text-success' : 'text-muted';
|
||||||
const seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
|
const seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
|
||||||
return `<tr><td><i class="fa-solid fa-circle ${dot}" style="font-size:8px"></i> ${esc(a.name)}</td><td>${esc(a.enrolled_on ? new Date(a.enrolled_on * 1000).toLocaleDateString() : '—')}</td><td>${esc(seen)}</td></tr>`;
|
return `<tr><td><i class="fa-solid fa-circle ${dot}" style="font-size:8px"></i> ${esc(a.name)}</td><td>${esc(a.enrolled_on ? new Date(a.enrolled_on * 1000).toLocaleDateString() : '—')}</td><td>${esc(seen)}</td></tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
$out.html(
|
$out.html(
|
||||||
`<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` +
|
`<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` +
|
||||||
@@ -2857,7 +2857,7 @@
|
|||||||
$('#agent-join-key-result').show().html(
|
$('#agent-join-key-result').show().html(
|
||||||
'<div class="alert alert-success py-2 small mb-3">'
|
'<div class="alert alert-success py-2 small mb-3">'
|
||||||
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> '
|
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> '
|
||||||
+ 'It is shown <strong>once</strong> — only its hash is stored. It is already in the command below.'
|
+ 'It is shown <strong>once</strong> — only its hash is stored. It is already in the command below.'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<label class="form-label small fw-bold mb-1">Join key</label>'
|
+ '<label class="form-label small fw-bold mb-1">Join key</label>'
|
||||||
+ '<div class="input-group input-group-sm mb-3">'
|
+ '<div class="input-group input-group-sm mb-3">'
|
||||||
@@ -2881,7 +2881,7 @@
|
|||||||
app.messages.toast('Give the agent a name first.', 'warning');
|
app.messages.toast('Give the agent a name first.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const $btn = $('#agent-enroll-btn').prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin me-1"></i> Enrolling…');
|
const $btn = $('#agent-enroll-btn').prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin me-1"></i> Enrolling…');
|
||||||
try {
|
try {
|
||||||
const res = await app.api.post('agent/enroll', { name, resourceId });
|
const res = await app.api.post('agent/enroll', { name, resourceId });
|
||||||
const body = res && (res.results || res);
|
const body = res && (res.results || res);
|
||||||
@@ -2904,7 +2904,7 @@
|
|||||||
keyWarn +
|
keyWarn +
|
||||||
'<div class="alert alert-success py-2 small mb-2">'
|
'<div class="alert alert-success py-2 small mb-2">'
|
||||||
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Enrolled.</strong> '
|
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Enrolled.</strong> '
|
||||||
+ 'This token is shown <strong>once</strong> — only its hash is stored. '
|
+ 'This token is shown <strong>once</strong> — only its hash is stored. '
|
||||||
+ 'If you lose it, rotate the agent to issue a new one.</div>'
|
+ 'If you lose it, rotate the agent to issue a new one.</div>'
|
||||||
+ '<label class="form-label small fw-bold mb-1">Agent token</label>'
|
+ '<label class="form-label small fw-bold mb-1">Agent token</label>'
|
||||||
+ '<div class="input-group input-group-sm mb-2">'
|
+ '<div class="input-group input-group-sm mb-2">'
|
||||||
@@ -2996,7 +2996,7 @@
|
|||||||
: '';
|
: '';
|
||||||
const log = p.lastLog || '(no log captured for this run)';
|
const log = p.lastLog || '(no log captured for this run)';
|
||||||
app.modal.open({
|
app.modal.open({
|
||||||
title: 'Run log — ' + (p.name || p.slug),
|
title: 'Run log — ' + (p.name || p.slug),
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
|
bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
|
||||||
});
|
});
|
||||||
@@ -3025,16 +3025,16 @@
|
|||||||
|
|
||||||
var discoveryPluginTypes = [];
|
var discoveryPluginTypes = [];
|
||||||
|
|
||||||
// ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
|
// ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
|
||||||
// Stored value is always a 5-field cron string; the dropdown picks a preset
|
// Stored value is always a 5-field cron string; the dropdown picks a preset
|
||||||
// and "Custom…" reveals the raw input. Config fields are driven by each
|
// and "Custom…" reveals the raw input. Config fields are driven by each
|
||||||
// plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
|
// plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
|
||||||
// tokenId / tokenSecret) are collected at create time.
|
// tokenId / tokenSecret) are collected at create time.
|
||||||
var DP_CRON_PRESETS = [
|
var DP_CRON_PRESETS = [
|
||||||
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
|
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
|
||||||
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
|
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
|
||||||
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
|
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
|
||||||
{ key: 'custom', label: 'Custom…', cron: null },
|
{ key: 'custom', label: 'Custom…', cron: null },
|
||||||
];
|
];
|
||||||
function dpCronKeyFor(cron) {
|
function dpCronKeyFor(cron) {
|
||||||
var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
|
var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
|
||||||
@@ -3107,7 +3107,7 @@
|
|||||||
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
|
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
|
||||||
var val = '';
|
var val = '';
|
||||||
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
|
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
|
||||||
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged — type a new value to replace"';
|
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged — type a new value to replace"';
|
||||||
html += '<div class="mb-3"><label class="form-label fw-bold">' + label + '</label>' +
|
html += '<div class="mb-3"><label class="form-label fw-bold">' + label + '</label>' +
|
||||||
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
|
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
|
||||||
}
|
}
|
||||||
@@ -3194,7 +3194,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-bold">Plugin Type</label>
|
<label class="form-label fw-bold">Plugin Type</label>
|
||||||
<input type="text" class="form-control" value="${esc(p.pluginType)}" disabled>
|
<input type="text" class="form-control" value="${esc(p.pluginType)}" disabled>
|
||||||
<div class="form-text">The type is fixed once an instance exists — create a new instance to use a different one.</div>
|
<div class="form-text">The type is fixed once an instance exists — create a new instance to use a different one.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-bold">Instance Name</label>
|
<label class="form-label fw-bold">Instance Name</label>
|
||||||
@@ -3216,7 +3216,7 @@
|
|||||||
<button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button>
|
<button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
app.modal.open({ title: 'Edit Discovery Plugin — ' + p.name, bodyHtml: bodyHtml, size: 'lg' });
|
app.modal.open({ title: 'Edit Discovery Plugin — ' + p.name, bodyHtml: bodyHtml, size: 'lg' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3301,7 +3301,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Multi-Site & Master Node Controls ─────────────────────────────────────
|
// ── Multi-Site & Master Node Controls ─────────────────────────────────────
|
||||||
async function refreshSiteStatus() {
|
async function refreshSiteStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await app.api.get('directory-admin/site-status');
|
const res = await app.api.get('directory-admin/site-status');
|
||||||
@@ -3329,7 +3329,7 @@
|
|||||||
'<div class="card mb-3 shadow-sm border-' + (isMaster ? 'warning' : 'info') + '">' +
|
'<div class="card mb-3 shadow-sm border-' + (isMaster ? 'warning' : 'info') + '">' +
|
||||||
'<div class="card-body">' +
|
'<div class="card-body">' +
|
||||||
'<h5 class="card-title d-flex align-items-center justify-content-between">' +
|
'<h5 class="card-title d-flex align-items-center justify-content-between">' +
|
||||||
'<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.</p>' +
|
||||||
@@ -3388,7 +3388,7 @@
|
|||||||
loadDiscoveryPlugins();
|
loadDiscoveryPlugins();
|
||||||
refreshSiteStatus();
|
refreshSiteStatus();
|
||||||
// Keep the host status dots live: refresh the agent join periodically and on
|
// Keep the host status dots live: refresh the agent join periodically and on
|
||||||
// socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub).
|
// socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub).
|
||||||
refreshAgents();
|
refreshAgents();
|
||||||
setInterval(refreshAgents, 30000);
|
setInterval(refreshAgents, 30000);
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user