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
+42
View File
@@ -1,3 +1,45 @@
# Unreleased — LDAP-over-HTTPS API + agent LDAP byte-pump relay
### Added
- **`POST /api/v1/ldap/bind` and `POST /api/v1/ldap/search`** — an LDAP-over-HTTPS
API (DESIGN.md §3). A client stops speaking LDAP and instead does an HTTPS call
to the SSO, which performs the real bind/search against its own OpenLDAP. This
kills the hostname / cross-network / LDAPS-cert-chain pain. Caller auth is a
Bearer token: an agent token or a self-service API token (PAT). `/search` is
restricted to agent callers (the SSSD user/group-resolution use case) and runs
under the admin bind — see DESIGN.md §9.5 for the scoped-service-account
follow-up.
- **LDAP byte-pump relay** (`utils/ldap_tunnel.js`) — the SSO relays raw LDAP
bytes from an agent's local socket into its real OpenLDAP and pipes the
response back, over the existing agent WSS channel (`ldap_tunnel` messages).
The SSO does not parse LDAP; it is a transparent socket relay. See DESIGN.md §4.
- **`POST /api/v1/agent/secrets`** — an agent fetches its own node-scoped OpenBao
secrets (DESIGN.md §5). The agent may only read under `secret/data/nodes/<id>/*`;
the SSO fetches with its own OpenBao access, so the agent never holds a Vault
token. Agent-token authed (not admin-gated).
- **`iam_apply` command** — the SSO pushes node-scoped IAM config (sudo rules,
SSH keys, access control, revocation) to an agent as a signed high-risk
command (DESIGN.md §6). Added to `HIGH_RISK_COMMANDS`.
- **Agent capabilities in the Directory UI** — the agent reports its enabled
capabilities in its `discovery` frame; the SSO stores them and the host's
Metrics tab renders them as green/gray badges, so an operator can see at a
glance what each agent is allowed to do.
- **`GET /api/agent/join-keys/:id/agents`** — which hosts enrolled through a
given join key. Matches on the trace `Agent.enroll` already leaves in
`description` ("Self-enrolled with join key `<prefix>`") rather than a stored
relation.
- **Join key management in the Install Agent modal** — a table (label, prefix,
created date, hosts joined, status) alongside the existing mint/select
dropdown, with **Revoke** and **Delete** actions and a click-through to see
which hosts joined via a given key. Previously these were API-only. Revoke
and Delete confirm inline within the row ("Revoke? Yes/No") rather than a
blocking native `confirm()` (freezes the whole tab) or the shared
`app.messages.confirm()` banner (a single `.actionMessage` shared by the
whole card, so a second click before the first resolves leaves a dangling
`$('body').one('click', ...)` handler from the first call and desyncs which
row the banner is actually confirming for).
# v1.30.2 # v1.30.2
### Fixed ### Fixed
+2
View File
@@ -47,6 +47,8 @@ COPY directory_spec.md /directory_spec.md
COPY test_seed.js ./test_seed.js COPY test_seed.js ./test_seed.js
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
RUN chmod +x /usr/local/bin/seed-test-user RUN chmod +x /usr/local/bin/seed-test-user
# End-to-end LDAP tunnel test client (docker-compose.e2e.yml)
COPY test/tunnel_e2e.js ./test/tunnel_e2e.js
# Default command: seed the test user, then run the test suite # Default command: seed the test user, then run the test suite
CMD ["sh", "-c", "seed-test-user && npm test"] CMD ["sh", "-c", "seed-test-user && npm test"]
+93
View File
@@ -0,0 +1,93 @@
# End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
#
# Spins up OpenLDAP + Redis, a real SSO server (bin/www, so the WSS relay is
# live), and a client that simulates the agent: it enrolls one, connects over
# WSS, sends a real LDAP bind as raw bytes, and verifies the SSO relays it into
# OpenLDAP and pipes the response back.
#
# docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit
# # exit code 0 = tunnel works; the client prints E2E PASS.
services:
ldap:
build:
context: .
dockerfile: Dockerfile.openldap
environment:
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
- ORG_NAME=Test SSO
command: ["sleep", "infinity"]
healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
volumes:
- ldap-data:/var/lib/ldap
- ldap-certs:/etc/openldap/certs
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
sso:
build:
context: .
dockerfile: Dockerfile.test-runner
command: ["node", "bin/www"]
environment:
- NODE_ENV=test
- NODE_PORT=3001
# Test OpenBao (theta-test-bao) — sso-broker token so the SSO can sign
# high-risk agent commands and read node-scoped secrets.
- VAULT_ADDR=http://theta-test-bao:8200
- VAULT_TOKEN=${VAULT_TOKEN:-}
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
- app_redis__redisConf__url=redis://redis:6379
- REDIS_URL=redis://redis:6379
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
- app_name=Test SSO
depends_on:
ldap:
condition: service_healthy
redis:
condition: service_healthy
client:
build:
context: .
dockerfile: Dockerfile.test-runner
command: ["sh", "-c", "seed-test-user && node test/tunnel_e2e.js"]
environment:
- NODE_ENV=test
- SSO_URL=http://sso:3001
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
- app_redis__redisConf__url=redis://redis:6379
- REDIS_URL=redis://redis:6379
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
- app_name=Test SSO
depends_on:
sso:
condition: service_started
ldap:
condition: service_healthy
redis:
condition: service_healthy
volumes:
ldap-data:
ldap-certs:
+202 -3
View File
@@ -6,7 +6,12 @@ nav_order: 5
# Theta Agent & Endpoint Management # Theta Agent & Endpoint Management
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go for Linux hosts across your home lab, infrastructure, or data center. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management. The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2)
endpoint management daemon written in Go for Linux hosts across your home lab,
infrastructure, or data center. It connects outbound via a long-lived WebSocket
connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`),
enabling real-time host telemetry, automated host discovery, and local-first
administrative management.
--- ---
@@ -44,10 +49,35 @@ host does not yield a credential that works anywhere else.
| `POST /api/agent/join-keys` | Mint one — returned **once** | | `POST /api/agent/join-keys` | Mint one — returned **once** |
| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts | | `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts |
| `DELETE /api/agent/join-keys/:id` | Remove it | | `DELETE /api/agent/join-keys/:id` | Remove it |
| `GET /api/agent/join-keys/:id/agents` | Which hosts enrolled through this key |
Revoking a join key does **not** disconnect hosts that already joined; they hold 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. their own tokens by then. Revoke the agent itself to cut a specific host off.
**Reuse.** Yes — a join key is not consumed on use. `AgentJoinKey.authenticate`
only checks `revoked` and `expires_on`; it never invalidates the key itself.
Every use increments `use_count` and stamps `last_used_on`, but the key keeps
working until you revoke or delete it (or it expires) — "one key works for as
many hosts as you like" above is literal, not a figure of speech.
**UI.** The **Install Agent** modal (Directory → Install Agent → Join key tab)
has a **Manage join keys** table below the mint/select dropdown: label, prefix,
created date, hosts joined, status, and **Revoke**/**Delete** actions per key.
Clicking a key's "N hosts" link expands the list of hosts that joined through
it (name, online status, joined date, last seen).
**Audit.** Yes, both halves are logged as structured `"component":"agent"`
lines, and the hosts-joined list in the UI above is queryable directly:
- Minting: `action: "join_key_issued"` records the acting admin (`actor`),
`label`, and `keyPrefix`.
- Each enrollment through that key: `action: "join"` records `agentId`,
`agentName`, `remoteAddr`, `joinKeyLabel`, and `joinKeyPrefix`.
- `GET /api/agent/join-keys/:id/agents` returns the same "which hosts did key
X add" answer the UI shows — it matches on the trace `Agent.enroll` leaves in
each agent's `description` ("Self-enrolled with join key `<prefix>`") rather
than a stored foreign key, since a join key is exchanged for a per-agent
token immediately and from then on the agent's own identity is what matters.
### Pre-registering a host ### Pre-registering a host
When you want the agent bound to a specific Directory host up front, enroll it When you want the agent bound to a specific Directory host up front, enroll it
@@ -167,6 +197,9 @@ To protect hosts against unauthorized control, `theta-agent` enforces a **strict
| **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). | | **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
| **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). | | **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). | | **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
| **LDAP Tunnel** | `ldap_tunnel` | Moderate | Serves a local LDAP byte-pump socket (`ldap_socket`, default `/run/theta/ldap.sock`) for SSSD/PAM. The agent never parses LDAP — it forwards raw bytes to the SSO, which relays them into its own OpenLDAP. |
| **Secrets** | `secrets` | Moderate | Renders OpenBao secrets to local files from templates (see [Secrets Engine](#secrets-engine---rendering-openbao-secrets-to-local-files) below). |
| **IAM** | `iam` | Critical | Applies SSO-pushed node identity config: sudo rules, SSH `AuthorizedKeysCommand` keys, `/etc/security/access.conf`, and revocation (`sss_cache -E` + session kill). Every push is Ed25519-signed. |
--- ---
@@ -197,6 +230,174 @@ would run `reboot`, `configure_ldap` and `arbitrary_bash` unverified.
--- ---
## Secrets Engine — rendering OpenBao secrets to local files
The agent can render OpenBao secrets to local files that any process on the
host — a bash script, a systemd unit, a Node app, whatever — reads like an
ordinary env file. The agent never holds a Vault token: it asks the SSO for the
values over its existing WSS channel, and the SSO fetches them from OpenBao
using its own access, scoped so the agent can only ever read its own node's
secrets.
**Node scope.** Every path an agent can request must start with
`secret/data/nodes/<this-agent's-id>/`. The SSO enforces this server-side
(`POST /api/v1/agent/secrets`); a request for any other node's path is
rejected:
```
$ curl -sk https://sso.example.com/api/v1/agent/secrets \
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
-d '{"paths":["secret/data/nodes/some-other-node-id/db"]}'
{"status":"error","message":"path outside node scope: secret/data/nodes/some-other-node-id/db"}
```
A compromised agent can therefore never reach another host's secrets, or
anything outside `secret/data/nodes/*`.
### Walkthrough: a 3rd-party app reads a secret the agent rendered
This walks through the whole path end to end, on a stack freshly brought up
from theta-suite's own `docs/fixtures.md` demo data — the same steps work on
any theta-suite install.
**1. Enroll the host.** Directory → Install Agent → mint a join key, run the
install command on the target host as root.
<a href="images/agent-install-join-key.png" target="_blank"><img src="images/agent-install-join-key.png" alt="Install Theta Agent modal with a freshly minted join key and install command" width="80%"></a>
On first connect the agent exchanges the join key for its own token + the
SSO's public key and writes both back into `/etc/theta42/agent.yml`. Note the
agent's id from `GET /api/agent/nodes` (or the Directory URL) — you need it for
the next step.
**2. Turn on the `secrets` capability and point it at a template.** Add to the
host's `/etc/theta42/agent.yml`:
```yaml
secrets:
- template: /etc/theta/templates/db.env.tpl
target: /etc/theta/rendered/db.env
reload: "" # optional: e.g. "systemctl reload myapp"
capabilities:
secrets: true
```
And the template itself, `/etc/theta/templates/db.env.tpl` — placeholders are
`{{ bao "secret/data/nodes/<agent-id>/<name>#<key>" }}`:
```
DB_USER="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#username" }}"
DB_PASS="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#password" }}"
```
Restart the agent to pick up the config change.
**3. Seed the secret.** From `theta-suite/` (theta-env), as the operator:
```
./setup.sh --seed-node-secret f9a30ab0-7d8a-4b77-a4c4-6a6383d084db db \
username=demoapp password=CorrectHorseBattery42
```
This writes to `secret/nodes/<agent-id>/db` in OpenBao (the CLI path — the HTTP
API the agent uses sees it as `secret/data/nodes/<agent-id>/db`, matched by the
node-scope check above). It's idempotent: it skips silently if that path is
already seeded.
**4. Trigger the render.** The Directory UI doesn't have a button for this yet
— push it the same way any admin command goes out, `POST
/api/agent/nodes/:id/command`. It's in the high-risk list, so the SSO signs it
automatically:
```
curl -X POST https://sso.example.com/api/agent/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/command \
-H "auth-token: <admin session token>" -H 'Content-Type: application/json' \
-d '{"command": "render_secrets", "payload": {}}'
```
The agent logs `Received command: render_secrets` / `Rendering secret
templates...` and atomically writes the target file at mode `0600`:
```
$ cat /etc/theta/rendered/db.env
DB_USER="demoapp"
DB_PASS="CorrectHorseBattery42"
```
Back in the Directory, the host's Metrics tab shows **Secrets** lit up green
among the reported capabilities:
<a href="images/agent-capabilities-metrics.png" target="_blank"><img src="images/agent-capabilities-metrics.png" alt="Directory Metrics tab showing live telemetry and the agent's reported capability badges, with Telemetry and Secrets lit green" width="80%"></a>
**5. Read it from a bash app on the same host.** The rendered file is just an
env file — no agent involvement needed to consume it:
```sh
#!/bin/sh
. /etc/theta/rendered/db.env
echo "DB_USER=$DB_USER"
echo "DB_PASS=$DB_PASS"
```
**6. Read it from a Node app on the same host:**
```js
const fs = require('fs');
const env = fs.readFileSync('/etc/theta/rendered/db.env', 'utf8');
const db = {};
for (const line of env.split('\n')) {
const m = /^(\w+)="(.*)"$/.exec(line.trim());
if (m) db[m[1]] = m[2];
}
console.log('DB_USER=' + db.DB_USER);
console.log('DB_PASS=' + db.DB_PASS);
```
Both print the same values the template resolved — `demoapp` /
`CorrectHorseBattery42` in this walkthrough. `theta-agent/demo/` in the
theta-agent repo has these two scripts ready to run.
### Alternative: calling the API directly
Rendering to a file is the normal path — it works for any app regardless of
language, and the secret never touches an HTTP client the app itself controls.
But an app can also fetch its node's secrets directly, bypassing the template
engine entirely (useful for debugging, or a process that wants to hold the
value only in memory). This uses the **agent's own bearer token**, not an admin
token — the same node-scope enforcement applies:
```sh
curl -sk https://sso.example.com/api/v1/agent/secrets \
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
-d '{"paths":["secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db"]}'
```
```js
const token = process.env.THETA_AGENT_TOKEN; // from /etc/theta42/agent.yml
fetch('https://sso.example.com/api/v1/agent/secrets', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: ['secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db'] })
}).then(r => r.json()).then(d => console.log(d.secrets));
```
Both return:
```json
{
"status": "ok",
"secrets": {
"secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db": {
"username": "demoapp",
"password": "CorrectHorseBattery42"
}
}
}
```
---
## Installation & Deployment ## Installation & Deployment
### Quick One-Liner Install ### Quick One-Liner Install
@@ -294,5 +495,3 @@ Fix options:
> sure the proxy has a **persistent Host record** for the real SSO domain — not > sure the proxy has a **persistent Host record** for the real SSO domain — not
> just the `localtest.me` placeholder — so routing survives a proxy restart > just the `localtest.me` placeholder — so routing survives a proxy restart
> (an in-memory lookup cache can mask a missing Redis record for up to ~1h). > (an in-memory lookup cache can mask a missing Redis record for up to ~1h).
Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

+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. // WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
app.use('/api/agent', require('./routes/api_agent')); 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 // OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter); app.use('/oauth', oauthRouter);
app.use('/api/oauth', middleware.auth, oauthApiRouter); 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 permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager'); const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys'); const agentKeys = require('../utils/agent_keys');
const ldapTunnel = require('../utils/ldap_tunnel');
const { Agent, AgentJoinKey } = require('../models/agent'); const { Agent, AgentJoinKey } = require('../models/agent');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin']; 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 // 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 // SSO's persisted Ed25519 key and the agent verifies against the key pinned in
// its agent.yml. // 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) ── // ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
// This is a plain Express Router exported directly so app.js can // 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); } } 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) => { router.post('/join-keys', async (req, res, next) => {
try { try {
const { label, expiresInDays } = req.body || {}; const { label, expiresInDays } = req.body || {};
@@ -358,6 +376,11 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
await agentManager.handleResponse(current, payload); await agentManager.handleResponse(current, payload);
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload }); if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
break; 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: default:
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`); 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', () => { ws.on('close', () => {
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`); console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
agentManager.unregisterAgent(agent.id, ws); agentManager.unregisterAgent(agent.id, ws);
ldapTunnel.cleanup(agent.id);
}); });
// Send initial welcome/config payload. When this connection enrolled via a // 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 || '', cpu: payload.cpu || '',
ram_total_gb: payload.ram_total_gb || 0, ram_total_gb: payload.ram_total_gb || 0,
disk_total_gb: payload.disk_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.touch(agent, { lastDiscovery: discovery });
await this.applyDiscoveryToDirectory(agent, 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">IPs: ${esc((d.ip_addresses || []).join(', '))}</div>
<div class="col-6">Location: ${esc(d.location || '')}</div> <div class="col-6">Location: ${esc(d.location || '')}</div>
</div> </div>
<hr><h6>Capabilities</h6>
<div class="small">${capabilitiesHtml(d.capabilities)}</div>
</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. // Re-fetch agents (every 30s + on socket events) so status dots stay live.
async function refreshAgents() { async function refreshAgents() {
try { try {
@@ -1782,6 +1805,28 @@
</div> </div>
</div> </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> </div>
<!-- ── Pre-register: bind to a host resource up front ───────────── --> <!-- ── 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 // 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 // able to rebuild an install command from it. Minting is the only way to see
// a key's value, and only once. // 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 var mintedJoinKey = null; // in-memory, for the command shown right now
function loadAgentJoinKeys() { function loadAgentJoinKeys() {
app.api.get('agent/join-keys', function(err, res) { 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(); const $sel = $('#agent-join-key-select').empty();
if (!agentJoinKeys.length) { if (!agentJoinKeys.length) {
$sel.append('<option value="">No join keys yet — create one</option>'); $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})`)); $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(); 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() { async function mintAgentJoinKey() {
try { try {
const res = await app.api.post('agent/join-keys', { label: 'ui' }); const res = await app.api.post('agent/join-keys', { label: 'ui' });
+110
View File
@@ -0,0 +1,110 @@
'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));