diff --git a/CHANGELOG.md b/CHANGELOG.md index 1138e19..cbbb606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//*`; + 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 ``") 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 ### Fixed diff --git a/Dockerfile.test-runner b/Dockerfile.test-runner index dc75fbb..4af969c 100644 --- a/Dockerfile.test-runner +++ b/Dockerfile.test-runner @@ -47,6 +47,8 @@ COPY directory_spec.md /directory_spec.md COPY test_seed.js ./test_seed.js COPY test/seed-test-user.sh /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 CMD ["sh", "-c", "seed-test-user && npm test"] diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..a6f694e --- /dev/null +++ b/docker-compose.e2e.yml @@ -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: diff --git a/docs/agents.md b/docs/agents.md index a373a54..3d3fe8d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -6,7 +6,12 @@ nav_order: 5 # 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:///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:///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/:id/revoke` | Stop it enrolling new hosts | | `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 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 ``") 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 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"]`). | | **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). | +| **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//`. 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 " -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. + +Install Theta Agent modal with a freshly minted join key and install command + +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//#" }}`: + +``` +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//db` in OpenBao (the CLI path — the HTTP +API the agent uses sees it as `secret/data/nodes//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: " -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: + +Directory Metrics tab showing live telemetry and the agent's reported capability badges, with Telemetry and Secrets lit green + +**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 " -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 ### Quick One-Liner Install @@ -294,5 +495,3 @@ Fix options: > 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 > (an in-memory lookup cache can mask a missing Redis record for up to ~1h). - - diff --git a/docs/images/agent-capabilities-metrics.png b/docs/images/agent-capabilities-metrics.png new file mode 100644 index 0000000..ac88159 Binary files /dev/null and b/docs/images/agent-capabilities-metrics.png differ diff --git a/docs/images/agent-install-join-key.png b/docs/images/agent-install-join-key.png new file mode 100644 index 0000000..36e781f Binary files /dev/null and b/docs/images/agent-install-join-key.png differ diff --git a/nodejs/app.js b/nodejs/app.js index f842bc7..af912be 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -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); diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index 650cf1a..b05e2eb 100644 --- a/nodejs/routes/api_agent.js +++ b/nodejs/routes/api_agent.js @@ -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 ") 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 diff --git a/nodejs/routes/api_agent_ops.js b/nodejs/routes/api_agent_ops.js new file mode 100644 index 0000000..505fdc9 --- /dev/null +++ b/nodejs/routes/api_agent_ops.js @@ -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//db"] } +// -> { status: "ok", secrets: { "secret/data/nodes//db": { key: value } } } +// +// The agent may only read under its own node prefix (secret/data/nodes//*), +// 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; diff --git a/nodejs/routes/api_ldap.js b/nodejs/routes/api_ldap.js new file mode 100644 index 0000000..0cda320 --- /dev/null +++ b/nodejs/routes/api_ldap.js @@ -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; diff --git a/nodejs/tests/api_agent_ops.test.js b/nodejs/tests/api_agent_ops.test.js new file mode 100644 index 0000000..5537112 --- /dev/null +++ b/nodejs/tests/api_agent_ops.test.js @@ -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); + }); +}); diff --git a/nodejs/tests/api_ldap.test.js b/nodejs/tests/api_ldap.test.js new file mode 100644 index 0000000..a0eb562 --- /dev/null +++ b/nodejs/tests/api_ldap.test.js @@ -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); + }); +}); diff --git a/nodejs/utils/agent_auth.js b/nodejs/utils/agent_auth.js new file mode 100644 index 0000000..303ef0e --- /dev/null +++ b/nodejs/utils/agent_auth.js @@ -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 }; diff --git a/nodejs/utils/agent_manager.js b/nodejs/utils/agent_manager.js index 3bb1926..8084774 100644 --- a/nodejs/utils/agent_manager.js +++ b/nodejs/utils/agent_manager.js @@ -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); diff --git a/nodejs/utils/ldap_tunnel.js b/nodejs/utils/ldap_tunnel.js new file mode 100644 index 0000000..e404d8c --- /dev/null +++ b/nodejs/utils/ldap_tunnel.js @@ -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 }; diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 7b0d1b9..5c1111d 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -724,9 +724,32 @@
IPs: ${esc((d.ip_addresses || []).join(', '))}
Location: ${esc(d.location || '')}
+
Capabilities
+
${capabilitiesHtml(d.capabilities)}
`; } + // 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) => `${esc(name)}`; + 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 + ? `
Service control: ${esc(sc.join(', '))}
` + : ''; + 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 @@ + +
+
+ Manage join keys +
+
+ + + + + + + + + + + + +
LabelPrefixCreatedHosts joinedStatusActions
+ +
+
@@ -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(''); @@ -1990,10 +2038,97 @@ $sel.append($('