181ca8c9cb
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>
111 lines
3.9 KiB
JavaScript
111 lines
3.9 KiB
JavaScript
'use strict';
|
|
|
|
// End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
|
|
//
|
|
// Simulates the agent: enrolls one, connects to the SSO WSS with its token,
|
|
// sends a real LDAP bind request as raw bytes in an `ldap_tunnel` message, and
|
|
// verifies the SSO relays it into OpenLDAP and pipes the bind response back.
|
|
// This proves the SSO side of the tunnel without needing the agent binary.
|
|
|
|
const WebSocket = require('ws');
|
|
|
|
const SSO_URL = process.env.SSO_URL || 'http://sso:3001';
|
|
const WS_URL = SSO_URL.replace(/^http/, 'ws') + '/api/agent/ws';
|
|
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
|
|
const USER_DN = 'cn=test,ou=people,dc=test,dc=local';
|
|
|
|
function fail(msg) { console.error('E2E FAIL:', msg); process.exit(1); }
|
|
|
|
async function waitForSso() {
|
|
for (let i = 0; i < 60; i++) {
|
|
try {
|
|
const r = await fetch(`${SSO_URL}/health`);
|
|
if (r.ok) return;
|
|
} catch (_) {}
|
|
await new Promise((res) => setTimeout(res, 1000));
|
|
}
|
|
fail('SSO never became ready');
|
|
}
|
|
|
|
async function login() {
|
|
const r = await fetch(`${SSO_URL}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(TEST_CREDS),
|
|
});
|
|
if (!r.ok) fail(`login failed: ${r.status}`);
|
|
const body = await r.json();
|
|
return body.token;
|
|
}
|
|
|
|
async function enrollAgent(authToken) {
|
|
const r = await fetch(`${SSO_URL}/api/agent/enroll`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'auth-token': authToken },
|
|
body: JSON.stringify({ name: `e2e-${Date.now().toString(36)}` }),
|
|
});
|
|
if (!r.ok) fail(`enroll failed: ${r.status}`);
|
|
const body = await r.json();
|
|
return body.token;
|
|
}
|
|
|
|
// Build a simple LDAP bind request (version 3) as raw BER bytes.
|
|
function buildBindRequest(dn, password) {
|
|
const dnBuf = Buffer.from(dn, 'utf8');
|
|
const pwBuf = Buffer.from(password, 'utf8');
|
|
const version = Buffer.from([0x02, 0x01, 0x03]);
|
|
const name = Buffer.concat([Buffer.from([0x04, dnBuf.length]), dnBuf]);
|
|
const simple = Buffer.concat([Buffer.from([0x80, pwBuf.length]), pwBuf]);
|
|
const bindContent = Buffer.concat([version, name, simple]);
|
|
const bindReq = Buffer.concat([Buffer.from([0x60, bindContent.length]), bindContent]);
|
|
const msgId = Buffer.from([0x02, 0x01, 0x01]);
|
|
const msgContent = Buffer.concat([msgId, bindReq]);
|
|
return Buffer.concat([Buffer.from([0x30, msgContent.length]), msgContent]);
|
|
}
|
|
|
|
// A successful bind response is a BindResponse (0x61) with resultCode 0 (0x0a 01 00).
|
|
function isSuccessBindResponse(buf) {
|
|
return buf.includes(Buffer.from([0x61])) && buf.includes(Buffer.from([0x0a, 0x01, 0x00]));
|
|
}
|
|
|
|
async function main() {
|
|
await waitForSso();
|
|
const authToken = await login();
|
|
const agentToken = await enrollAgent(authToken);
|
|
console.log('E2E: enrolled agent, connecting WSS...');
|
|
|
|
const ws = new WebSocket(`${WS_URL}?token=${agentToken}`);
|
|
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
|
|
console.log('E2E: WSS connected');
|
|
|
|
const bindBytes = buildBindRequest(USER_DN, TEST_CREDS.password);
|
|
ws.send(JSON.stringify({
|
|
type: 'ldap_tunnel',
|
|
payload: { conn_id: 'e2e-1', data: bindBytes.toString('base64') },
|
|
}));
|
|
console.log('E2E: sent bind request bytes');
|
|
|
|
const result = await new Promise((res, rej) => {
|
|
const timeout = setTimeout(() => rej(new Error('timed out waiting for bind response')), 10000);
|
|
ws.on('message', (data) => {
|
|
let msg;
|
|
try { msg = JSON.parse(data); } catch (_) { return; }
|
|
if (msg.type !== 'ldap_tunnel') return;
|
|
if (msg.payload.close) return;
|
|
const buf = Buffer.from(msg.payload.data || '', 'base64');
|
|
if (isSuccessBindResponse(buf)) {
|
|
clearTimeout(timeout);
|
|
res({ ok: true, bytes: buf.length });
|
|
}
|
|
});
|
|
ws.on('error', (e) => { clearTimeout(timeout); rej(e); });
|
|
});
|
|
|
|
console.log(`E2E: got successful bind response (${result.bytes} bytes)`);
|
|
ws.close();
|
|
console.log('E2E PASS');
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((e) => fail(e.message));
|