feat: agent join keys; fix directory collapse, plugin edit/delete, docs (v1.30.0)
JOIN KEYS v1.29.0 required an admin to pre-register every machine before its agent would be spoken to. The security model was right; the workflow was not -- installing the agent should be enough to add a host. 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 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. DIRECTORY Collapsing the tree did nothing. applyTreeCollapse found the caret with `.tree-caret i` and returned early when absent -- Font Awesome's SVG mode rewrites <i> to <svg>, so that selector matched nothing and the early return skipped setting hideBelowDepth. State now lives on the caret button and is rotated by CSS. The Discovery Plugins delete button called deleteDiscoveryPlugin(), which was never defined. The pane also had no .actionMessage, and confirmations render into one -- without it the promise never settles, so an awaited confirmation hangs forever and the action silently never happens. Plugin instances can now be edited. DISCOVERY A fresh install presented its own five containers as unmanaged discoveries. The Docker plugin now recognises the stack's compose project and attaches each container to the service it implements. Container slugs came from the container id, which changes on recreate, so every deploy minted a new resource and orphaned the old one. DOCS /docs/discovery 404'd (no slug entry) and `agents` pointed at plugins.md, leaving docs/agents.md unreachable. Adds docs/discovery.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+95
-10
@@ -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) {}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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')},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user