diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df101f..4ba2098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +# v1.30.0 + +Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install. + +### theta-agent — enrollment without pre-registering + +- feat: **join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued **its own** per-agent token plus the public key it must pin, delivered in the `config` frame; the agent persists both and blanks the join key. v1.29.0 required an admin to pre-register every machine before its agent would be spoken to, which made adding a host a two-system chore — the security model was right, the workflow was not. +- feat: a join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable and a compromised host yields nothing that works elsewhere. Revoking a join key stops new hosts joining and leaves already-enrolled agents alone. +- feat: join keys support a label and optional expiry, record their use count, and are stored as a SHA-256 (`AgentJoinKey`). Issue/revoke/delete and every self-enrollment are audited. + +### Directory + +- fix: **collapsing the tree did nothing.** `applyTreeCollapse` located the caret with `$row.find('.tree-caret i')` and returned early when it found nothing. Font Awesome runs in SVG-with-JS mode and its mutation observer rewrites every `` into an ``, so moments after a render that selector matched nothing — and the early return skipped setting `hideBelowDepth`, so no row was ever hidden. Collapse state now lives on the caret *button* and is rotated by CSS, and the hide decision is made from the collapsed set alone. Never key behaviour to an element another library is free to replace. +- fix: **the Discovery Plugins delete button did nothing.** It called `deleteDiscoveryPlugin()`, which was never defined — clicking it only threw a `ReferenceError`. +- fix: the plugins pane had no `.actionMessage` element, and `app.messages` confirmations render into one. Without it the returned promise **never settles**, so an awaited confirmation hangs forever and the action it gates silently never happens. Added, along with a note that any pane asking for confirmation needs it. +- feat: **discovery plugin instances can be edited.** Name, schedule, loaded state and configuration, with secrets on their own endpoint and left blank ("unchanged") rather than prefilled with the mask — submitting `********` back would otherwise store the asterisks as the secret. + +### Discovery + +- fix: **a fresh install no longer presents its own containers as things to triage.** The Docker plugin recognises containers belonging to the stack's own compose project, records them as managed, and attaches each to the service it implements. `setup.sh` deploys `sso-manager`, `proxy`, `jump-host`, `openbao` and `bao-renewer`; all five arrived as unmanaged discoveries awaiting promotion. +- fix: **Docker container slugs were derived from the container id**, which changes on every recreate — so each `docker compose up` minted a brand-new resource and orphaned the previous one. Slugs now come from compose project + service, falling back to the container name. +- feat: discovered containers carry `composeProject`, `composeService`, `containerName` and `sourceId`. + +### Docs + +- fix: `/docs/discovery` 404'd — the slug had no entry, though the Discovery tab's help icon linked to it. New `docs/discovery.md` covering the catalog/discovered distinction, how sources are matched and merged, naming precedence, promotion and garbage collection. +- fix: the `agents` slug pointed at `plugins.md`, so `docs/agents.md` was unreachable in the app. + # v1.29.0 **Breaking:** theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the `sso-broker` OpenBao policy must grant `secret/agent/*`); re-run `./setup.sh`. diff --git a/docs/agents.md b/docs/agents.md index 40146db..a373a54 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -10,20 +10,59 @@ The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) e --- -## Enrollment (required) +## Enrollment -An agent is only real if the SSO issued its token. **Tokens the server did not -issue are rejected** at the WebSocket handshake. +An agent is only real if the SSO issued its credential. **Tokens the server did +not issue are rejected** at the WebSocket handshake. -Enroll from **Directory → Install Agent**: +There are two ways to get a host enrolled, and the first is the normal one. -1. Give the agent a name and, ideally, **bind it to a host resource**. The - binding is what links telemetry, status and commands to a Directory entry. +### Join key — install the agent and the host appears + +Hand the machine a **join key** and nothing else. On first connect the SSO +enrolls the host, issues it its own per-agent token plus the public key it must +pin, and the agent **writes both into its own `agent.yml`** and blanks the join +key. From then on it authenticates as itself. + +```bash +curl -fsSL https:///resources/theta-agent/install.sh | sh -s -- \ + --url "https://" --join-key "tjk_..." +``` + +That is the whole procedure — no pre-registering the machine, no copying a +public key by hand. `setup.sh` mints a key and configures the stack's own host +this way automatically. + +The join key is a *bootstrap* credential, not the host's identity. That +distinction is what keeps one key convenient without making it a fleet-wide +skeleton key: every host still ends up individually revocable, and a compromised +host does not yield a credential that works anywhere else. + +| Endpoint | Purpose | +| :--- | :--- | +| `GET /api/agent/join-keys` | List keys (prefix + usage only; never the key) | +| `POST /api/agent/join-keys` | Mint one — returned **once** | +| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts | +| `DELETE /api/agent/join-keys/:id` | Remove it | + +Revoking a join key does **not** disconnect hosts that already joined; they hold +their own tokens by then. Revoke the agent itself to cut a specific host off. + +### Pre-registering a host + +When you want the agent bound to a specific Directory host up front, enroll it +from **Directory → Install Agent**: + +1. Give the agent a name and **bind it to a host resource**. The binding is what + links telemetry, status and commands to a Directory entry. 2. Press **Enroll & issue token**. The SSO mints a 256-bit token, stores only its SHA-256, and shows the raw value **once**. 3. Copy the generated install command — it already carries the token and the server's public key. +A host that self-enrolls with a join key arrives unbound; bind it afterwards with +`PUT /api/agent/nodes/:id` or from the Directory. + Or via the API: ```bash @@ -186,8 +225,12 @@ curl -fsSL https:///resources/theta-agent/install.sh | sh -s -- " Nothing grants access to a discovered resource. It carries no groups until it +> is promoted, and the jump host applies the same rule — an unpromoted Proxmox +> guest is not a jump target. + +--- + +## Where discovered data comes from + +| Source | What it reports | +| :--- | :--- | +| [Proxmox](plugins.html) | The cluster endpoint, its nodes, and every VM/LXC with NICs, `vmid` and node | +| [UniFi](plugins.html) | Network devices and connected clients, by MAC | +| [nmap](plugins.html) | Hosts and open ports on a target range | +| [Docker](plugins.html) | Containers on a local or remote daemon | +| [theta-agent](agents.html) | The host it runs on — OS, kernel, CPU, RAM, disk, addresses | +| [ldap-client](directory.html) | A Linux host registering itself when it joins | + +An agent is the most authoritative of these: it runs *on* the machine it +describes. A network scan is the least — it only knows what answered. + +--- + +## How results are matched to existing resources + +Every source runs through one reconciler, so two sources seeing the same +machine converge on one resource instead of creating duplicates. Matching is +tried in order of precision: + +1. **MAC address** — the strongest signal, compared across every interface. +2. **IP address** — any address on any interface, plus `metadata.address`. +3. **Slug, name, or base hostname** — last resort. + +A candidate must also be **the same kind**. Without that guard a discovered VM +named `gitea-runner` would match a hand-created *service* of the same name on +rule 3 and overwrite it. (`template` counts as `host`: converting a VM to a +template is the same machine.) + +When a match is found the metadata is merged, interfaces are unioned by MAC, and +the source is added to `discovery_sources` — so a resource can legitimately read +`["unifi", "proxmox"]`, meaning two independent sources agree it exists. + +### Naming + +Sources disagree about names, so the most human one wins: a **hostname** beats +an **IP-shaped** name, which beats a **MAC-shaped** name; length is only a +tie-break within a rank. This is why a device UniFi knows only as +`ac:16:2d:b3:da:80` is renamed `dl380-0` once Proxmox reports it. + +### Relationships + +Plugins emit edges as well as resources (a Proxmox node under its cluster +endpoint, a guest under its node). The reconciler refuses any edge that would +make a resource its own parent, or that would close a loop — a cycle renders as +an infinitely nested tree and breaks every ancestor walk in the app. + +--- + +## Promoting a discovered resource + +On the **Discovered Inventory** tab, press **Promote**. The resource form opens +pre-filled with what was discovered — name, kind, address, subtype — so you can +correct it before committing. Saving marks it managed and provisions its +[LDAP groups](groups.html). + +Each row shows what the directory knows about the device: its source(s), its +`vmid` where applicable, the identifier it has at that source (`sourceId`, e.g. +`dl380-0/qemu/234`), and every interface with its MAC and address. If a row +looks wrong, that detail is where to start. + +--- + +## Stale results + +Resources that are *only* auto-discovered are garbage-collected: if a source +stops reporting one for long enough it is marked +`lifecycle_state: "archived"` rather than deleted. Anything you created or +promoted is never touched — `manual` in `discovery_sources` exempts it. + +A Proxmox node that is powered off is still reported (with its `status`), so +downtime does not look like decommissioning. + +--- + +## What the stack discovers about itself + +`setup.sh` seeds its own components as catalog resources — the site, the stack +host, `theta-proxy` and `theta-jump`, and the services under them. The Docker +discovery plugin then finds the containers backing them. Containers belonging to +the theta-suite compose project are recognised and attached to the service they +implement rather than appearing as unmanaged strangers, so a fresh install has an +empty Discovered Inventory rather than five things demanding attention. diff --git a/nodejs/models/agent.js b/nodejs/models/agent.js index e905197..94ba36f 100644 --- a/nodejs/models/agent.js +++ b/nodejs/models/agent.js @@ -108,4 +108,75 @@ class Agent extends Model { } } -module.exports = { Agent }; +// A join key: the one credential an operator hands out so a host can enroll +// itself. Requiring an admin to pre-register every machine before the agent +// would talk to them made adding a host a two-system chore -- installing the +// agent should be enough. +// +// A join key is NOT the agent's long-term credential. On first connect the +// server auto-enrolls the host and issues it a unique per-agent token, which +// the agent persists and uses from then on (PROTOCOL.md 1.2). That keeps the +// operator experience to "one key" while still giving every host its own +// revocable identity -- revoking a single agent means something, and a host +// that is compromised does not hand over the credential for the whole fleet. +class AgentJoinKey extends Model { + static hashKey(raw) { + return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex'); + } + + static generateKey() { + // `tjk_` so an operator can tell a join key from an agent token at a + // glance -- they are handled very differently. + return 'tjk_' + crypto.randomBytes(32).toString('hex'); + } + + // Resolve a presented key to a usable join key, or null. Expiry and + // revocation are both 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 = { Agent, AgentJoinKey }; diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 087a4be..20f0272 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -20,7 +20,7 @@ const { PluginInstance } = require('./plugin_instance'); const { SharedSecret } = require('./shared_secret'); const { SharedSecretGrant } = require('./shared_secret_grant'); const { VaultAppToken } = require('./vault_app_token'); -const { Agent } = require('./agent'); +const { Agent, AgentJoinKey } = require('./agent'); async function initORM() { const ormConf = conf.orm || { dialect: 'sqlite', @@ -35,7 +35,7 @@ async function initORM() { conf: { orm: ormConf }, models: [ Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance, - SharedSecret, SharedSecretGrant, VaultAppToken, Agent, + SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken ] }); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 1d32b26..e10fbe3 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.29.0", + "version": "1.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.29.0", + "version": "1.30.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 808bffc..1938bec 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.29.0", + "version": "1.30.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/plugins/discovery/docker.js b/nodejs/plugins/discovery/docker.js index 9b07d09..8d15c95 100644 --- a/nodejs/plugins/discovery/docker.js +++ b/nodejs/plugins/discovery/docker.js @@ -7,7 +7,15 @@ module.exports = { description: 'Discover running containers and networks from a local or remote Docker daemon.', configSchema: [ { key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' }, - { key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' } + { key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' }, + // Containers in this compose project are the stack's own. They are already + // represented in the catalog as services, so they are recorded as managed + // and linked to the service they implement instead of arriving as + // unmanaged strangers a fresh install has to triage. + { key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' }, + // The catalog host these containers run on, so they land in the tree + // instead of as roots. + { key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_' } ], validate: async (config) => { @@ -48,23 +56,57 @@ module.exports = { const resources = []; const edges = []; + const stackProject = (config.stackProject || '').trim(); + const hostSlug = (config.hostSlug || '').trim(); + for (const c of containers) { + const labels = c.Labels || {}; + const composeProject = labels['com.docker.compose.project'] || ''; + const composeService = labels['com.docker.compose.service'] || ''; const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12); - const slug = `docker-cnt-${c.Id.substring(0, 12)}`; - + + // A container id changes every time the container is recreated, + // so an id-derived slug made `docker compose up` mint a brand-new + // resource on every deploy and orphan the previous one. Prefer + // identifiers that survive a recreate: the compose project+service + // it belongs to, else its name. + const stableKey = composeProject && composeService + ? `${composeProject}-${composeService}` + : (name || c.Id.substring(0, 12)); + const slug = `docker-${stableKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`; + const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', '); - + const isOwnStack = !!(stackProject && composeProject === stackProject); + resources.push({ kind: 'container', - name: name, + name: composeService || name, slug: slug, metadata: { image: c.Image, state: c.State, status: c.Status, - ports: ports + ports: ports, + composeProject: composeProject || undefined, + composeService: composeService || undefined, + containerName: name, + sourceId: stableKey, + // Part of the deployment we are running inside: already + // accounted for, not something to promote. + managed: isOwnStack ? true : undefined } }); + + // Attach the container to the service it implements when the + // catalog already has one under that slug (the bootstrap seeds + // `sso-manager`, `proxy`, `jump-host`, … using the same names + // compose uses). The reconciler drops an edge whose parent does + // not resolve, so an unmatched name is simply not linked. + if (isOwnStack && composeService) { + edges.push({ parentSlug: composeService, childSlug: slug, relation: 'runs' }); + } else if (hostSlug) { + edges.push({ parentSlug: hostSlug, childSlug: slug, relation: 'hosts' }); + } } resolve({ resources, edges }); diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index 30d5f47..650cf1a 100644 --- a/nodejs/routes/api_agent.js +++ b/nodejs/routes/api_agent.js @@ -5,7 +5,7 @@ const middleware = require('../middleware/auth'); const permission = require('../utils/permission'); const agentManager = require('../utils/agent_manager'); const agentKeys = require('../utils/agent_keys'); -const { Agent } = require('../models/agent'); +const { Agent, AgentJoinKey } = require('../models/agent'); const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin']; @@ -172,6 +172,54 @@ router.delete('/nodes/:id', async (req, res, next) => { } catch (err) { next(err); } }); +// --- Join keys --- +// One key an operator hands out; hosts that present it enroll themselves and +// are immediately issued their own per-agent token. Listing never returns the +// key itself -- only its prefix and usage. +router.get('/join-keys', async (req, res, next) => { + try { + const keys = await AgentJoinKey.list(); + res.json({ status: 'ok', joinKeys: keys.map(k => k.toPublic()) }); + } catch (err) { next(err); } +}); + +router.post('/join-keys', async (req, res, next) => { + try { + const { label, expiresInDays } = req.body || {}; + const { key, raw } = await AgentJoinKey.issue({ + label: (label && String(label).trim()) || 'default', + createdBy: req.user.uid, + expiresInDays: expiresInDays ? Number(expiresInDays) : null + }); + logAgentAudit('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 (err) { next(err); } +}); + +router.post('/join-keys/:id/revoke', async (req, res, next) => { + try { + const key = await AgentJoinKey.get(req.params.id); + if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' }); + await key.update({ revoked: true }); + logAgentAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix }); + // Agents already enrolled keep working -- they hold their own tokens now, + // which is the whole point of exchanging the join key rather than using it + // as the long-term credential. + res.json({ status: 'ok' }); + } catch (err) { next(err); } +}); + +router.delete('/join-keys/:id', async (req, res, next) => { + try { + const key = await AgentJoinKey.get(req.params.id); + if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' }); + await key.delete(); + logAgentAudit('join_key_deleted', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix }); + res.json({ status: 'ok' }); + } catch (err) { next(err); } +}); + // --- Commands --- // Addressed by agent id, not by token: a token is a credential and has no // business travelling in a URL, being logged, or sitting in browser history. @@ -227,8 +275,37 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) { // the peer is an anonymous stranger, and the old code treated it as a // trusted node purely for presenting a non-empty string. let agent = null; + let issuedToken = null; // set when this connection auto-enrolled try { agent = await Agent.authenticate(token); + + // Not a known agent token -- try it as a join key. This is what makes + // "install the agent with a key and the host appears" work without an + // admin pre-registering every machine. The join key is exchanged for a + // per-agent token below, so it never becomes the host's long-term + // credential. + if (!agent) { + const joinKey = await AgentJoinKey.authenticate(token); + if (joinKey) { + const hostname = (url.searchParams.get('hostname') || '').trim(); + const enrolled = await Agent.enroll({ + name: hostname || `agent-${Date.now().toString(36)}`, + description: `Self-enrolled with join key ${joinKey.keyPrefix}`, + enrolledBy: `join-key:${joinKey.label}` + }); + agent = enrolled.agent; + issuedToken = enrolled.token; + await joinKey.update({ + use_count: (joinKey.use_count || 0) + 1, + last_used_on: Math.floor(Date.now() / 1000) + }).catch(() => {}); + logAgentAudit('join', { + agentId: agent.id, agentName: agent.name, remoteAddr, + joinKeyLabel: joinKey.label, joinKeyPrefix: joinKey.keyPrefix + }); + console.log(`[Theta Agent] "${agent.name}" self-enrolled with join key ${joinKey.keyPrefix}`); + } + } } catch (err) { console.error('[Theta Agent] authentication lookup failed:', err.message); try { ws.close(1011, 'Authentication unavailable'); } catch (e) {} @@ -294,16 +371,24 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) { agentManager.unregisterAgent(agent.id, ws); }); - // Send initial welcome/config payload + // Send initial welcome/config payload. When this connection enrolled via a + // join key it also carries the credentials the agent should persist and use + // from now on: its own token, and the public key it must pin to verify + // signed commands. Handing the public key over here is what removes the + // last manual step -- an agent installed with only a join key ends up fully + // configured without anyone copying values between two machines. try { - ws.send(JSON.stringify({ - type: 'config', - payload: { - message: 'Connected to SSO Manager C2', - protocol_version: '1.2.0', - agent_id: agent.id - } - })); + const payload = { + message: 'Connected to SSO Manager C2', + protocol_version: '1.2.0', + agent_id: agent.id + }; + if (issuedToken) { + payload.enrolled = true; + payload.auth_token = issuedToken; + payload.public_key = await agentManager.publicKeyBase64(); + } + ws.send(JSON.stringify({ type: 'config', payload })); } catch (e) {} }); }; diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js index ac9573e..54357ce 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -34,8 +34,12 @@ const DOCS = { 'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')}, 'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')}, directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')}, - agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')}, + // `agents` pointed at plugins.md, so docs/agents.md -- the theta-agent + // guide the Directory links to -- was unreachable in the app. + agents: {title: 'Theta Agent', file: path.join(__dirname, '../../docs/agents.md')}, plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')}, + // The Discovery tab's help icon links here; without an entry it 404'd. + discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')}, vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')}, groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')}, diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 0f2a994..158030b 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -1,5 +1,16 @@ <%- include('top') %> + with an , so anything keyed to the child element stops working + the moment its observer runs. Targeting both covers either state. */ + .tree-caret > i, + .tree-caret > svg { transition: transform .12s ease-in-out; } + .tree-caret.tree-caret-collapsed > i, + .tree-caret.tree-caret-collapsed > svg { transform: rotate(-90deg); } + +
@@ -230,6 +241,13 @@
+ +
@@ -914,13 +932,23 @@ hideBelowDepth = null; $row.show(); - const $icon = $row.find('.tree-caret i'); - if (!$icon.length) return; + // Visual state lives on the .tree-caret BUTTON, rotated by CSS, and the + // hide decision is made from `collapsed` alone. + // + // This used to read `.tree-caret i` and bail out when it found nothing. + // Font Awesome runs in SVG-with-JS mode here: its mutation observer + // rewrites every into an , so moments after a + // render that selector matches nothing, the function returned early + // WITHOUT setting hideBelowDepth, and collapsing silently did nothing at + // all. Never make the collapse logic depend on an element another library + // is free to replace. + const $caret = $row.find('.tree-caret'); + if (!$caret.length) return; // leaf row: nothing to collapse if (collapsed.has(id)) { - $icon.removeClass('fa-chevron-down').addClass('fa-chevron-right'); + $caret.addClass('tree-caret-collapsed'); hideBelowDepth = depth; } else { - $icon.removeClass('fa-chevron-right').addClass('fa-chevron-down'); + $caret.removeClass('tree-caret-collapsed'); } }); } @@ -1963,6 +1991,7 @@
${statusText} ${logsBtn} + @@ -2066,18 +2095,27 @@ var raw = document.getElementById(prefix + 'cron'); return (raw && raw.value.trim()) || '0 * * * *'; } - function dpConfigFormHtml(type, prefix) { + // `values` pre-fills the form for edit mode. Secret fields are never returned + // by the API in the clear (they live in OpenBao and come back masked), so + // they are rendered EMPTY with a "leave blank to keep" hint rather than + // prefilled with `********` -- submitting the mask back would otherwise store + // the literal asterisks as the secret. + function dpConfigFormHtml(type, prefix, values) { var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0]; var schema = t && t.configSchema; if (!schema || !schema.length) return '

No configuration fields for this plugin.

'; + values = values || {}; var html = ''; schema.forEach(function(f) { var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text'); - var req = f.required ? ' required' : ''; - var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : ''; + var req = (f.required && !f.secret) ? ' required' : ''; + var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : ''; + var val = ''; + 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"'; var label = f.label + (f.secret ? ' ' : '') + (f.required ? ' *' : ''); html += '
' + - '
'; + '
'; }); return html; } @@ -2137,6 +2175,102 @@ }); } + // Edit an existing instance. Non-secret config goes to PUT /plugins/:id; + // secrets go to PUT /plugins/:id/secrets and only when the operator actually + // typed a new value -- they are two endpoints because the DB row must never + // hold a secret (see routes/api_plugins.js). + function openEditDiscoveryPluginModal(id) { + const p = discoveryPlugins.find(x => x.id === id); + if (!p) return; + app.api.get('plugins/types', function(err, res) { + if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; } + discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery'); + const values = Object.assign({}, p.config || {}, { __isEdit: true }); + const bodyHtml = ` +
+ + +
The type is fixed once an instance exists — create a new instance to use a different one.
+
+
+ + +
Slug ${esc(p.slug)} is stable and does not change.
+
+
+ + ${dpCronSelectHtml('ep-', p.cron)} +
+
+ + +
+
Configuration
+
${dpConfigFormHtml(p.pluginType, 'ep-', values)}
+
+ + +
+ `; + app.modal.open({ title: 'Edit Discovery Plugin — ' + p.name, bodyHtml: bodyHtml, size: 'lg' }); + }); + } + + async function saveEditedDiscoveryPlugin(id) { + const p = discoveryPlugins.find(x => x.id === id); + if (!p) return; + const name = ($('#edit-plugin-name').val() || '').trim(); + if (!name) { app.messages.toast('Name is required', 'warning'); return; } + + const flat = dpCollectConfig(p.pluginType, 'ep-'); + const type = discoveryPluginTypes.find(t => t.type === p.pluginType); + const schema = (type && type.configSchema) || []; + + // Split by the schema so a secret never rides along in the DB payload, and + // an untouched secret field is not sent at all. + const config = {}; + const secrets = {}; + schema.forEach(f => { + const v = flat[f.key]; + if (f.secret) { if (v) secrets[f.key] = v; } + else config[f.key] = v; + }); + + try { + await app.api.put(`plugins/${id}`, { + name, + cron: dpCronFromForm('ep-'), + enabled: $('#edit-plugin-enabled').is(':checked'), + config + }); + if (Object.keys(secrets).length) await app.api.put(`plugins/${id}/secrets`, secrets); + app.modal.close(); + app.messages.toast('Plugin updated', 'success'); + loadDiscoveryPlugins(); + } catch (e) { + app.messages.toast('Error saving plugin: ' + (e.message || e), 'danger'); + } + } + + // Was referenced by the card's trash button but never defined, so clicking it + // only threw a ReferenceError -- delete appeared to do nothing. + async function deleteDiscoveryPlugin(id) { + const p = discoveryPlugins.find(x => x.id === id); + const label = p ? (p.name || p.slug) : 'this plugin'; + const $card = $('#plugins-tab-pane'); + const confirmed = await app.messages.confirm( + `Delete discovery plugin "${label}"? Its schedule stops and its stored secrets are removed. Resources it already discovered stay in the Directory.`, + $card, 'warning'); + if (!confirmed) return; + try { + await app.api.delete(`plugins/${id}`); + app.messages.toast('Plugin deleted', 'success'); + loadDiscoveryPlugins(); + } catch (e) { + app.messages.toast('Error deleting plugin: ' + (e.message || e), 'danger'); + } + } + async function saveNewDiscoveryPlugin() { const type = $('#new-plugin-type').val(); const name = $('#new-plugin-name').val().trim();