diff --git a/CHANGELOG.md b/CHANGELOG.md index 1138e19..f158bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,58 @@ +# v1.31.0 - 2026-08-07 + +### Added +- **Resource Secrets Engine & Zero-View Security.** OpenBao KV-v2 encrypted secrets for directory resources (`secret/data/resources//conf`). Zero-View UI & API model — secret values are never returned to admin browsers or UI templates, and delivered exclusively to authenticated `theta-agent` instances. +- **Strict Secret Key Regex Validation.** Secret keys are validated against `^[A-Za-z0-9_]+$` (Standard Environment Variable format, e.g. `DB_PASSWORD`). +- **Field-Populating Password Generator.** Cryptographic secret generator (`window.crypto.getRandomValues`) with length selector dropdown (8–128 chars) populating input fields with security notices. +- **Multi-Level Secret Inheritance.** Dynamic secret resolution across any depth of the resource tree (`Services / Apps -> Hosts / Nodes -> Global Sites`). +- **Non-Blocking UI Confirmations.** Replaced browser blocking dialogs with async `app.messages.confirm()` banners. +- **UI Directory Layout Improvements.** Fixed Directory table resource name and badge order for enhanced readability. + +### Fixed +- **SSSD `sshPublicKey` Mapping.** Included `ldap_user_ssh_public_key = sshPublicKey` in generated agent `sssd.conf` template. + +# 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/models/resource.js b/nodejs/models/resource.js index 92f296e..ca90341 100644 --- a/nodejs/models/resource.js +++ b/nodejs/models/resource.js @@ -191,6 +191,24 @@ class Resource extends Model { } return null; } + + // Walk all parent ResourceEdges upwards recursively to find all ancestor + // resources (Host, Cluster, Site, etc.). + static async findAllAncestors(resourceId, visited = new Set()) { + if (visited.has(resourceId)) return []; + visited.add(resourceId); + + const ancestors = []; + const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } }).catch(() => []); + for (const edge of parentEdges) { + const parent = await this.get(edge.parentId).catch(() => null); + if (!parent) continue; + ancestors.push(parent); + const higher = await this.findAllAncestors(parent.id, visited); + ancestors.push(...higher); + } + return ancestors; + } } class ResourceEdge extends Model { diff --git a/nodejs/package.json b/nodejs/package.json index 7fc1e0e..cdcc146 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.30.2", + "version": "1.31.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/public/resources/theta-agent/install.sh b/nodejs/public/resources/theta-agent/install.sh index 7a782cf..4e3fcfe 100644 --- a/nodejs/public/resources/theta-agent/install.sh +++ b/nodejs/public/resources/theta-agent/install.sh @@ -1,9 +1,7 @@ -#!/bin/bash +#!/bin/sh set -e # --- Configuration --- -# In a real environment, these would be derived from the script's download URL -# or passed as additional arguments. For now, we use the most recent release. BINARY_URL="${BINARY_URL:-}" CONFIG_DIR="/etc/theta42" CONFIG_FILE="$CONFIG_DIR/agent.yml" @@ -15,20 +13,50 @@ RED='\033[0;31m' GREEN='\033[0;32m' NC='\033[0m' # No Color -log() { echo -e "${GREEN}[+]${NC} $1"; } -error() { echo -e "${RED}[!]${NC} $1"; exit 1; } +log() { echo "${GREEN}[+]${NC} $1"; } +error() { echo "${RED}[!]${NC} $1"; exit 1; } # 1. Root check -if [ "$EUID" -ne 0 ]; then +if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then error "This script must be run as root." fi +# Install SSSD and PAM integration packages if missing +install_sssd_deps() { + if ! command -v sssd >/dev/null 2>&1; then + log "Installing SSSD and PAM integration dependencies..." + if command -v apt-get >/dev/null 2>&1; then + DEBIAN_FRONTEND=noninteractive apt-get update -qq || true + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true + if command -v pam-auth-update >/dev/null 2>&1; then + pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true + fi + elif command -v dnf >/dev/null 2>&1; then + dnf install -y sssd sssd-ldap sssd-tools || true + elif command -v yum >/dev/null 2>&1; then + yum install -y sssd sssd-ldap sssd-tools || true + elif command -v pacman >/dev/null 2>&1; then + pacman -S --noconfirm sssd || true + elif command -v zypper >/dev/null 2>&1; then + zypper in -y sssd || true + fi + else + log "SSSD is already installed." + fi + mkdir -p /etc/sssd + chmod 755 /etc/sssd +} + # 2. Argument Parsing URL="" TOKEN="" +JOIN_KEY="" +PUBLIC_KEY="" B64_CONFIG="" +INSTALL_SSSD=0 -while [[ $# -gt 0 ]]; do +while [ $# -gt 0 ]; do case $1 in --url) URL="$2" @@ -38,6 +66,18 @@ while [[ $# -gt 0 ]]; do TOKEN="$2" shift 2 ;; + --public-key) + PUBLIC_KEY="$2" + shift 2 + ;; + --join-key) + JOIN_KEY="$2" + shift 2 + ;; + --install-sssd|--ldap) + INSTALL_SSSD=1 + shift + ;; *) B64_CONFIG="$1" shift @@ -45,12 +85,9 @@ while [[ $# -gt 0 ]]; do esac done -# Validation -if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then - error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token." - echo "Usage examples:" - echo " sh install.sh \"BASE64_CONFIG\"" - echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\"" +# Validation: require credentials ONLY if config file does not already exist +if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then + error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token." exit 1 fi @@ -71,8 +108,9 @@ if [ -z "$BINARY_URL" ]; then fi log "Downloading binary from $BINARY_URL..." -curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary." -chmod +x "$BIN_PATH" +curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary." +chmod +x "$BIN_PATH.tmp" +mv -f "$BIN_PATH.tmp" "$BIN_PATH" # 4. Setup configuration log "Preparing configuration directory $CONFIG_DIR..." @@ -82,23 +120,32 @@ chmod 755 "$CONFIG_DIR" if [ -n "$B64_CONFIG" ]; then log "Decoding and writing configuration from base64..." echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration." -else +elif [ ! -f "$CONFIG_FILE" ]; then log "Generating minimal configuration from arguments..." - # Create a minimal yaml with the provided URL and Token cat < "$CONFIG_FILE" server_url: "$URL" auth_token: "$TOKEN" +join_key: "$JOIN_KEY" +public_key: "$PUBLIC_KEY" location: "unknown" capabilities: telemetry: true - configure_ldap: false + configure_ldap: true + ldap_tunnel: true reboot: false service_control: [] arbitrary_bash: false EOF +else + log "Preserving existing configuration at $CONFIG_FILE" fi chmod 600 "$CONFIG_FILE" +# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled +if [ "$INSTALL_SSSD" -eq 1 ] || grep -qE -i 'configure_ldap:[[:space:]]*true' "$CONFIG_FILE" 2>/dev/null; then + install_sssd_deps +fi + # 5. Setup systemd service log "Creating systemd service unit..." cat < "$SERVICE_FILE" @@ -111,8 +158,6 @@ Type=simple ExecStart=$BIN_PATH Restart=always RestartSec=5 -StandardOutput=syslog -StandardError=syslog SyslogIdentifier=theta-agent [Install] diff --git a/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 b/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 index cb9c281..92c74b7 100755 Binary files a/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 and b/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 differ diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index 650cf1a..74e0224 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 || {}; @@ -288,13 +306,24 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) { const joinKey = await AgentJoinKey.authenticate(token); if (joinKey) { const hostname = (url.searchParams.get('hostname') || '').trim(); - const enrolled = await Agent.enroll({ - name: hostname || `agent-${Date.now().toString(36)}`, - description: `Self-enrolled with join key ${joinKey.keyPrefix}`, - enrolledBy: `join-key:${joinKey.label}` - }); - agent = enrolled.agent; - issuedToken = enrolled.token; + let existingAgent = null; + if (hostname) { + const matches = await Agent.list({ where: { name: hostname } }); + existingAgent = matches && matches.find(a => !a.revoked); + } + if (existingAgent) { + const newToken = await existingAgent.rotateToken(); + agent = existingAgent; + issuedToken = newToken; + } else { + const enrolled = await Agent.enroll({ + name: hostname || `agent-${Date.now().toString(36)}`, + description: `Self-enrolled with join key ${joinKey.keyPrefix}`, + enrolledBy: `join-key:${joinKey.label}` + }); + agent = enrolled.agent; + issuedToken = enrolled.token; + } await joinKey.update({ use_count: (joinKey.use_count || 0) + 1, last_used_on: Math.floor(Date.now() / 1000) @@ -327,6 +356,23 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) { // `ws` discards messages emitted while no listener is attached. agentManager.registerAgent(agent, ws, remoteAddr); + if (issuedToken) { + const publicKey = await agentManager.publicKeyBase64(); + try { + ws.send(JSON.stringify({ + type: 'config', + payload: { + enrolled: true, + auth_token: issuedToken, + public_key: publicKey + } + })); + console.log(`[Theta Agent] Sent auto-enrollment credentials to "${agent.name}"`); + } catch (err) { + console.error(`[Theta Agent] Failed to send auto-enrollment config to "${agent.name}":`, err.message); + } + } + ws.on('message', async (message) => { try { const data = JSON.parse(message); @@ -346,6 +392,65 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) { case 'discovery': await agentManager.handleDiscovery(current, payload); if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload }); + if (payload.capabilities && payload.capabilities.configure_ldap) { + const conf = require('@simpleworkjs/conf'); + const os = require('os'); + const ssoHost = (conf.stack && conf.stack.ssoHost) || 'sso.laptop-dev.vm42.us'; + const ldapBaseDn = (conf.stack && conf.stack.ldapBaseDn) || 'dc=laptop-dev,dc=vm42,dc=us'; + + const lanIps = []; + const ifaces = os.networkInterfaces(); + for (const dev in ifaces) { + for (const details of ifaces[dev]) { + if (!details.internal && details.family === 'IPv4') lanIps.push(details.address); + } + } + const uriList = [ + `ldapi://%2frun%2ftheta%2fldap.sock`, + `ldap://127.0.0.1:3890`, + `ldap://127.0.0.1:389`, + `ldap://${ssoHost}:389`, + `ldaps://${ssoHost}:636`, + ...lanIps.map(ip => `ldap://${ip}:389`) + ]; + const ldapUris = [...new Set(uriList)].join(', '); + + const sssdConfig = `[sssd] +config_file_version = 2 +domains = default + +[domain/default] +id_provider = ldap +auth_provider = ldap +chpass_provider = ldap +sudo_provider = ldap +ldap_uri = ${ldapUris} +ldap_search_base = ${ldapBaseDn} +ldap_user_search_base = ou=people,${ldapBaseDn} +ldap_group_search_base = ou=groups,${ldapBaseDn} +ldap_sudo_search_base = ou=people,${ldapBaseDn} +ldap_schema = rfc2307bis +ldap_user_object_class = posixAccount +ldap_user_name = uid +ldap_user_ssh_public_key = sshPublicKey +ldap_group_object_class = groupOfNames +ldap_group_member = member +ldap_id_mapping = false +ldap_id_use_start_tls = false +ldap_tls_reqcert = never +cache_credentials = true +entry_cache_timeout = 600 +entry_cache_user_timeout = 600 +entry_cache_group_timeout = 600 +entry_cache_sudo_timeout = 600 +refresh_expired_interval = 300 +`; + agentManager.sendCommand(current, 'configure_ldap', { config: sssdConfig }, true).then(() => { + console.log(`[Theta Agent] Pushed auto configure_ldap to "${current.name}"`); + }).catch(err => { + console.error(`[Theta Agent] Auto push configure_ldap to "${current.name}" failed:`, err.message); + }); + } break; case 'telemetry': await agentManager.handleTelemetry(current, payload); @@ -358,6 +463,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 +479,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..3aecdce --- /dev/null +++ b/nodejs/routes/api_agent_ops.js @@ -0,0 +1,115 @@ +'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. +const { Resource } = require('../models/resource'); +const { SharedSecretGrant } = require('../models/shared_secret_grant'); +const { SharedSecret } = require('../models/shared_secret'); + +router.post('/secrets', async (req, res, next) => { + try { + const agent = await authenticateAgent(req); + if (!agent) return res.status(401).json({ status: 'error', message: 'unauthorized' }); + + let { paths } = req.body || {}; + let boundResource = null; + if (agent.resourceId) { + boundResource = await Resource.get(agent.resourceId).catch(() => null); + } + + if (!Array.isArray(paths) || paths.length === 0) { + paths = [`secret/data/nodes/${agent.id}/conf`]; + if (boundResource && boundResource.slug) { + paths.push(`secret/data/resources/${boundResource.slug}/conf`); + } + } + + // Allowed prefixes for this agent: + // 1. Node scope: secret/data/nodes// + // 2. Bound Resource scope: secret/data/resources// + // 3. Shared Resource Grants: secret/data/resources// + const allowedPrefixes = [`secret/data/nodes/${agent.id}/`]; + if (boundResource && boundResource.slug) { + allowedPrefixes.push(`secret/data/resources/${boundResource.slug}/`); + } + + // Add granted shared resources + if (boundResource) { + const grants = await SharedSecretGrant.listForGrantee('resource', boundResource.id).catch(() => []); + for (const g of grants) { + const sharedSec = await SharedSecret.get(g.secretId).catch(() => null); + if (sharedSec && sharedSec.slug) { + allowedPrefixes.push(`secret/data/resources/${sharedSec.slug}/`); + allowedPrefixes.push(`secret/data/shared/${sharedSec.ownerUid}/${sharedSec.slug}/`); + } + } + } + + const secrets = {}; + for (let p of paths) { + if (typeof p !== 'string') continue; + // Normalize human shorthand "resources/foo/bar" -> "secret/data/resources/foo/bar" + if (p.startsWith('resources/')) { + p = `secret/data/resources/${p.slice('resources/'.length)}`; + } + + const isAllowed = allowedPrefixes.some(prefix => p.startsWith(prefix)); + if (!isAllowed) { + return res.status(403).json({ status: 'error', message: `path outside authorized scope: ${p}` }); + } + + const r = await baoConf.request('GET', p); + if (r.ok) { + const body = await r.json().catch(() => ({})); + const rawMap = (body.data && body.data.data) || {}; + const resolvedMap = {}; + for (const [k, v] of Object.entries(rawMap)) { + const strV = String(v || ''); + if (strV.startsWith('INHERIT:')) { + const parts = strV.split(':'); + if (parts.length >= 3) { + const targetSlug = parts[1]; + const targetKey = parts[2]; + const parentR = await baoConf.request('GET', `secret/data/resources/${targetSlug}/conf`); + if (parentR.ok) { + const parentBody = await parentR.json().catch(() => ({})); + const parentMap = (parentBody.data && parentBody.data.data) || {}; + resolvedMap[k] = parentMap[targetKey] || ''; + } else { + resolvedMap[k] = ''; + } + } else { + resolvedMap[k] = ''; + } + } else { + resolvedMap[k] = v; + } + } + secrets[p] = resolvedMap; + } else { + secrets[p] = {}; + } + } + + return res.json({ status: 'ok', secrets }); + } catch (err) { next(err); } +}); + +module.exports = router; diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index c994d1e..d0e5fa7 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -199,6 +199,7 @@ router.get('/resources', async (req, res, next) => { try { let resources = await Resource.list(); resources = resources.filter(r => { + if (r.kind === 'host' || r.kind === 'site') return true; const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual'); const isManaged = r.metadata?.managed === true; return !isAuto || isManaged; @@ -600,4 +601,140 @@ router.get('/audit-logs', async (req, res, next) => { } catch (err) { next(err); } }); +// ── Resource Secrets API (OpenBao KV-v2 under secret/data/resources//conf) ── +const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/; + +router.get('/resources/:id/secrets', async (req, res, next) => { + try { + const resource = await Resource.get(req.params.id); + if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' }); + const baoConf = require('@simpleworkjs/bao-conf'); + + // Read resource secrets from OpenBao + const path = `secret/data/resources/${resource.slug}/conf`; + const r = await baoConf.request('GET', path); + let secretsMap = {}; + if (r.ok) { + const body = await r.json().catch(() => ({})); + secretsMap = (body.data && body.data.data) || {}; + } + + // Zero-View Security: Return metadata only, NEVER return raw secret values + const secrets = Object.keys(secretsMap).map(key => { + const val = String(secretsMap[key] || ''); + let isInherited = false; + let parentSlug = null; + let parentKey = null; + + if (val.startsWith('INHERIT:')) { + isInherited = true; + const parts = val.split(':'); + if (parts.length >= 3) { + parentSlug = parts[1]; + parentKey = parts[2]; + } else if (parts.length === 2) { + parentKey = parts[1]; + } + } + + return { + key, + hasValue: val.length > 0, + isInherited, + parentSlug, + parentKey + }; + }); + + // Find all ancestor resources across any depth (Host, Site, etc.) + Global Sites + const parentSecrets = []; + const seenAncestors = new Set(); + + const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []); + const sites = await Resource.list({ where: { kind: 'site' } }).catch(() => []); + const allAncestors = [...ancestors, ...sites]; + + for (const parent of allAncestors) { + if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue; + seenAncestors.add(parent.id); + + const parentPath = `secret/data/resources/${parent.slug}/conf`; + const parentR = await baoConf.request('GET', parentPath); + if (parentR.ok) { + const parentBody = await parentR.json().catch(() => ({})); + const pMap = (parentBody.data && parentBody.data.data) || {}; + for (const pKey of Object.keys(pMap)) { + parentSecrets.push({ + parentSlug: parent.slug, + parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'PARENT'})`, + key: pKey + }); + } + } + } + + res.json({ status: 'ok', resourceId: resource.id, slug: resource.slug, secrets, parentSecrets }); + } catch (err) { next(err); } +}); + +router.post('/resources/:id/secrets', async (req, res, next) => { + try { + const resource = await Resource.get(req.params.id); + if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' }); + const secrets = (req.body.secrets && typeof req.body.secrets === 'object') ? req.body.secrets : {}; + + // Validate key names (Standard Env Var format: A-Z, 0-9, underscores) + for (const key of Object.keys(secrets)) { + if (!SECRET_KEY_REGEX.test(key)) { + return res.status(400).json({ + status: 'error', + message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)` + }); + } + } + + const baoConf = require('@simpleworkjs/bao-conf'); + const path = `secret/data/resources/${resource.slug}/conf`; + const r = await baoConf.request('POST', path, { data: secrets }); + if (!r.ok) { + return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' }); + } + res.json({ status: 'ok' }); + } catch (err) { next(err); } +}); + +router.get('/resources/:id/grants', async (req, res, next) => { + try { + const { SharedSecretGrant } = require('../models/shared_secret_grant'); + const { SharedSecret } = require('../models/shared_secret'); + const resource = await Resource.get(req.params.id); + if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' }); + const grants = await SharedSecretGrant.listForGrantee('resource', resource.id); + const sharedSecretIds = grants.map(g => g.secretId); + const secrets = sharedSecretIds.length ? await SharedSecret.list({ where: { id: { in: sharedSecretIds } } }) : []; + res.json({ status: 'ok', grants: secrets.map(s => ({ id: s.id, slug: s.slug, description: s.description })) }); + } catch (err) { next(err); } +}); + +router.post('/resources/:id/grants', async (req, res, next) => { + try { + const { SharedSecretGrant } = require('../models/shared_secret_grant'); + const { SharedSecret } = require('../models/shared_secret'); + const resource = await Resource.get(req.params.id); + if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' }); + const { secretSlug, action } = req.body || {}; + const secret = await SharedSecret.getBySlug(secretSlug); + if (!secret) return res.status(404).json({ status: 'error', message: `shared secret '${secretSlug}' not found` }); + + if (action === 'revoke') { + const existing = await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType: 'resource', granteeId: resource.id } }); + for (const g of existing) await g.delete(); + return res.json({ status: 'ok', message: 'grant revoked' }); + } else { + await SharedSecretGrant.grant({ secretId: secret.id, granteeType: 'resource', granteeId: resource.id, grantedBy: req.user.uid }); + return res.json({ status: 'ok', message: 'grant created' }); + } + } 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..c17de60 --- /dev/null +++ b/nodejs/tests/api_agent_ops.test.js @@ -0,0 +1,74 @@ +'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 defaults to agent node & resource secrets', async () => { + const { token } = await enrollAgent(); + const res = await request(app) + .post('/api/v1/agent/secrets') + .set('Authorization', `Bearer ${token}`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body.secrets).toBeDefined(); + }); +}); 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..c8d49e9 100644 --- a/nodejs/utils/agent_manager.js +++ b/nodejs/utils/agent_manager.js @@ -21,12 +21,17 @@ class AgentManager { * Sort keys alphabetically, remove whitespace, omit 'signature' key. */ canonicalize(payload) { - const cleanObj = {}; - const sortedKeys = Object.keys(payload).filter(k => k !== 'signature').sort(); - for (const key of sortedKeys) { - cleanObj[key] = payload[key]; - } - return JSON.stringify(cleanObj); + const sortObj = (val) => { + if (val === null || typeof val !== 'object') return val; + if (Array.isArray(val)) return val.map(sortObj); + const sorted = {}; + const keys = Object.keys(val).filter(k => k !== 'signature').sort(); + for (const k of keys) { + sorted[k] = sortObj(val[k]); + } + return sorted; + }; + return JSON.stringify(sortObj(payload)); } /** @@ -120,7 +125,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); @@ -145,6 +153,7 @@ class AgentManager { ram_total_gb: discovery.ram_total_gb || undefined, disk_total_gb: discovery.disk_total_gb || undefined, ip: (discovery.ip_addresses || [])[0] || undefined, + public_ip: discovery.public_ip || undefined, agentId: agent.id, last_seen: Date.now() }; @@ -165,15 +174,54 @@ class AgentManager { if (!discovery.hostname) return; const { DiscoveryReconciler } = require('../services/discovery_reconciler'); + const { ResourceEdge } = require('../models/resource'); + + const hostSlug = `host-${discovery.hostname.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}`; await DiscoveryReconciler.reconcile('theta-agent', { resources: [{ kind: 'host', name: discovery.hostname, - slug: `agent-${agent.id.slice(0, 8)}`, - metadata: { ...metadata, subType: 'linux' } + slug: hostSlug, + metadata: { ...metadata, subType: 'linux', managed: true } }], edges: [] }); + + // Find the matched or created host resource + const allHosts = await Resource.list({ where: { kind: 'host' } }); + const hostRes = allHosts.find(r => + r.name.toLowerCase() === discovery.hostname.toLowerCase() || + r.slug === hostSlug || + r.metadata?.agentId === agent.id + ); + + if (hostRes) { + // Bind the agent to its Host resource + await agent.update({ resourceId: hostRes.id }).catch(() => {}); + + // Attach host to matching Site by Public IP if not already parented + const existingEdges = await ResourceEdge.list({ where: { childId: hostRes.id } }); + if (existingEdges.length === 0) { + const sites = await Resource.list({ where: { kind: 'site' } }); + let targetSite = null; + if (discovery.public_ip) { + targetSite = sites.find(s => { + const siteIp = (s.metadata?.public_ip || s.metadata?.ip || s.metadata?.address || '').trim(); + return siteIp && (siteIp === discovery.public_ip || siteIp.includes(discovery.public_ip)); + }); + } + if (!targetSite) targetSite = sites[0]; + + if (targetSite) { + await ResourceEdge.create({ + id: crypto.randomUUID(), + parentId: targetSite.id, + childId: hostRes.id, + relation: 'hosts' + }).catch(() => {}); + } + } + } } catch (err) { // Never let a directory write break the agent connection. console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message); 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..58bcf51 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -48,6 +48,10 @@ +
+ + +
+
Only letters, numbers, and underscores allowed (e.g. DB_PASSWORD).
+ +
+ + +
+
+ +
+ + +
+
+ + +
+
+ +
+
+ + + + + + + + `; + // Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally // URL-tracked) modal DOM. Callers then populate fields via .val() and hide // the Groups/Children tabs in add-mode (no resource id to scope them to). @@ -519,6 +612,7 @@ {id: 'details', label: 'Details', bodyHtml: detailsTabHtml}, {id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml}, {id: 'children', label: 'Children', bodyHtml: childrenTabHtml}, + {id: 'secrets', label: 'Secrets & OpenBao', bodyHtml: secretsTabHtml}, {id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)}, ], footer: { @@ -527,7 +621,7 @@ }, url: id ? {path: '/directory/' + resourcesById[id].slug} : null, }); - $('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id); + $('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn, #sw-modal-tab-secrets-btn').closest('li').toggle(!!id); } function refreshChildrenUI(resourceId) { @@ -724,9 +818,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 { @@ -798,8 +915,15 @@ function renderTable() { const filter = $('#search-filter').val().toLowerCase(); const sort = $('#sort-by').val(); + const showPlumbing = $('#toggle-plumbing').is(':checked'); let filtered = rawResources.filter(r => { + if (!showPlumbing && !filter) { + const sub = (r.metadata?.subType || '').toLowerCase(); + if (r.kind === 'container' || r.kind === 'oauth' || sub === 'sidecar' || sub === 'container' || sub === 'openresty') { + return false; + } + } if (!filter) return true; return (r.name || '').toLowerCase().includes(filter) || (r.slug || '').toLowerCase().includes(filter) || @@ -1354,8 +1478,188 @@ refreshGroupsUI(r.id); refreshEdgesUI(r.id); refreshChildrenUI(r.id); + loadResourceSecrets(r.id); await loadLdapGroups(); } + + var currentResourceSecretsList = []; + var currentParentSecretsList = []; + var rawResourceSecretsMap = {}; + + const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/; + + function validateSecretKeyInput(el) { + const $el = $(el); + const val = $el.val().trim(); + if (val && !SECRET_KEY_REGEX.test(val)) { + $el.addClass('is-invalid'); + return false; + } else { + $el.removeClass('is-invalid'); + return true; + } + } + + function generateRandomString(len) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?'; + const bytes = new Uint8Array(len); + window.crypto.getRandomValues(bytes); + let str = ''; + for (let i = 0; i < len; i++) { + str += chars[bytes[i] % chars.length]; + } + return str; + } + + async function loadResourceSecrets(id) { + const $tbody = $('#secrets-table-body').empty(); + $tbody.append('Loading secrets from OpenBao...'); + currentResourceSecretsList = []; + currentParentSecretsList = []; + rawResourceSecretsMap = {}; + + try { + const res = await app.api.get(`directory-admin/resources/${id}/secrets`); + currentResourceSecretsList = (res && res.secrets) || []; + currentParentSecretsList = (res && res.parentSecrets) || []; + renderSecretsTable(); + populateParentSecretsDropdown(); + } catch (err) { + $tbody.empty().append(`Failed to load secrets: ${esc(err.message || 'Unknown error')}`); + } + } + + function populateParentSecretsDropdown() { + const $card = $('#inherit-secret-card'); + const $select = $('#inherit-parent-select').empty(); + + if (!currentParentSecretsList || currentParentSecretsList.length === 0) { + $card.hide(); + return; + } + + currentParentSecretsList.forEach(p => { + const valStr = `INHERIT:${p.parentSlug}:${p.key}`; + const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`; + $select.append(``); + }); + $card.show(); + } + + function renderSecretsTable() { + const $tbody = $('#secrets-table-body').empty(); + + if (currentResourceSecretsList.length === 0) { + $tbody.append('No secrets configured for this resource yet.'); + return; + } + + currentResourceSecretsList.forEach((s, idx) => { + const $row = $(` + + ${esc(s.key)} + + ${s.isInherited + ? `Inherited from ${esc(s.parentSlug || 'Parent')} (${esc(s.parentKey || s.key)})` + : `Configured in OpenBao Secret Value Hidden` + } + + + + + + + `); + $tbody.append($row); + }); + } + + function generateSecretValue() { + let key = $('#new-secret-key').val().trim(); + if (!key) { + key = 'SECRET_KEY'; + $('#new-secret-key').val(key); + } + const len = parseInt($('#gen-secret-length').val(), 10) || 32; + const randomSecret = generateRandomString(len); + $('#new-secret-val').val(randomSecret); + $('#gen-secret-notice').show(); + } + + function editSecretKey(key) { + $('#new-secret-key').val(key); + $('#new-secret-val').val('').focus(); + $('#gen-secret-notice').hide(); + } + + async function addSecretRow() { + const keyEl = $('#new-secret-key')[0]; + const key = $('#new-secret-key').val().trim(); + const val = $('#new-secret-val').val(); + + if (!key) { + app.messages.action('Please enter a secret key name (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'warning'); + return; + } + if (!validateSecretKeyInput(keyEl)) { + app.messages.action('Invalid secret key format. Only uppercase/lowercase letters, numbers, and underscores are allowed (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'danger'); + return; + } + + rawResourceSecretsMap[key] = val || ''; + $('#new-secret-key').val(''); + $('#new-secret-val').val(''); + $('#gen-secret-notice').hide(); + await saveResourceSecretsMap(); + } + + async function inheritParentSecret() { + const childKey = $('#inherit-child-key').val().trim(); + const inheritVal = $('#inherit-parent-select').val(); + + if (!childKey) { + app.messages.action('Please enter a child secret key name (e.g. DB_HOST).', $('#secrets-tab-container'), 'warning'); + return; + } + if (!SECRET_KEY_REGEX.test(childKey)) { + app.messages.action('Invalid child key name. Only letters, numbers, and underscores allowed.', $('#secrets-tab-container'), 'danger'); + return; + } + if (!inheritVal) { + app.messages.action('Select a parent secret to inherit from.', $('#secrets-tab-container'), 'warning'); + return; + } + + rawResourceSecretsMap[childKey] = inheritVal; + $('#inherit-child-key').val(''); + await saveResourceSecretsMap(); + } + + async function deleteSecretKey(key) { + const confirmed = await app.messages.confirm(`Delete secret '${key}' from OpenBao?`, $('#secrets-tab-container'), 'danger'); + if (!confirmed) return; + delete rawResourceSecretsMap[key]; + await saveResourceSecretsMap(); + } + + async function saveResourceSecretsMap() { + const resourceId = $('#res-id').val(); + if (!resourceId) return; + + try { + app.messages.action('Saving secrets to OpenBao...', $('#secrets-tab-container'), 'info'); + await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { secrets: rawResourceSecretsMap }); + app.messages.action('Secret saved to OpenBao successfully!', $('#secrets-tab-container'), 'success'); + loadResourceSecrets(resourceId); + } catch (err) { + app.messages.action(err.message || 'Failed to save secrets to OpenBao', $('#secrets-tab-container'), 'danger'); + } + } + + function refreshResourceSecrets() { + const resourceId = $('#res-id').val(); + if (resourceId) loadResourceSecrets(resourceId); + } async function saveResource() { // Promote path: the modal was opened from a discovered inventory row, so @@ -1782,6 +2086,28 @@ + +
+
+ Manage join keys +
+
+ + + + + + + + + + + + +
LabelPrefixCreatedHosts joinedStatusActions
+ +
+
@@ -1974,12 +2300,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 +2319,97 @@ $sel.append($('