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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user