Add LDAP-over-HTTPS API, agent secrets/IAM engines, and join key management

See CHANGELOG.md for the full breakdown. Summary:

- POST /api/v1/ldap/{bind,search}: LDAP-over-HTTPS so a client stops
  speaking raw LDAP and instead calls the SSO, which binds/searches its
  own OpenLDAP on the caller's behalf (DESIGN.md §3).
- LDAP byte-pump relay (utils/ldap_tunnel.js): forwards raw LDAP bytes
  from an agent's local socket into OpenLDAP over the existing agent WSS
  channel; the SSO never parses LDAP (DESIGN.md §4).
- POST /api/v1/agent/secrets: node-scoped OpenBao secret fetch for
  agents, enforced to each agent's own secret/data/nodes/<id>/* prefix
  (DESIGN.md §5).
- iam_apply signed command: push node-scoped IAM config (sudo rules, SSH
  keys, access control, revocation) to an agent (DESIGN.md §6).
- Agent capability badges on the Directory Metrics tab, sourced from the
  agent's own discovery frame.
- Join key management: GET /api/agent/join-keys/:id/agents (which hosts
  enrolled through a key) plus a Manage join keys table in the Install
  Agent modal with Revoke/Delete actions, confirmed inline per-row rather
  than a blocking native confirm() or the shared app.messages.confirm()
  banner (which desyncs across concurrent rows -- see CHANGELOG).
- docs/agents.md: capability matrix updated for the three new
  capabilities, a full secrets-engine walkthrough with screenshots
  (bash + Node consuming a rendered secret, plus the direct-API
  alternative), and the join-key reuse/UI/audit questions answered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:03:41 -04:00
parent 6e748bfa66
commit 181ca8c9cb
17 changed files with 1091 additions and 7 deletions
+10
View File
@@ -112,6 +112,16 @@ app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
app.use('/api/agent', require('./routes/api_agent'));
// LDAP-over-HTTPS API (DESIGN.md §3). Bearer-authed (agent token or PAT); the
// SSO performs the real LDAP bind/search against its own OpenLDAP. Mounted
// synchronously for the same reason as /api/agent — it must sit before the 404
// catch-all.
app.use('/api/v1/ldap', require('./routes/api_ldap'));
// Agent-facing operations (DESIGN.md §5, §6): node-scoped secrets, IAM. The
// caller is the agent itself (Bearer agent token), not an admin session.
app.use('/api/v1/agent', require('./routes/api_agent_ops'));
// OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter);
app.use('/api/oauth', middleware.auth, oauthApiRouter);
+25 -1
View File
@@ -5,6 +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 ldapTunnel = require('../utils/ldap_tunnel');
const { Agent, AgentJoinKey } = require('../models/agent');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
@@ -12,7 +13,7 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_adm
// Commands that can change or run code on the host. They are signed with the
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
// its agent.yml.
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply'];
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
// This is a plain Express Router exported directly so app.js can
@@ -183,6 +184,23 @@ router.get('/join-keys', async (req, res, next) => {
} catch (err) { next(err); }
});
// Which hosts enrolled through a given key. There is no stored relation --
// join keys are exchanged for a per-agent token immediately, and from then on
// the agent's own identity is what matters -- so this matches on the
// human-readable trace `Agent.enroll` already leaves in `description`
// ("Self-enrolled with join key <prefix>") rather than a foreign key. Prefixes
// are 12 random hex chars, so a collision is not a practical concern.
router.get('/join-keys/:id/agents', 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' });
const marker = `join key ${key.keyPrefix}`;
const agents = await Agent.list();
const matches = agents.filter(a => (a.description || '').includes(marker));
res.json({ status: 'ok', agents: matches.map(a => a.toPublic(agentManager.liveState(a.id))) });
} catch (err) { next(err); }
});
router.post('/join-keys', async (req, res, next) => {
try {
const { label, expiresInDays } = req.body || {};
@@ -358,6 +376,11 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
await agentManager.handleResponse(current, payload);
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
break;
case 'ldap_tunnel':
// Raw LDAP bytes from the agent's local socket → relay into OpenLDAP
// and pipe the response back (DESIGN.md §4).
ldapTunnel.handleTunnel(current.id, ws, payload);
break;
default:
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
}
@@ -369,6 +392,7 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
ws.on('close', () => {
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
agentManager.unregisterAgent(agent.id, ws);
ldapTunnel.cleanup(agent.id);
});
// Send initial welcome/config payload. When this connection enrolled via a
+53
View File
@@ -0,0 +1,53 @@
'use strict';
// Agent-facing operations (DESIGN.md §5, §6). These are NOT admin-gated: the
// caller is the agent itself, authenticated by its own token (the same one it
// presents on its WSS channel). Mounted at /api/v1/agent.
const express = require('express');
const baoConf = require('@simpleworkjs/bao-conf');
const { authenticateAgent } = require('../utils/agent_auth');
const router = express.Router();
// POST /secrets — fetch node-scoped OpenBao secrets for the agent's own node.
//
// { paths: ["secret/data/nodes/<agent-id>/db"] }
// -> { status: "ok", secrets: { "secret/data/nodes/<agent-id>/db": { key: value } } }
//
// The agent may only read under its own node prefix (secret/data/nodes/<id>/*),
// so a compromised agent cannot reach other nodes' or shared secrets. The SSO
// fetches with its own OpenBao access (SSO_VAULT_TOKEN); the agent never holds a
// Vault token.
router.post('/secrets', async (req, res, next) => {
try {
const agent = await authenticateAgent(req);
if (!agent) return res.status(401).json({ status: 'error', message: 'unauthorized' });
const { paths } = req.body || {};
if (!Array.isArray(paths) || paths.length === 0) {
return res.status(400).json({ status: 'error', message: 'paths (array) is required' });
}
const nodeScope = `secret/data/nodes/${agent.id}/`;
const secrets = {};
for (const p of paths) {
if (typeof p !== 'string' || !p.startsWith(nodeScope)) {
return res.status(403).json({ status: 'error', message: `path outside node scope: ${p}` });
}
const r = await baoConf.request('GET', p);
if (r.ok) {
const body = await r.json().catch(() => ({}));
secrets[p] = (body.data && body.data.data) || {};
} else {
// Missing secret: return an empty object for that path rather than
// failing the whole batch; the agent renders what it can.
secrets[p] = {};
}
}
return res.json({ status: 'ok', secrets });
} catch (err) { next(err); }
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
// LDAP-over-HTTPS API (DESIGN.md §3).
//
// The whole point of this API is that a client stops speaking LDAP and instead
// does an HTTPS call to the SSO, where the directory is reachable. That kills
// the hostname / cross-network / LDAPS-cert-chain pain: no LDAP protocol, no
// cert to trust, no firewall rule.
//
// POST /api/v1/ldap/bind {username, password} -> 200 {dn, uid} | 401
// POST /api/v1/ldap/search {base_dn, scope, filter, attributes} -> 200 {entries}
//
// Caller auth: a Bearer token in the Authorization header. Two kinds of caller
// are accepted, reusing existing credentials:
// - an agent token (the same one the agent presents on its WSS channel) — the
// caller is a node acting for SSSD;
// - a self-service API token (PAT, `sso_...`) — the caller is a user/app.
// The API authorizes the *caller*; OpenLDAP enforces the actual directory ACLs.
//
// Security note on /search: it runs under the directory admin bind (withClient),
// so it can read the whole tree. It is therefore restricted to agent callers
// (the SSSD user/group-resolution use case) and must eventually move to a
// scoped read-only service account rather than the admin bind. See DESIGN.md §9.
const express = require('express');
const { createLdapClient } = require('@simpleworkjs/ldap');
const conf = require('@simpleworkjs/conf').ldap;
const { Agent } = require('../models/agent');
const { ApiToken } = require('../models/api_token');
const router = express.Router();
const ldap = createLdapClient(conf);
// Resolve a Bearer token to a caller identity, or null. Tries the agent token
// first, then a PAT. Every failure collapses to null so a probing caller learns
// nothing about which credential was wrong.
async function authenticateCaller(req) {
const auth = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(auth);
if (!m) return null;
const token = String(m[1]).trim();
if (!token) return null;
try {
const agent = await Agent.authenticate(token);
if (agent) return { kind: 'agent', id: agent.id, name: agent.name };
} catch (_) {}
try {
const pat = await ApiToken.authenticate(token);
if (pat) return { kind: 'user', id: pat.created_by };
} catch (_) {}
return null;
}
// POST /bind — authenticate a username/password against the directory.
router.post('/bind', async (req, res, next) => {
try {
const caller = await authenticateCaller(req);
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ status: 'error', message: 'username and password are required' });
}
// Resolve the username to a DN, then simple-bind as that DN. A missing user
// and a wrong password both surface as 401 (no user-existence oracle).
const user = await ldap.getUser(String(username));
if (!user) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
const ok = await ldap.checkPassword(user.dn, String(password));
if (!ok) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
return res.json({ status: 'ok', dn: user.dn, uid: user.uid });
} catch (err) { next(err); }
});
// POST /search — run a directory search. Agent callers only (see header note).
router.post('/search', async (req, res, next) => {
try {
const caller = await authenticateCaller(req);
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
if (caller.kind !== 'agent') {
return res.status(403).json({ status: 'error', message: 'search is restricted to agents' });
}
const { base_dn, scope, filter, attributes } = req.body || {};
if (!filter) return res.status(400).json({ status: 'error', message: 'filter is required' });
const entries = await ldap.withClient(async (client) => {
const { searchEntries } = await client.search(base_dn || conf.userBase, {
scope: scope || 'sub',
filter: String(filter),
attributes: Array.isArray(attributes) && attributes.length ? attributes : undefined,
});
return searchEntries;
});
return res.json({ status: 'ok', entries });
} catch (err) { next(err); }
});
module.exports = router;
+72
View File
@@ -0,0 +1,72 @@
'use strict';
// Agent-facing ops (DESIGN.md §5): node-scoped secrets. OpenBao is not present
// in the test env, so @simpleworkjs/bao-conf is mocked.
jest.mock('@simpleworkjs/bao-conf', () => ({
request: jest.fn(async (method, path) => {
if (path.startsWith('secret/data/nodes/')) {
return {
ok: true,
status: 200,
json: async () => ({ data: { data: { username: 'alice', password: 's3cret' } } }),
};
}
return { ok: false, status: 404, json: async () => ({}) };
}),
}));
const { request, app } = require('./setup');
const { Agent } = require('../models/agent');
async function enrollAgent() {
const { agent, token } = await Agent.enroll({
name: `ops-test-${Date.now().toString(36)}`,
description: 'api_agent_ops test',
enrolledBy: 'test'
});
return { agent, token };
}
describe('Agent ops — POST /api/v1/agent/secrets', () => {
test('an agent can fetch its own node-scoped secrets', async () => {
const { agent, token } = await enrollAgent();
const path = `secret/data/nodes/${agent.id}/db`;
const res = await request(app)
.post('/api/v1/agent/secrets')
.set('Authorization', `Bearer ${token}`)
.send({ paths: [path] });
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.body.secrets[path]).toEqual({ username: 'alice', password: 's3cret' });
});
test('a path outside the node scope is rejected', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/agent/secrets')
.set('Authorization', `Bearer ${token}`)
.send({ paths: ['secret/data/nodes/other-node/db'] });
expect(res.status).toBe(403);
});
test('no bearer token returns 401', async () => {
const res = await request(app)
.post('/api/v1/agent/secrets')
.send({ paths: ['secret/data/nodes/x/db'] });
expect(res.status).toBe(401);
});
test('missing paths returns 400', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/agent/secrets')
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(400);
});
});
+126
View File
@@ -0,0 +1,126 @@
'use strict';
// LDAP-over-HTTPS API (DESIGN.md §3). Exercises caller auth (agent token vs
// PAT), the bind flow against the real test OpenLDAP, and the agent-only search
// restriction.
const { TEST_CREDS, request, app } = require('./setup');
const { Agent } = require('../models/agent');
const { ApiToken } = require('../models/api_token');
async function enrollAgent() {
const { agent, token } = await Agent.enroll({
name: `ldap-test-${Date.now().toString(36)}`,
description: 'api_ldap test agent',
enrolledBy: 'test'
});
return { agent, token };
}
async function makePat() {
const token = await ApiToken.add({
name: 'ldap-test-pat',
description: 'api_ldap test',
created_by: 'test'
});
return token._raw_token;
}
describe('LDAP-over-HTTPS — POST /api/v1/ldap/bind', () => {
test('valid credentials return the bound DN', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.body.uid).toBe(TEST_CREDS.uid);
expect(res.body.dn).toContain(TEST_CREDS.uid);
});
test('wrong password returns 401', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: TEST_CREDS.uid, password: 'wrong-password' });
expect(res.status).toBe(401);
});
test('unknown user returns 401 (no existence oracle)', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: 'no_such_user_xyz', password: 'whatever' });
expect(res.status).toBe(401);
});
test('a PAT caller can bind', async () => {
const pat = await makePat();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${pat}`)
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
expect(res.status).toBe(200);
});
test('no bearer token returns 401', async () => {
const res = await request(app)
.post('/api/v1/ldap/bind')
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
expect(res.status).toBe(401);
});
test('missing username/password returns 400', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: TEST_CREDS.uid });
expect(res.status).toBe(400);
});
});
describe('LDAP-over-HTTPS — POST /api/v1/ldap/search', () => {
test('an agent can search the user tree', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/search')
.set('Authorization', `Bearer ${token}`)
.send({ filter: `(uid=${TEST_CREDS.uid})`, attributes: ['uid', 'cn'] });
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(Array.isArray(res.body.entries)).toBe(true);
expect(res.body.entries.length).toBeGreaterThan(0);
expect(res.body.entries[0].uid).toBe(TEST_CREDS.uid);
});
test('a PAT caller is denied search (agent-only)', async () => {
const pat = await makePat();
const res = await request(app)
.post('/api/v1/ldap/search')
.set('Authorization', `Bearer ${pat}`)
.send({ filter: `(uid=${TEST_CREDS.uid})` });
expect(res.status).toBe(403);
});
test('missing filter returns 400', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/search')
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(400);
});
});
+26
View File
@@ -0,0 +1,26 @@
'use strict';
// Authenticate an agent from a Bearer token (the same token the agent presents
// on its WSS channel). Used by agent-facing REST endpoints (secrets, IAM) that
// are NOT admin-gated — the caller is the agent itself, not an admin session.
const { Agent } = require('../models/agent');
// Resolve a Bearer token to its (non-revoked) Agent, or null. Every failure
// collapses to null so a probing caller learns nothing about which part was
// wrong.
async function authenticateAgent(req) {
const auth = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(auth);
if (!m) return null;
const token = String(m[1]).trim();
if (!token) return null;
try {
const agent = await Agent.authenticate(token);
return agent || null;
} catch (_) {
return null;
}
}
module.exports = { authenticateAgent };
+4 -1
View File
@@ -120,7 +120,10 @@ class AgentManager {
cpu: payload.cpu || '',
ram_total_gb: payload.ram_total_gb || 0,
disk_total_gb: payload.disk_total_gb || 0,
location: payload.location || 'default'
location: payload.location || 'default',
// The agent's enabled capabilities (from its local agent.yml). The agent
// is the authoritative source for what it will actually do.
capabilities: payload.capabilities || {}
};
await this.touch(agent, { lastDiscovery: discovery });
await this.applyDiscoveryToDirectory(agent, discovery);
+84
View File
@@ -0,0 +1,84 @@
'use strict';
// LDAP byte-pump relay (DESIGN.md §4). The agent forwards raw LDAP bytes from a
// local socket (SSSD) over the WSS channel as `ldap_tunnel` messages; this
// module relays them into the SSO's real OpenLDAP and pipes the responses back.
// The SSO does not parse LDAP either — it is a transparent socket relay.
const net = require('net');
const conf = require('@simpleworkjs/conf').ldap;
// Parse host:port from an ldap:// or ldaps:// URL. The relay connects plaintext
// to the SSO's own slapd (which is plaintext on localhost); an ldaps:// URL
// would need TLS termination here and is not supported yet (DESIGN.md §9.5).
function ldapTarget() {
const url = conf.url || 'ldap://localhost:389';
const m = /^ldaps?:\/\/([^:/]+)(?::(\d+))?/.exec(url);
const host = m ? m[1] : 'localhost';
const port = m && m[2] ? Number(m[2]) : 389;
return { host, port };
}
// Per-agent relay state: agentId -> Map(conn_id -> LDAP socket).
const relays = new Map();
function relayFor(agentId) {
if (!relays.has(agentId)) relays.set(agentId, new Map());
return relays.get(agentId);
}
// Handle one ldap_tunnel message from an agent.
function handleTunnel(agentId, ws, payload) {
const connId = payload.conn_id;
if (!connId) return;
const conns = relayFor(agentId);
// End of connection: close the relay socket.
if (payload.close) {
const sock = conns.get(connId);
if (sock) { sock.destroy(); conns.delete(connId); }
return;
}
const data = Buffer.from(payload.data || '', 'base64');
if (data.length === 0) return;
let sock = conns.get(connId);
if (!sock) {
const { host, port } = ldapTarget();
sock = net.connect(port, host);
conns.set(connId, sock);
// Relay OpenLDAP's responses back to the agent.
sock.on('data', (chunk) => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({
type: 'ldap_tunnel',
payload: { conn_id: connId, data: chunk.toString('base64') }
}));
}
});
sock.on('close', () => {
conns.delete(connId);
if (ws.readyState === 1) {
ws.send(JSON.stringify({
type: 'ldap_tunnel',
payload: { conn_id: connId, close: true }
}));
}
});
sock.on('error', () => { sock.destroy(); });
}
sock.write(data);
}
// Drop every relay socket for an agent (on WSS disconnect).
function cleanup(agentId) {
const conns = relays.get(agentId);
if (conns) {
for (const sock of conns.values()) sock.destroy();
relays.delete(agentId);
}
}
module.exports = { handleTunnel, cleanup };
+137 -2
View File
@@ -724,9 +724,32 @@
<div class="col-6">IPs: ${esc((d.ip_addresses || []).join(', '))}</div>
<div class="col-6">Location: ${esc(d.location || '')}</div>
</div>
<hr><h6>Capabilities</h6>
<div class="small">${capabilitiesHtml(d.capabilities)}</div>
</div>`;
}
// Render the agent's enabled capabilities (reported in its discovery frame) as
// green/gray badges. service_control is a list, so it renders as its own line.
function capabilitiesHtml(caps) {
caps = caps || {};
const badge = (name, on) => `<span class="badge ${on ? 'bg-success' : 'bg-secondary'} me-1 mb-1">${esc(name)}</span>`;
const bools = [
['Telemetry', caps.telemetry],
['LDAP config', caps.configure_ldap],
['LDAP tunnel', caps.ldap_tunnel],
['Secrets', caps.secrets],
['IAM', caps.iam],
['Reboot', caps.reboot],
['Bash', caps.arbitrary_bash],
];
const sc = Array.isArray(caps.service_control) ? caps.service_control : [];
const scLine = sc.length
? `<div class="mt-1 text-muted">Service control: ${esc(sc.join(', '))}</div>`
: '';
return bools.map(([n, on]) => badge(n, !!on)).join('') + scLine;
}
// Re-fetch agents (every 30s + on socket events) so status dots stay live.
async function refreshAgents() {
try {
@@ -1782,6 +1805,28 @@
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header py-2 fw-bold small">
<i class="fa-solid fa-list-check me-1"></i> Manage join keys
</div>
<div class="card-body py-2">
<table class="table table-sm table-hover mb-0 small" id="agent-join-key-table">
<thead>
<tr>
<th>Label</th>
<th>Prefix</th>
<th>Created</th>
<th>Hosts joined</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="agent-join-key-tbody"></tbody>
</table>
<div id="agent-join-key-hosts" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
@@ -1974,12 +2019,15 @@
// endpoint -- only a prefix -- so the dropdown identifies a key without being
// able to rebuild an install command from it. Minting is the only way to see
// a key's value, and only once.
var agentJoinKeys = [];
var agentJoinKeys = []; // non-revoked, for the install-command dropdown
var agentJoinKeysAll = []; // every key, for the management table
var mintedJoinKey = null; // in-memory, for the command shown right now
function loadAgentJoinKeys() {
app.api.get('agent/join-keys', function(err, res) {
agentJoinKeys = (res && res.joinKeys ? res.joinKeys : []).filter(k => !k.revoked);
agentJoinKeysAll = (res && res.joinKeys ? res.joinKeys : []);
agentJoinKeys = agentJoinKeysAll.filter(k => !k.revoked);
const $sel = $('#agent-join-key-select').empty();
if (!agentJoinKeys.length) {
$sel.append('<option value="">No join keys yet — create one</option>');
@@ -1990,10 +2038,97 @@
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
});
}
const $tbody = $('#agent-join-key-tbody').empty();
$('#agent-join-key-hosts').hide().empty();
if (!agentJoinKeysAll.length) {
$tbody.append('<tr><td colspan="6" class="text-muted">No join keys yet.</td></tr>');
} else {
agentJoinKeysAll.forEach(k => {
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 status = k.revoked
? '<span class="badge bg-secondary">Revoked</span>'
: '<span class="badge bg-success">Active</span>';
const revokeBtn = k.revoked ? '' :
`<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(
$('<td>').text(k.label),
$('<td>').append($('<code>').text(k.keyPrefix + '…')),
$('<td>').text(created),
$('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })),
$('<td>').html(status),
$('<td class="text-end agent-join-key-actions">').html(
'<div class="btn-group btn-group-sm">' + revokeBtn +
`<button class="btn btn-outline-danger btn-sm" title="Delete the key record itself; already-joined hosts keep working" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'delete')"><i class="fa-solid fa-trash"></i></button>` +
'</div>'
)
);
$tbody.append($row);
});
}
updateAgentCommands();
});
}
async function viewAgentJoinKeyHosts(id, label) {
const $out = $('#agent-join-key-hosts').show().html('<i class="fa-solid fa-spinner fa-spin"></i> Loading…');
try {
const res = await app.api.get(`agent/join-keys/${id}/agents`);
const body = (res && (res.results || res)) || {};
const agents = body.agents || [];
if (!agents.length) {
$out.html(`<div class="alert alert-secondary py-2 small mb-0">No hosts have joined with <strong>${esc(label)}</strong> yet.</div>`);
return;
}
const rows = agents.map(a => {
const dot = a.isOnline ? 'text-success' : 'text-muted';
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>`;
}).join('');
$out.html(
`<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` +
'<table class="table table-sm mb-0"><thead><tr><th>Host</th><th>Joined</th><th>Last seen</th></tr></thead><tbody>' + rows + '</tbody></table>'
);
} catch (err) {
$out.html('<div class="alert alert-danger py-2 small mb-0">Could not load hosts: ' + esc(err.message || err) + '</div>');
}
}
// Inline, row-scoped confirm -- swaps the row's action buttons for
// "Revoke/Delete this key? Yes/No" in place. Deliberately not
// app.messages.confirm(): that renders into a single shared .actionMessage
// banner, so a second click before the first resolves leaves a dangling
// `$('body').one('click', ...)` handler from the first call and the banner
// can end up out of sync with which row it's actually confirming for.
// Scoping state to the row itself sidesteps that entirely.
function confirmAgentJoinKeyAction(btn, id, action) {
const isDelete = action === 'delete';
const label = isDelete ? 'Delete' : 'Revoke';
const cls = isDelete ? 'btn-danger' : 'btn-warning';
$(btn).closest('td').html(
`<span class="small me-1">${label}?</span>` +
`<button class="btn ${cls} btn-sm me-1" onclick="reallyDoAgentJoinKeyAction('${id}', '${action}')">Yes</button>` +
`<button class="btn btn-outline-secondary btn-sm" onclick="loadAgentJoinKeys()">No</button>`
);
}
async function reallyDoAgentJoinKeyAction(id, action) {
try {
if (action === 'delete') {
await app.api.delete(`agent/join-keys/${id}`);
app.messages.toast('Join key deleted.', 'success');
} else {
await app.api.post(`agent/join-keys/${id}/revoke`, {});
app.messages.toast('Join key revoked.', 'success');
}
} catch (err) {
app.messages.toast(`Could not ${action}: ` + (err.message || err), 'danger');
}
loadAgentJoinKeys();
}
async function mintAgentJoinKey() {
try {
const res = await app.api.post('agent/join-keys', { label: 'ui' });