Compare commits

..

6 Commits

Author SHA1 Message Date
wmantly 15d9ce1078 feat: release v1.31.0 with Zero-View secrets engine, generator, multi-level inheritance, and SSSD key mappings 2026-08-07 23:26:39 -04:00
wmantly 181ca8c9cb Add LDAP-over-HTTPS API, agent secrets/IAM engines, and join key management
See CHANGELOG.md for the full breakdown. Summary:

- POST /api/v1/ldap/{bind,search}: LDAP-over-HTTPS so a client stops
  speaking raw LDAP and instead calls the SSO, which binds/searches its
  own OpenLDAP on the caller's behalf (DESIGN.md §3).
- LDAP byte-pump relay (utils/ldap_tunnel.js): forwards raw LDAP bytes
  from an agent's local socket into OpenLDAP over the existing agent WSS
  channel; the SSO never parses LDAP (DESIGN.md §4).
- POST /api/v1/agent/secrets: node-scoped OpenBao secret fetch for
  agents, enforced to each agent's own secret/data/nodes/<id>/* prefix
  (DESIGN.md §5).
- iam_apply signed command: push node-scoped IAM config (sudo rules, SSH
  keys, access control, revocation) to an agent (DESIGN.md §6).
- Agent capability badges on the Directory Metrics tab, sourced from the
  agent's own discovery frame.
- Join key management: GET /api/agent/join-keys/:id/agents (which hosts
  enrolled through a key) plus a Manage join keys table in the Install
  Agent modal with Revoke/Delete actions, confirmed inline per-row rather
  than a blocking native confirm() or the shared app.messages.confirm()
  banner (which desyncs across concurrent rows -- see CHANGELOG).
- docs/agents.md: capability matrix updated for the three new
  capabilities, a full secrets-engine walkthrough with screenshots
  (bash + Node consuming a rendered secret, plus the direct-API
  alternative), and the join-key reuse/UI/audit questions answered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 17:03:41 -04:00
wmantly 6e748bfa66 Merge pull request #173 from theta42/fix/smtp-from-fallback-and-doc-fixes
Fix SMTP From-address fallback rejection; catalog card icon order; doc corrections
2026-08-06 21:19:35 -04:00
wmantly a78db906e8 Fix SMTP From-address fallback rejection; catalog card icon order; doc corrections
Mail sending fell back to a hardcoded noreply@theta42.com From address when
smtp.from wasn't set, which authenticated relays reject with "Sender is not
same as SMTP authenticate username" since no relay authorized this account
to send as that address. Falls back to smtp.user first now.

Also: catalog card titles now read name-then-icon instead of icon-then-name,
and a handful of docs corrections found in an accuracy pass (configuration.md
missing the OpenBao/live-config layer, plugins.md undercounting plugin types,
vault.md describing OpenBao dev-mode/root-token access that doesn't reflect
the real production setup, orphaned discovery.md/vault.md pages linked in,
README's required-groups list missing app_sso_directory_admin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113gCdnfSCuZr6xvPDxTo3D
2026-08-06 21:13:38 -04:00
wmantly e7e3eeb6cd Merge pull request #172 from theta42/fix/test-email-sms-and-join-key-ui
fix: test email/SMS senders, all SMS delivery, join-key install UI (v1.30.1)
2026-08-06 14:40:04 -04:00
wmantly f178f1a972 fix: test email/SMS senders, all SMS delivery, join-key install UI (v1.30.1)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m43s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 31s
Pull Request Tests / Test Summary (push) Failing after 4s
Test Email always failed with "Email.send is not a function":
models/email.js exports {Mail}, and the handler required the module and
called .send on it directly. Every other caller destructures it.

Test SMS failed with "Unexpected token '<'": it POSTed to
https://api.voip.ms/v1.0/sms/send with Basic auth, an endpoint that does
not exist. VoIP.ms's REST API is a GET against voip.ms/api/v1/rest.php
with api_username/api_password and method=sendSMS, so the fabricated URL
returned HTML and response.json() threw.

Worse, ALL SMS delivery was broken. models/sms.js called
PluginInstance.find({...}) but the ORM has no find -- the query method is
list({where}) -- so it threw on every send, before it could even fall
back to the direct VoIP.ms path. OTP-by-SMS and notifications were dead.

Both test endpoints now send through the same senders every real message
uses. A test that reimplements delivery proves nothing about whether real
delivery works, which is how two broken paths went unnoticed. Failures
report as 400 with the underlying reason rather than an opaque 500.

Adds a guard suite that fails the build on any call to a non-existent ORM
static, on requiring models/email without destructuring {Mail}, and on
any reference to the bogus api.voip.ms host.

Also: the Install Agent modal now leads with the join-key flow. v1.30.0
shipped join keys in the API and documented the modal as the place to get
one, but the modal still only did the pre-register flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:51:37 -04:00
39 changed files with 2218 additions and 91 deletions
+86
View File
@@ -1,3 +1,89 @@
# v1.31.0 - 2026-08-07
### Added
- **Resource Secrets Engine & Zero-View Security.** OpenBao KV-v2 encrypted secrets for directory resources (`secret/data/resources/<slug>/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 (8128 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/<id>/*`;
the SSO fetches with its own OpenBao access, so the agent never holds a Vault
token. Agent-token authed (not admin-gated).
- **`iam_apply` command** — the SSO pushes node-scoped IAM config (sudo rules,
SSH keys, access control, revocation) to an agent as a signed high-risk
command (DESIGN.md §6). Added to `HIGH_RISK_COMMANDS`.
- **Agent capabilities in the Directory UI** — the agent reports its enabled
capabilities in its `discovery` frame; the SSO stores them and the host's
Metrics tab renders them as green/gray badges, so an operator can see at a
glance what each agent is allowed to do.
- **`GET /api/agent/join-keys/:id/agents`** — which hosts enrolled through a
given join key. Matches on the trace `Agent.enroll` already leaves in
`description` ("Self-enrolled with join key `<prefix>`") rather than a stored
relation.
- **Join key management in the Install Agent modal** — a table (label, prefix,
created date, hosts joined, status) alongside the existing mint/select
dropdown, with **Revoke** and **Delete** actions and a click-through to see
which hosts joined via a given key. Previously these were API-only. Revoke
and Delete confirm inline within the row ("Revoke? Yes/No") rather than a
blocking native `confirm()` (freezes the whole tab) or the shared
`app.messages.confirm()` banner (a single `.actionMessage` shared by the
whole card, so a second click before the first resolves leaves a dangling
`$('body').one('click', ...)` handler from the first call and desyncs which
row the banner is actually confirming for).
# v1.30.2
### Fixed
- **Outbound mail (test email, invites, password resets, OTP-by-email, notifications) could be rejected by the SMTP relay with `554 5.7.1 ... Sender is not same as SMTP authenticate username`.** Many authenticated relays require the `From` address to match the authenticated account or they refuse the send outright. `models/email.js` fell back to a hardcoded `noreply@theta42.com` when `smtp.from` wasn't set, which no relay ever authorized this account to send as. It now falls back to `smtp.user` first — the address the account can actually prove it owns — before the hardcoded placeholder.
- **Catalog page card titles read icon-then-name.** Swapped to name-then-icon so the resource name leads.
### Docs
- `docs/configuration.md` didn't mention that OpenBao + the live Configuration UI sit above the four file/env config layers and win the merge — added.
- `docs/plugins.md` listed 3 of 4 discovery plugin types (missing `docker`) and didn't mention the `messaging` plugin category (`twilio`, `webhook`) at all — added both.
- `docs/vault.md` had no navigation (no frontmatter, no back-link, unreachable from the docs index) and described OpenBao as running in dev mode with API access via the root token — both wrong for a real deployment. Fixed navigation and corrected to describe the actual production setup (unsealed OpenBao, server-side scoped-token injection, personal API tokens for programmatic access).
- `docs/discovery.md` was unreachable from the docs index and missing its back-link — both fixed.
- `README.md`'s required-groups list was missing `app_sso_directory_admin` (gates Directory/Plugins/Agent admin).
# v1.30.1
### Fixed
- **Test Email always failed with `Email.send is not a function`.** `models/email.js` exports `{Mail}`; the handler required the module and called `.send` on it directly. Every other caller destructures it. The button could never have worked.
- **Test SMS failed with `Unexpected token '<', "<!DOCTYPE "...`.** It POSTed to `https://api.voip.ms/v1.0/sms/send` with Basic auth — an endpoint that does not exist. VoIP.ms's REST API is a GET against `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and `method=sendSMS`, so the fabricated URL returned an HTML page and `response.json()` threw. It could never have sent anything.
- **All SMS delivery was broken, not just the test button.** `models/sms.js` called `PluginInstance.find({…})`, but @simpleworkjs/orm has no `find` — the query method is `list({where})`. It threw "is not a function" on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too.
- Both test endpoints now send through the **same senders every real message uses** (`Mail.send`, `SMS.send`). A test that reimplements delivery proves nothing about whether real delivery works — which is exactly how two broken paths went unnoticed.
- The SMS credential check no longer demands `conf.voipms` when a messaging plugin is loaded; the plugin supplies its own credentials, and requiring both blocked a working setup from testing itself.
- Both endpoints report a failure as a `400` with the underlying reason (`VoIP.ms error: invalid_credentials`, `connect ECONNREFUSED …:587`) instead of an opaque `500`. A misconfiguration is the operator's to fix and the UI should be able to show it.
- test: a guard suite that fails the build on any call to a non-existent ORM static (`find`/`findOne`/`findAll`/`where`), on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
### Added
- **Install Agent offers the join-key flow.** The modal now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself. Pre-registering a specific host moved to a second tab. v1.30.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow.
# v1.30.0
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
+2
View File
@@ -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"]
+2 -1
View File
@@ -200,7 +200,8 @@ If you are pointing the app at your own existing LDAP server, see
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
schema. The bundled Docker image and `install.sh` set all of that up for you.
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
OAuth clients only), `app_sso_invite` (invitation management) — see
OAuth clients only), `app_sso_invite` (invitation management),
`app_sso_directory_admin` (Directory/Plugins/Agent admin) — see
DEPLOYMENT.md for the full setup.
## Development
+93
View File
@@ -0,0 +1,93 @@
# End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
#
# Spins up OpenLDAP + Redis, a real SSO server (bin/www, so the WSS relay is
# live), and a client that simulates the agent: it enrolls one, connects over
# WSS, sends a real LDAP bind as raw bytes, and verifies the SSO relays it into
# OpenLDAP and pipes the response back.
#
# docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit
# # exit code 0 = tunnel works; the client prints E2E PASS.
services:
ldap:
build:
context: .
dockerfile: Dockerfile.openldap
environment:
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
- ORG_NAME=Test SSO
command: ["sleep", "infinity"]
healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
volumes:
- ldap-data:/var/lib/ldap
- ldap-certs:/etc/openldap/certs
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
sso:
build:
context: .
dockerfile: Dockerfile.test-runner
command: ["node", "bin/www"]
environment:
- NODE_ENV=test
- NODE_PORT=3001
# Test OpenBao (theta-test-bao) — sso-broker token so the SSO can sign
# high-risk agent commands and read node-scoped secrets.
- VAULT_ADDR=http://theta-test-bao:8200
- VAULT_TOKEN=${VAULT_TOKEN:-}
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
- app_redis__redisConf__url=redis://redis:6379
- REDIS_URL=redis://redis:6379
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
- app_name=Test SSO
depends_on:
ldap:
condition: service_healthy
redis:
condition: service_healthy
client:
build:
context: .
dockerfile: Dockerfile.test-runner
command: ["sh", "-c", "seed-test-user && node test/tunnel_e2e.js"]
environment:
- NODE_ENV=test
- SSO_URL=http://sso:3001
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
- app_redis__redisConf__url=redis://redis:6379
- REDIS_URL=redis://redis:6379
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
- app_name=Test SSO
depends_on:
sso:
condition: service_started
ldap:
condition: service_healthy
redis:
condition: service_healthy
volumes:
ldap-data:
ldap-certs:
+202 -3
View File
@@ -6,7 +6,12 @@ nav_order: 5
# Theta Agent & Endpoint Management
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go for Linux hosts across your home lab, infrastructure, or data center. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2)
endpoint management daemon written in Go for Linux hosts across your home lab,
infrastructure, or data center. It connects outbound via a long-lived WebSocket
connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`),
enabling real-time host telemetry, automated host discovery, and local-first
administrative management.
---
@@ -44,10 +49,35 @@ host does not yield a credential that works anywhere else.
| `POST /api/agent/join-keys` | Mint one — returned **once** |
| `POST /api/agent/join-keys/: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 `<prefix>`") rather
than a stored foreign key, since a join key is exchanged for a per-agent
token immediately and from then on the agent's own identity is what matters.
### Pre-registering a host
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/<this-agent's-id>/`. The SSO enforces this server-side
(`POST /api/v1/agent/secrets`); a request for any other node's path is
rejected:
```
$ curl -sk https://sso.example.com/api/v1/agent/secrets \
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
-d '{"paths":["secret/data/nodes/some-other-node-id/db"]}'
{"status":"error","message":"path outside node scope: secret/data/nodes/some-other-node-id/db"}
```
A compromised agent can therefore never reach another host's secrets, or
anything outside `secret/data/nodes/*`.
### Walkthrough: a 3rd-party app reads a secret the agent rendered
This walks through the whole path end to end, on a stack freshly brought up
from theta-suite's own `docs/fixtures.md` demo data — the same steps work on
any theta-suite install.
**1. Enroll the host.** Directory → Install Agent → mint a join key, run the
install command on the target host as root.
<a href="images/agent-install-join-key.png" target="_blank"><img src="images/agent-install-join-key.png" alt="Install Theta Agent modal with a freshly minted join key and install command" width="80%"></a>
On first connect the agent exchanges the join key for its own token + the
SSO's public key and writes both back into `/etc/theta42/agent.yml`. Note the
agent's id from `GET /api/agent/nodes` (or the Directory URL) — you need it for
the next step.
**2. Turn on the `secrets` capability and point it at a template.** Add to the
host's `/etc/theta42/agent.yml`:
```yaml
secrets:
- template: /etc/theta/templates/db.env.tpl
target: /etc/theta/rendered/db.env
reload: "" # optional: e.g. "systemctl reload myapp"
capabilities:
secrets: true
```
And the template itself, `/etc/theta/templates/db.env.tpl` — placeholders are
`{{ bao "secret/data/nodes/<agent-id>/<name>#<key>" }}`:
```
DB_USER="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#username" }}"
DB_PASS="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#password" }}"
```
Restart the agent to pick up the config change.
**3. Seed the secret.** From `theta-suite/` (theta-env), as the operator:
```
./setup.sh --seed-node-secret f9a30ab0-7d8a-4b77-a4c4-6a6383d084db db \
username=demoapp password=CorrectHorseBattery42
```
This writes to `secret/nodes/<agent-id>/db` in OpenBao (the CLI path — the HTTP
API the agent uses sees it as `secret/data/nodes/<agent-id>/db`, matched by the
node-scope check above). It's idempotent: it skips silently if that path is
already seeded.
**4. Trigger the render.** The Directory UI doesn't have a button for this yet
— push it the same way any admin command goes out, `POST
/api/agent/nodes/:id/command`. It's in the high-risk list, so the SSO signs it
automatically:
```
curl -X POST https://sso.example.com/api/agent/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/command \
-H "auth-token: <admin session token>" -H 'Content-Type: application/json' \
-d '{"command": "render_secrets", "payload": {}}'
```
The agent logs `Received command: render_secrets` / `Rendering secret
templates...` and atomically writes the target file at mode `0600`:
```
$ cat /etc/theta/rendered/db.env
DB_USER="demoapp"
DB_PASS="CorrectHorseBattery42"
```
Back in the Directory, the host's Metrics tab shows **Secrets** lit up green
among the reported capabilities:
<a href="images/agent-capabilities-metrics.png" target="_blank"><img src="images/agent-capabilities-metrics.png" alt="Directory Metrics tab showing live telemetry and the agent's reported capability badges, with Telemetry and Secrets lit green" width="80%"></a>
**5. Read it from a bash app on the same host.** The rendered file is just an
env file — no agent involvement needed to consume it:
```sh
#!/bin/sh
. /etc/theta/rendered/db.env
echo "DB_USER=$DB_USER"
echo "DB_PASS=$DB_PASS"
```
**6. Read it from a Node app on the same host:**
```js
const fs = require('fs');
const env = fs.readFileSync('/etc/theta/rendered/db.env', 'utf8');
const db = {};
for (const line of env.split('\n')) {
const m = /^(\w+)="(.*)"$/.exec(line.trim());
if (m) db[m[1]] = m[2];
}
console.log('DB_USER=' + db.DB_USER);
console.log('DB_PASS=' + db.DB_PASS);
```
Both print the same values the template resolved — `demoapp` /
`CorrectHorseBattery42` in this walkthrough. `theta-agent/demo/` in the
theta-agent repo has these two scripts ready to run.
### Alternative: calling the API directly
Rendering to a file is the normal path — it works for any app regardless of
language, and the secret never touches an HTTP client the app itself controls.
But an app can also fetch its node's secrets directly, bypassing the template
engine entirely (useful for debugging, or a process that wants to hold the
value only in memory). This uses the **agent's own bearer token**, not an admin
token — the same node-scope enforcement applies:
```sh
curl -sk https://sso.example.com/api/v1/agent/secrets \
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
-d '{"paths":["secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db"]}'
```
```js
const token = process.env.THETA_AGENT_TOKEN; // from /etc/theta42/agent.yml
fetch('https://sso.example.com/api/v1/agent/secrets', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: ['secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db'] })
}).then(r => r.json()).then(d => console.log(d.secrets));
```
Both return:
```json
{
"status": "ok",
"secrets": {
"secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db": {
"username": "demoapp",
"password": "CorrectHorseBattery42"
}
}
}
```
---
## Installation & Deployment
### 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).
+15 -1
View File
@@ -16,13 +16,27 @@ deep-merges, in order (later wins):
`localhost`, `SSO Manager`).
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
4. **`app_*` environment variables** — the highest-precedence layer.
4. **`app_*` environment variables** — the highest-precedence layer among these
four.
Any env var whose name starts with `app_` overrides the merged config. The rest
of the name splits on **double-underscore** (`__`) into a nested path. Values are
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
raw strings otherwise.
### A fifth, higher-precedence layer: OpenBao + the Configuration UI
In a theta-suite deployment, `@simpleworkjs/bao-conf`'s `init()` deep-merges
`secret/sso-manager/conf` (from OpenBao) over the four layers above at boot —
this is the layer `setup.sh`/theta-suite actually manages, and it wins over
everything else here. On top of that, the admin **Configuration** page in the
UI writes straight to `secret/sso-manager/conf` (via `routes/api_conf.js`)
and applies the change to the live `conf` object immediately
(`applyToLiveConf`) — no restart, and it bypasses `conf/secrets.js` entirely.
If a value isn't behaving the way `conf/secrets.js` says it should, check the
Configuration UI / OpenBao before assuming a file edit didn't take — it's
almost certainly OpenBao (or a live UI edit) winning the merge.
## Examples
| Env var | Sets | Type |
+2
View File
@@ -6,6 +6,8 @@ nav_order: 6
# Discovery & Inventory
[← Back to Home](index.html)
The Directory holds two different kinds of thing, and the distinction matters
for every consumer of the directory:
Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 320 KiB

+3
View File
@@ -60,7 +60,10 @@ backend, that's the niche.
run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
- **[Discovery](discovery.html)** — the catalog-vs-discovered distinction, how scanned assets are matched/merged into existing resources, and how a discovery gets promoted into the catalog (and becomes reachable through the jump host).
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
- **[Vault secrets](vault.html)** — an OpenBao-backed key-value store built into the UI, for stashing passwords/API keys/credentials with encryption and access control.
- **[API tokens](concepts-api-tokens.html)** — self-service personal access tokens for calling the management API from scripts/CI without a browser session.
## Get it
+15 -1
View File
@@ -17,11 +17,25 @@ needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
filename basename (without `.js`) is the `type`; the parent directory is the
`category`. The built-ins ship under `plugins/discovery/`:
`category`. Two built-in categories ship today:
**`discovery`** — scheduled scans that sync external assets into the
directory catalog:
- `proxmox` — Proxmox VE (URL + API token)
- `unifi` — UniFi Network controller (URL + username/password)
- `nmap` — nmap OS + port scan (a target range; no credentials)
- `docker` — Docker daemon discovery (containers as directory resources)
**`messaging`** — on-demand delivery for alerts, 2FA codes, and
notifications:
- `twilio` — Twilio SMS
- `webhook` — universal REST webhook (custom JSON payload to Slack, Teams,
Discord, or any HTTP endpoint)
If no messaging plugin instance is enabled, the system falls back to the
legacy `voipms` integration configured directly in the SSO secrets.
### What the Proxmox plugin produces
+34 -4
View File
@@ -1,5 +1,13 @@
---
layout: default
title: Vault Secrets
description: OpenBao-backed personal, shared, and external-app secret storage built into the SSO Manager UI.
---
# Vault Secrets Management
[← Back to Home](index.html)
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
## Usage
@@ -26,7 +34,14 @@ You can access the Vault UI from the application's top navigation bar.
### OpenBao Integration
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
The secrets are stored in a real, initialized-and-unsealed OpenBao backend
(`setup.sh` handles init/unseal on first run) — not OpenBao's ephemeral dev
mode, which auto-unseals with an in-memory store and loses everything on
restart. The default KV (Key-Value) version 2 engine is mounted at `secret/`.
The built-in UI proxies through `/api/vault/secret/…`, authenticated the same
way as the rest of the app (session cookie or a personal API token) — the
server resolves your OpenBao access itself and injects the right scoped
token; you never see or handle a raw OpenBao token as a UI user.
## Apps tab (admin)
@@ -48,9 +63,24 @@ The **Shared** tab lets you share a secret with another user (or app) without co
## API Access
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
To read your own secrets programmatically, call the `/api/vault` proxy with
a [personal API token](concepts-api-tokens.html) — **not** a raw OpenBao
token. The server authenticates the request, resolves your own scoped
OpenBao access, and injects the real `X-Vault-Token` itself:
```bash
# Example: Read a secret via the API
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
# Example: Read a secret via the API (KV-v2, so the path includes /data/)
curl -H "Authorization: Bearer sso_<id>_<secret>" \
https://<your-sso-host>/api/vault/secret/data/<your-secret-path>
```
An **external app** reading its own config uses the scoped token minted for
it on the **Apps** tab instead of a personal token — see *Apps tab (admin)*
above for how that token is minted and what it's confined to.
Using the OpenBao **root token** directly (bypassing the SSO entirely) is
never the intended path for day-to-day secret access — it's an
operator/maintenance credential (seeding, disaster recovery), kept in
`setup.env` and never passed to a service container. See
[theta-env's Secrets doc](https://theta42.github.io/theta-env/secrets.html)
for the full token/policy model.
+10
View File
@@ -112,6 +112,16 @@ app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
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);
+8 -1
View File
@@ -33,8 +33,15 @@ Mail.send = function(to, subject, message, from){
var transporter = nodemailer.createTransport(transportOpts);
// Most authenticated SMTP relays (and this bit the field: "554 5.7.1
// ...: Sender is not same as SMTP authenticate username") require the
// envelope/header From to equal the authenticated user, or reject the
// send outright. If the operator hasn't set an explicit smtp.from,
// defaulting to the SMTP username is far more likely to actually send
// than a made-up noreply@theta42.com address that no relay authorized
// this account to send as.
var mailOpts = {
from: from || conf.smtp.from || `${conf.name} Accounts <noreply@theta42.com>`,
from: from || conf.smtp.from || conf.smtp.user || `${conf.name} Accounts <noreply@theta42.com>`,
to: to,
subject: subject,
html: message
+18
View File
@@ -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 {
+6 -1
View File
@@ -14,7 +14,12 @@ async function send(to, message) {
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
// @simpleworkjs/orm has no `find` -- the query method is `list({where})`.
// `PluginInstance.find(...)` threw "is not a function" on EVERY call into
// this sender, so SMS delivery never worked at all: not the test button, not
// OTP-by-SMS, not notifications. It failed before it could even fall back to
// the direct VoIP.ms path below.
const instances = await PluginInstance.list({ where: { category: 'messaging', enabled: true } });
if (instances.length > 0) {
const inst = instances[0];
const manifest = registry.getManifest(inst.pluginType);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.30.0",
"version": "1.30.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.30.0",
"version": "1.30.2",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.30.0",
"version": "1.31.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+65 -20
View File
@@ -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 <<EOF > "$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 <<EOF > "$SERVICE_FILE"
@@ -111,8 +158,6 @@ Type=simple
ExecStart=$BIN_PATH
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent
[Install]
+119 -8
View File
@@ -5,6 +5,7 @@ const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys');
const ldapTunnel = require('../utils/ldap_tunnel');
const { Agent, AgentJoinKey } = require('../models/agent');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
@@ -12,7 +13,7 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_adm
// Commands that can change or run code on the host. They are signed with the
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
// its agent.yml.
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply'];
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
// This is a plain Express Router exported directly so app.js can
@@ -183,6 +184,23 @@ router.get('/join-keys', async (req, res, next) => {
} catch (err) { next(err); }
});
// Which hosts enrolled through a given key. There is no stored relation --
// join keys are exchanged for a per-agent token immediately, and from then on
// the agent's own identity is what matters -- so this matches on the
// human-readable trace `Agent.enroll` already leaves in `description`
// ("Self-enrolled with join key <prefix>") rather than a foreign key. Prefixes
// are 12 random hex chars, so a collision is not a practical concern.
router.get('/join-keys/:id/agents', async (req, res, next) => {
try {
const key = await AgentJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
const marker = `join key ${key.keyPrefix}`;
const agents = await Agent.list();
const matches = agents.filter(a => (a.description || '').includes(marker));
res.json({ status: 'ok', agents: matches.map(a => a.toPublic(agentManager.liveState(a.id))) });
} catch (err) { next(err); }
});
router.post('/join-keys', async (req, res, next) => {
try {
const { label, expiresInDays } = req.body || {};
@@ -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
+115
View File
@@ -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/<agent-id>/db"] }
// -> { status: "ok", secrets: { "secret/data/nodes/<agent-id>/db": { key: value } } }
//
// The agent may only read under its own node prefix (secret/data/nodes/<id>/*),
// so a compromised agent cannot reach other nodes' or shared secrets. The SSO
// fetches with its own OpenBao access (SSO_VAULT_TOKEN); the agent never holds a
// Vault token.
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/<agent.id>/
// 2. Bound Resource scope: secret/data/resources/<resource.slug>/
// 3. Shared Resource Grants: secret/data/resources/<grantee-slug>/
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;
+40 -31
View File
@@ -136,15 +136,25 @@ router.post('/test-email', async (req, res, next) => {
return res.status(400).json({ error: 'Recipient email address is required' });
}
// Use the email model to send the test message
const Email = require('../models/email');
// Send through the SAME sender every other feature uses (password reset,
// invites, OTP-by-email, notifications). A "test" that reimplements
// delivery proves nothing about whether real mail works.
//
// models/email.js exports `{Mail}`; requiring the module and calling
// `.send` on it directly -- as this did -- always threw
// "Email.send is not a function", so the button could never succeed.
const { Mail } = require('../models/email');
const testSubject = subject || 'SSO Manager Test Email';
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
await Email.send(to, testSubject, testBody);
await Mail.send(to, testSubject, testBody);
res.json({ success: true, message: `Test email sent to ${to}` });
} catch(err) {
next(err);
// A failed test is almost always a misconfiguration (wrong host, refused
// connection, bad credentials) -- the operator's to fix, and something the
// UI should be able to show them. Surfacing it as a 400 with the reason
// beats an opaque 500 carrying a raw stack-trace name.
return res.status(400).json({ error: err.message || 'Failed to send test email' });
}
});
@@ -156,38 +166,37 @@ router.post('/test-sms', async (req, res, next) => {
return res.status(400).json({ error: 'Recipient phone number is required' });
}
// Send through models/sms.js -- the same path every real SMS takes. It
// prefers a configured messaging plugin and falls back to VoIP.ms, and it
// normalizes the destination to E.164 digits.
//
// This used to POST to `https://api.voip.ms/v1.0/sms/send` with Basic auth.
// No such endpoint exists: VoIP.ms's REST API is a GET against
// `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and
// `method=sendSMS`. The fabricated URL returned an HTML page, so
// `response.json()` threw `Unexpected token '<', "<!DOCTYPE "...` and the
// button reported that as the failure. It could never have sent anything.
const { SMS } = require('../models/sms');
const { PluginInstance } = require('../models/plugin_instance');
// A messaging plugin, when present, supplies its own credentials -- so
// requiring conf.voipms unconditionally would block a perfectly working
// setup from testing itself.
const messagingPlugins = await PluginInstance.list({ where: { category: 'messaging', enabled: true } }).catch(() => []);
const voipmsConf = conf.voipms || {};
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
if (!messagingPlugins.length && (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did)) {
return res.status(400).json({ error: 'No messaging plugin is loaded and VoIP.ms credentials are not configured. Set username, DID and password in the SMS tab, or load a messaging plugin.' });
}
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`;
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your SMS configuration is working correctly.`;
// VoIP.ms SMS API endpoint
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
method: 'POST',
headers: {
'Authorization': `Basic ${authHeader}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
did: voipmsConf.did,
to: to,
message: testMessage
})
});
const result = await response.json();
if (result.status === 'success') {
res.json({ success: true, message: `Test SMS sent to ${to}` });
} else {
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
}
await SMS.send(to, testMessage);
res.json({ success: true, message: `Test SMS sent to ${to}` });
} catch(err) {
next(err);
// The sender rejects with a useful reason (`VoIP.ms error: <status>`, or a
// plugin's own error). Surface it as a 400 the UI can display rather than
// an opaque 500 -- a misconfiguration is the operator's to fix, not a bug.
return res.status(400).json({ error: err.message || 'Failed to send test SMS' });
}
});
+137
View File
@@ -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/<slug>/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;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
// LDAP-over-HTTPS API (DESIGN.md §3).
//
// The whole point of this API is that a client stops speaking LDAP and instead
// does an HTTPS call to the SSO, where the directory is reachable. That kills
// the hostname / cross-network / LDAPS-cert-chain pain: no LDAP protocol, no
// cert to trust, no firewall rule.
//
// POST /api/v1/ldap/bind {username, password} -> 200 {dn, uid} | 401
// POST /api/v1/ldap/search {base_dn, scope, filter, attributes} -> 200 {entries}
//
// Caller auth: a Bearer token in the Authorization header. Two kinds of caller
// are accepted, reusing existing credentials:
// - an agent token (the same one the agent presents on its WSS channel) — the
// caller is a node acting for SSSD;
// - a self-service API token (PAT, `sso_...`) — the caller is a user/app.
// The API authorizes the *caller*; OpenLDAP enforces the actual directory ACLs.
//
// Security note on /search: it runs under the directory admin bind (withClient),
// so it can read the whole tree. It is therefore restricted to agent callers
// (the SSSD user/group-resolution use case) and must eventually move to a
// scoped read-only service account rather than the admin bind. See DESIGN.md §9.
const express = require('express');
const { createLdapClient } = require('@simpleworkjs/ldap');
const conf = require('@simpleworkjs/conf').ldap;
const { Agent } = require('../models/agent');
const { ApiToken } = require('../models/api_token');
const router = express.Router();
const ldap = createLdapClient(conf);
// Resolve a Bearer token to a caller identity, or null. Tries the agent token
// first, then a PAT. Every failure collapses to null so a probing caller learns
// nothing about which credential was wrong.
async function authenticateCaller(req) {
const auth = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(auth);
if (!m) return null;
const token = String(m[1]).trim();
if (!token) return null;
try {
const agent = await Agent.authenticate(token);
if (agent) return { kind: 'agent', id: agent.id, name: agent.name };
} catch (_) {}
try {
const pat = await ApiToken.authenticate(token);
if (pat) return { kind: 'user', id: pat.created_by };
} catch (_) {}
return null;
}
// POST /bind — authenticate a username/password against the directory.
router.post('/bind', async (req, res, next) => {
try {
const caller = await authenticateCaller(req);
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ status: 'error', message: 'username and password are required' });
}
// Resolve the username to a DN, then simple-bind as that DN. A missing user
// and a wrong password both surface as 401 (no user-existence oracle).
const user = await ldap.getUser(String(username));
if (!user) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
const ok = await ldap.checkPassword(user.dn, String(password));
if (!ok) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
return res.json({ status: 'ok', dn: user.dn, uid: user.uid });
} catch (err) { next(err); }
});
// POST /search — run a directory search. Agent callers only (see header note).
router.post('/search', async (req, res, next) => {
try {
const caller = await authenticateCaller(req);
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
if (caller.kind !== 'agent') {
return res.status(403).json({ status: 'error', message: 'search is restricted to agents' });
}
const { base_dn, scope, filter, attributes } = req.body || {};
if (!filter) return res.status(400).json({ status: 'error', message: 'filter is required' });
const entries = await ldap.withClient(async (client) => {
const { searchEntries } = await client.search(base_dn || conf.userBase, {
scope: scope || 'sub',
filter: String(filter),
attributes: Array.isArray(attributes) && attributes.length ? attributes : undefined,
});
return searchEntries;
});
return res.json({ status: 'ok', entries });
} catch (err) { next(err); }
});
module.exports = router;
+72
View File
@@ -0,0 +1,72 @@
'use strict';
// Agent-facing ops (DESIGN.md §5): node-scoped secrets. OpenBao is not present
// in the test env, so @simpleworkjs/bao-conf is mocked.
jest.mock('@simpleworkjs/bao-conf', () => ({
request: jest.fn(async (method, path) => {
if (path.startsWith('secret/data/nodes/')) {
return {
ok: true,
status: 200,
json: async () => ({ data: { data: { username: 'alice', password: 's3cret' } } }),
};
}
return { ok: false, status: 404, json: async () => ({}) };
}),
}));
const { request, app } = require('./setup');
const { Agent } = require('../models/agent');
async function enrollAgent() {
const { agent, token } = await Agent.enroll({
name: `ops-test-${Date.now().toString(36)}`,
description: 'api_agent_ops test',
enrolledBy: 'test'
});
return { agent, token };
}
describe('Agent ops — POST /api/v1/agent/secrets', () => {
test('an agent can fetch its own node-scoped secrets', async () => {
const { agent, token } = await enrollAgent();
const path = `secret/data/nodes/${agent.id}/db`;
const res = await request(app)
.post('/api/v1/agent/secrets')
.set('Authorization', `Bearer ${token}`)
.send({ paths: [path] });
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.body.secrets[path]).toEqual({ username: 'alice', password: 's3cret' });
});
test('a path outside the node scope is rejected', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/agent/secrets')
.set('Authorization', `Bearer ${token}`)
.send({ paths: ['secret/data/nodes/other-node/db'] });
expect(res.status).toBe(403);
});
test('no bearer token returns 401', async () => {
const res = await request(app)
.post('/api/v1/agent/secrets')
.send({ paths: ['secret/data/nodes/x/db'] });
expect(res.status).toBe(401);
});
test('missing paths returns 400', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/agent/secrets')
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(400);
});
});
+126
View File
@@ -0,0 +1,126 @@
'use strict';
// LDAP-over-HTTPS API (DESIGN.md §3). Exercises caller auth (agent token vs
// PAT), the bind flow against the real test OpenLDAP, and the agent-only search
// restriction.
const { TEST_CREDS, request, app } = require('./setup');
const { Agent } = require('../models/agent');
const { ApiToken } = require('../models/api_token');
async function enrollAgent() {
const { agent, token } = await Agent.enroll({
name: `ldap-test-${Date.now().toString(36)}`,
description: 'api_ldap test agent',
enrolledBy: 'test'
});
return { agent, token };
}
async function makePat() {
const token = await ApiToken.add({
name: 'ldap-test-pat',
description: 'api_ldap test',
created_by: 'test'
});
return token._raw_token;
}
describe('LDAP-over-HTTPS — POST /api/v1/ldap/bind', () => {
test('valid credentials return the bound DN', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.body.uid).toBe(TEST_CREDS.uid);
expect(res.body.dn).toContain(TEST_CREDS.uid);
});
test('wrong password returns 401', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: TEST_CREDS.uid, password: 'wrong-password' });
expect(res.status).toBe(401);
});
test('unknown user returns 401 (no existence oracle)', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: 'no_such_user_xyz', password: 'whatever' });
expect(res.status).toBe(401);
});
test('a PAT caller can bind', async () => {
const pat = await makePat();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${pat}`)
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
expect(res.status).toBe(200);
});
test('no bearer token returns 401', async () => {
const res = await request(app)
.post('/api/v1/ldap/bind')
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
expect(res.status).toBe(401);
});
test('missing username/password returns 400', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: TEST_CREDS.uid });
expect(res.status).toBe(400);
});
});
describe('LDAP-over-HTTPS — POST /api/v1/ldap/search', () => {
test('an agent can search the user tree', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/search')
.set('Authorization', `Bearer ${token}`)
.send({ filter: `(uid=${TEST_CREDS.uid})`, attributes: ['uid', 'cn'] });
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(Array.isArray(res.body.entries)).toBe(true);
expect(res.body.entries.length).toBeGreaterThan(0);
expect(res.body.entries[0].uid).toBe(TEST_CREDS.uid);
});
test('a PAT caller is denied search (agent-only)', async () => {
const pat = await makePat();
const res = await request(app)
.post('/api/v1/ldap/search')
.set('Authorization', `Bearer ${pat}`)
.send({ filter: `(uid=${TEST_CREDS.uid})` });
expect(res.status).toBe(403);
});
test('missing filter returns 400', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/search')
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(400);
});
});
+118
View File
@@ -0,0 +1,118 @@
'use strict';
const fs = require('fs');
const path = require('path');
// @simpleworkjs/orm models expose `list`/`get`/`count`/`create` -- there is no
// `find`, `findOne`, `findAll` or `where`. Calling one is not a syntax error and
// nothing catches it until the line actually runs, so it can sit in a rarely
// exercised path indefinitely.
//
// It did: `models/sms.js` called `PluginInstance.find({...})`, which threw
// "is not a function" on EVERY SMS send -- the test button, OTP-by-SMS and
// notifications alike -- before it could even reach the VoIP.ms fallback. SMS
// delivery had simply never worked.
const ORM_MODELS = [
'Resource', 'ResourceEdge', 'ResourceGroup', 'AccessRequest', 'Webhook',
'PluginInstance', 'SharedSecret', 'SharedSecretGrant', 'VaultAppToken',
'Agent', 'AgentJoinKey',
];
const MISSING_STATICS = ['find', 'findOne', 'findAll', 'findAndCountAll', 'where'];
const ROOT = path.join(__dirname, '..');
const SCAN_DIRS = ['models', 'routes', 'services', 'utils', 'plugins', 'controller', 'middleware'];
function walk(dir, out = []) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
walk(full, out);
} else if (entry.name.endsWith('.js')) {
out.push(full);
}
}
return out;
}
// Strip comments so a line *describing* the bug (like the one in models/sms.js)
// isn't reported as the bug.
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
}
test('no source file calls an ORM static that does not exist', () => {
const pattern = new RegExp(
`\\b(${ORM_MODELS.join('|')})\\s*\\.\\s*(${MISSING_STATICS.join('|')})\\s*\\(`,
'g'
);
const offenders = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
const src = stripComments(fs.readFileSync(file, 'utf8'));
src.split('\n').forEach((line, i) => {
const m = line.match(pattern);
if (m) offenders.push(`${path.relative(ROOT, file)}:${i + 1}${m.join(', ')}`);
});
}
}
expect(offenders).toEqual([]);
});
// models/email.js exports `{Mail}`, not a bare sender. Requiring the module and
// calling `.send` on it -- as routes/api_conf.js's test-email did -- always
// threw "Email.send is not a function", so the Test Email button could never
// have worked.
test('the email module exports Mail.send and callers destructure it', () => {
const mod = require('../models/email');
expect(typeof mod.Mail).toBe('object');
expect(typeof mod.Mail.send).toBe('function');
// The bare module has no send() -- this is exactly the mistake to catch.
expect(mod.send).toBeUndefined();
const offenders = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
const src = stripComments(fs.readFileSync(file, 'utf8'));
// `X = require('...email')` followed by `X.send(` where X was not
// destructured.
const assigned = [...src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*require\([^)]*models\/email[^)]*\)/g)]
.map(m => m[1]);
for (const name of assigned) {
if (new RegExp(`\\b${name}\\s*\\.\\s*send\\s*\\(`).test(src)) {
offenders.push(`${path.relative(ROOT, file)}${name}.send(), but the module exports {Mail}`);
}
}
}
}
expect(offenders).toEqual([]);
});
// The VoIP.ms REST API is a GET against voip.ms/api/v1/rest.php with
// api_username/api_password and method=sendSMS. `api.voip.ms/v1.0/sms/send`
// (which test-sms used to POST to with Basic auth) does not exist -- it
// returned an HTML page, so response.json() threw
// `Unexpected token '<', "<!DOCTYPE "...` and the button reported that.
test('nothing targets the non-existent api.voip.ms host', () => {
const offenders = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
// Comments stripped: the note in routes/api_conf.js explaining this
// very bug names the bad host, and describing a mistake is not
// making it.
const src = stripComments(fs.readFileSync(file, 'utf8'));
src.split('\n').forEach((line, i) => {
if (line.includes('api.voip.ms')) {
offenders.push(`${path.relative(ROOT, file)}:${i + 1}`);
}
});
}
}
expect(offenders).toEqual([]);
});
+26
View File
@@ -0,0 +1,26 @@
'use strict';
// Authenticate an agent from a Bearer token (the same token the agent presents
// on its WSS channel). Used by agent-facing REST endpoints (secrets, IAM) that
// are NOT admin-gated — the caller is the agent itself, not an admin session.
const { Agent } = require('../models/agent');
// Resolve a Bearer token to its (non-revoked) Agent, or null. Every failure
// collapses to null so a probing caller learns nothing about which part was
// wrong.
async function authenticateAgent(req) {
const auth = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(auth);
if (!m) return null;
const token = String(m[1]).trim();
if (!token) return null;
try {
const agent = await Agent.authenticate(token);
return agent || null;
} catch (_) {
return null;
}
}
module.exports = { authenticateAgent };
+57 -9
View File
@@ -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);
+84
View File
@@ -0,0 +1,84 @@
'use strict';
// LDAP byte-pump relay (DESIGN.md §4). The agent forwards raw LDAP bytes from a
// local socket (SSSD) over the WSS channel as `ldap_tunnel` messages; this
// module relays them into the SSO's real OpenLDAP and pipes the responses back.
// The SSO does not parse LDAP either — it is a transparent socket relay.
const net = require('net');
const conf = require('@simpleworkjs/conf').ldap;
// Parse host:port from an ldap:// or ldaps:// URL. The relay connects plaintext
// to the SSO's own slapd (which is plaintext on localhost); an ldaps:// URL
// would need TLS termination here and is not supported yet (DESIGN.md §9.5).
function ldapTarget() {
const url = conf.url || 'ldap://localhost:389';
const m = /^ldaps?:\/\/([^:/]+)(?::(\d+))?/.exec(url);
const host = m ? m[1] : 'localhost';
const port = m && m[2] ? Number(m[2]) : 389;
return { host, port };
}
// Per-agent relay state: agentId -> Map(conn_id -> LDAP socket).
const relays = new Map();
function relayFor(agentId) {
if (!relays.has(agentId)) relays.set(agentId, new Map());
return relays.get(agentId);
}
// Handle one ldap_tunnel message from an agent.
function handleTunnel(agentId, ws, payload) {
const connId = payload.conn_id;
if (!connId) return;
const conns = relayFor(agentId);
// End of connection: close the relay socket.
if (payload.close) {
const sock = conns.get(connId);
if (sock) { sock.destroy(); conns.delete(connId); }
return;
}
const data = Buffer.from(payload.data || '', 'base64');
if (data.length === 0) return;
let sock = conns.get(connId);
if (!sock) {
const { host, port } = ldapTarget();
sock = net.connect(port, host);
conns.set(connId, sock);
// Relay OpenLDAP's responses back to the agent.
sock.on('data', (chunk) => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({
type: 'ldap_tunnel',
payload: { conn_id: connId, data: chunk.toString('base64') }
}));
}
});
sock.on('close', () => {
conns.delete(connId);
if (ws.readyState === 1) {
ws.send(JSON.stringify({
type: 'ldap_tunnel',
payload: { conn_id: connId, close: true }
}));
}
});
sock.on('error', () => { sock.destroy(); });
}
sock.write(data);
}
// Drop every relay socket for an agent (on WSS disconnect).
function cleanup(agentId) {
const conns = relays.get(agentId);
if (conns) {
for (const sock of conns.values()) sock.destroy();
relays.delete(agentId);
}
}
module.exports = { handleTunnel, cleanup };
+544 -7
View File
@@ -48,6 +48,10 @@
<button type="button" class="btn btn-outline-secondary" onclick="expandAllTree()" title="Expand all"><i class="fa-solid fa-angles-down"></i></button>
<button type="button" class="btn btn-outline-secondary" onclick="collapseAllTree()" title="Collapse all"><i class="fa-solid fa-angles-up"></i></button>
</div>
<div class="form-check form-switch form-check-inline ms-1 me-1">
<input class="form-check-input" type="checkbox" id="toggle-plumbing" onchange="renderTable()">
<label class="form-check-label small text-muted" for="toggle-plumbing" title="Show containers, oauth clients, and sidecars">Plumbing</label>
</div>
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="name">Name (A-Z)</option>
@@ -90,12 +94,12 @@
{{{indentHtml}}}
{{{caretHtml}}}
{{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
<a href="#" class="text-reset text-decoration-none ms-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
<a href="#" class="text-reset text-decoration-none me-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong>
</a>
<span class="badge bg-secondary me-1">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger me-1">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info me-1">Dev</span>{{/metadata.isProduction}}
</td>
<td>
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
@@ -507,6 +511,95 @@
</div>
`;
var secretsTabHtml = `
<div class="mb-3" id="secrets-tab-container">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="mb-0"><i class="fa-solid fa-vault text-warning me-2"></i>Resource Secrets (OpenBao KV Engine)</h6>
<small class="text-muted">Encrypted key-value secrets stored in OpenBao under <code>secret/data/resources/&lt;slug&gt;/conf</code></small>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="refreshResourceSecrets()"><i class="fa-solid fa-sync me-1"></i> Refresh</button>
</div>
<div class="secrets-action-msg mb-2" style="display:none"></div>
<div class="table-responsive shadow-sm rounded border mb-3">
<table class="table table-hover align-middle mb-0" id="secrets-table">
<thead class="table-dark">
<tr>
<th style="width: 35%;">Secret Key</th>
<th>Status / Security</th>
<th style="width: 240px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody id="secrets-table-body">
<tr><td colspan="3" class="text-center text-muted py-3">Loading secrets...</td></tr>
</tbody>
</table>
</div>
<!-- Add / Generate Secret Card -->
<div class="card bg-light border-0 shadow-sm p-3 mb-3">
<h6 class="card-title text-dark mb-2"><i class="fa-solid fa-plus-circle text-primary me-1"></i> Add or Generate Secret Key</h6>
<div class="row g-2 align-items-center mb-2">
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Secret Key Name (e.g. <code>DB_PASSWORD</code>)</label>
<input type="text" class="form-control form-control-sm font-monospace" id="new-secret-key" placeholder="DB_PASSWORD" onkeyup="validateSecretKeyInput(this)">
<div class="invalid-feedback small">Only letters, numbers, and underscores allowed (e.g. DB_PASSWORD).</div>
</div>
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Secret Value</label>
<input type="text" class="form-control form-control-sm font-monospace" id="new-secret-val" placeholder="Enter value or click Generate">
</div>
<div class="col-md-3 pt-3">
<button class="btn btn-sm btn-primary w-100" id="btn-add-secret" onclick="addSecretRow()"><i class="fa-solid fa-save me-1"></i> Save Secret</button>
</div>
</div>
<div class="row g-2 align-items-center mt-1">
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Generator Length</label>
<select class="form-select form-select-sm" id="gen-secret-length">
<option value="8">8 characters</option>
<option value="12">12 characters</option>
<option value="16">16 characters</option>
<option value="24">24 characters</option>
<option value="32" selected>32 characters (Default)</option>
<option value="48">48 characters</option>
<option value="64">64 characters</option>
<option value="128">128 characters</option>
</select>
</div>
<div class="col-md-8 text-end pt-3">
<button class="btn btn-sm btn-outline-success" onclick="generateSecretValue()"><i class="fa-solid fa-bolt me-1"></i> Generate Secret into Field</button>
</div>
</div>
<div class="alert alert-warning border-0 shadow-sm p-2 small mt-2 mb-0" id="gen-secret-notice" style="display:none">
<i class="fa-solid fa-shield-halved text-warning me-1"></i> Generated secret is shown in the field above. Click <strong>Save Secret</strong> to store in OpenBao — secret values will not be displayed again once saved.
</div>
</div>
<!-- Inherit Parent Secret Card -->
<div class="card bg-light border-0 shadow-sm p-3" id="inherit-secret-card" style="display:none">
<h6 class="card-title text-dark mb-2"><i class="fa-solid fa-diagram-project text-info me-1"></i> Inherit Secret from Parent Resource</h6>
<div class="row g-2 align-items-center">
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Child Secret Key Name</label>
<input type="text" class="form-control form-control-sm font-monospace" id="inherit-child-key" placeholder="DB_HOST">
</div>
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Parent Resource Secret</label>
<select class="form-select form-select-sm" id="inherit-parent-select"></select>
</div>
<div class="col-md-3 pt-3">
<button class="btn btn-sm btn-outline-info w-100" onclick="inheritParentSecret()"><i class="fa-solid fa-link me-1"></i> Inherit Secret</button>
</div>
</div>
</div>
</div>
`;
// 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 @@
<div class="col-6">IPs: ${esc((d.ip_addresses || []).join(', '))}</div>
<div class="col-6">Location: ${esc(d.location || '')}</div>
</div>
<hr><h6>Capabilities</h6>
<div class="small">${capabilitiesHtml(d.capabilities)}</div>
</div>`;
}
// Render the agent's enabled capabilities (reported in its discovery frame) as
// green/gray badges. service_control is a list, so it renders as its own line.
function capabilitiesHtml(caps) {
caps = caps || {};
const badge = (name, on) => `<span class="badge ${on ? 'bg-success' : 'bg-secondary'} me-1 mb-1">${esc(name)}</span>`;
const bools = [
['Telemetry', caps.telemetry],
['LDAP config', caps.configure_ldap],
['LDAP tunnel', caps.ldap_tunnel],
['Secrets', caps.secrets],
['IAM', caps.iam],
['Reboot', caps.reboot],
['Bash', caps.arbitrary_bash],
];
const sc = Array.isArray(caps.service_control) ? caps.service_control : [];
const scLine = sc.length
? `<div class="mt-1 text-muted">Service control: ${esc(sc.join(', '))}</div>`
: '';
return bools.map(([n, on]) => badge(n, !!on)).join('') + scLine;
}
// Re-fetch agents (every 30s + on socket events) so status dots stay live.
async function refreshAgents() {
try {
@@ -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('<tr><td colspan="3" class="text-center text-muted py-3"><i class="fa-solid fa-spinner fa-spin me-2"></i>Loading secrets from OpenBao...</td></tr>');
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(`<tr><td colspan="3" class="text-center text-danger py-3"><i class="fa-solid fa-triangle-exclamation me-2"></i>Failed to load secrets: ${esc(err.message || 'Unknown error')}</td></tr>`);
}
}
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(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
});
$card.show();
}
function renderSecretsTable() {
const $tbody = $('#secrets-table-body').empty();
if (currentResourceSecretsList.length === 0) {
$tbody.append('<tr><td colspan="3" class="text-center text-muted py-3">No secrets configured for this resource yet.</td></tr>');
return;
}
currentResourceSecretsList.forEach((s, idx) => {
const $row = $(`
<tr class="secret-row" data-key="${esc(s.key)}">
<td><code class="fw-bold fs-6">${esc(s.key)}</code></td>
<td>
${s.isInherited
? `<span class="badge bg-info text-dark shadow-sm"><i class="fa-solid fa-link me-1"></i>Inherited from ${esc(s.parentSlug || 'Parent')}</span> <small class="text-muted ms-1">(${esc(s.parentKey || s.key)})</small>`
: `<span class="badge bg-success shadow-sm"><i class="fa-solid fa-lock me-1"></i>Configured in OpenBao</span> <span class="badge bg-secondary ms-1"><i class="fa-solid fa-eye-slash me-1"></i>Secret Value Hidden</span>`
}
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary" onclick="editSecretKey('${esc(s.key)}')" title="Set / Overwrite Value"><i class="fa-solid fa-pen me-1"></i> Edit Value</button>
<button class="btn btn-sm btn-outline-danger ms-1" onclick="deleteSecretKey('${esc(s.key)}')" title="Delete Secret"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
`);
$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
@@ -1640,6 +1944,21 @@
// public_key must reach the host: without it the agent refuses every
// high-risk command. It was never emitted before, which is why signed
// commands only ever "worked" while verification was being skipped.
// Join-key command. Only a key we just minted can appear here -- the list
// endpoint deliberately never returns key values.
const joinUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
const selectedKeyId = $('#agent-join-key-select').val();
let joinCmd;
if (mintedJoinKey) {
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
} else if (selectedKeyId) {
const k = agentJoinKeys.find(x => x.id === selectedKeyId);
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}…"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
} else {
joinCmd = '# Create a join key above, or select one you already have the value for.';
}
$('#agent-join-command').text(joinCmd);
const pubKey = (pendingEnrollment && pendingEnrollment.publicKey) || '';
const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`
+ (pubKey ? ` --public-key "${pubKey}"` : '');
@@ -1719,14 +2038,89 @@
</div>
</div>
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="agent-mode-join-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-join" type="button" role="tab">
<i class="fa-solid fa-key me-1"></i> Join key <span class="badge bg-success ms-1">easiest</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="agent-mode-pre-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-pre" type="button" role="tab">
<i class="fa-solid fa-id-badge me-1"></i> Pre-register this host
</button>
</li>
</ul>
<div class="tab-content mb-3">
<!-- ── Join key: one credential, host enrolls itself ────────────── -->
<div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
<div class="card border-success">
<div class="card-header py-2 fw-bold small bg-success-subtle">
<i class="fa-solid fa-key me-1"></i> Install with a join key
</div>
<div class="card-body py-3">
<p class="small text-muted mb-3">
Run this on any host and it enrolls itself. The SSO issues that host its own
token and public key on first connect, and the agent writes both into its
<code>agent.yml</code> — nothing to copy back and forth. One key works for as
many hosts as you like; each still gets its own revocable identity.
</p>
<div class="d-flex gap-2 align-items-end mb-3">
<div class="flex-grow-1">
<label class="form-label small fw-bold mb-1">Existing join keys</label>
<select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select>
<div class="form-text small">A key's value is shown only when it is created — mint a new one if you don't have it saved.</div>
</div>
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
<i class="fa-solid fa-plus me-1"></i> New join key
</button>
</div>
<div id="agent-join-key-result" style="display:none"></div>
<label class="form-label small fw-bold mb-1">Run on the target host (as root):</label>
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-2 text-wrap text-break" id="agent-join-command" style="user-select: all;"></pre>
<div class="d-flex justify-content-end">
<button class="btn btn-sm btn-success" id="btn-copy-join" onclick="copyAgentCommand('agent-join-command', 'btn-copy-join')">
<i class="fa-solid fa-copy me-1"></i> Copy install command
</button>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header py-2 fw-bold small">
<i class="fa-solid fa-list-check me-1"></i> Manage join keys
</div>
<div class="card-body py-2">
<table class="table table-sm table-hover mb-0 small" id="agent-join-key-table">
<thead>
<tr>
<th>Label</th>
<th>Prefix</th>
<th>Created</th>
<th>Hosts joined</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="agent-join-key-tbody"></tbody>
</table>
<div id="agent-join-key-hosts" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
<div class="tab-pane fade" id="agent-mode-pre" role="tabpanel">
<div class="card border-primary mb-3" id="agent-enroll-card">
<div class="card-header py-2 fw-bold small bg-primary-subtle">
<i class="fa-solid fa-id-badge me-1"></i> 1. Enroll this host
</div>
<div class="card-body py-3">
<p class="small text-muted mb-3">
The SSO issues the agent's token and records it. Tokens it did not issue are rejected,
so enroll the host first — the install command below is built from the result.
Use this when you want the agent bound to a specific Directory host from the start.
The SSO issues the token here and you copy it onto the machine yourself.
</p>
<div class="row g-2 align-items-end">
<div class="col-md-4">
@@ -1870,6 +2264,8 @@
</div>
</div>
</div>
</div><!-- /pre-register pane -->
</div><!-- /tab-content -->
`;
app.modal.open({
@@ -1878,6 +2274,8 @@
size: 'lg'
});
loadAgentJoinKeys();
// Only hosts can carry an agent -- the API rejects anything else, so don't
// offer it here.
const $sel = $('#agent-enroll-resource').empty();
@@ -1898,6 +2296,145 @@
updateAgentCommands();
}
// Join keys the operator can reuse. Values are never returned by the list
// 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 = []; // 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) {
agentJoinKeysAll = (res && res.joinKeys ? res.joinKeys : []);
agentJoinKeys = agentJoinKeysAll.filter(k => !k.revoked);
const $sel = $('#agent-join-key-select').empty();
if (!agentJoinKeys.length) {
$sel.append('<option value="">No join keys yet — create one</option>');
} else {
$sel.append('<option value="">Select a key…</option>');
agentJoinKeys.forEach(k => {
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused';
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
});
}
const $tbody = $('#agent-join-key-tbody').empty();
$('#agent-join-key-hosts').hide().empty();
if (!agentJoinKeysAll.length) {
$tbody.append('<tr><td colspan="6" class="text-muted">No join keys yet.</td></tr>');
} else {
agentJoinKeysAll.forEach(k => {
const created = k.created_on ? new Date(k.created_on * 1000).toLocaleDateString() : '—';
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : '0 hosts';
const status = k.revoked
? '<span class="badge bg-secondary">Revoked</span>'
: '<span class="badge bg-success">Active</span>';
const revokeBtn = k.revoked ? '' :
`<button class="btn btn-outline-warning btn-sm" title="Revoke -- stops it enrolling new hosts; already-joined hosts are unaffected" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'revoke')"><i class="fa-solid fa-ban"></i></button>`;
const $row = $('<tr>').attr('data-join-key-row', k.id).append(
$('<td>').text(k.label),
$('<td>').append($('<code>').text(k.keyPrefix + '…')),
$('<td>').text(created),
$('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })),
$('<td>').html(status),
$('<td class="text-end agent-join-key-actions">').html(
'<div class="btn-group btn-group-sm">' + revokeBtn +
`<button class="btn btn-outline-danger btn-sm" title="Delete the key record itself; already-joined hosts keep working" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'delete')"><i class="fa-solid fa-trash"></i></button>` +
'</div>'
)
);
$tbody.append($row);
});
}
updateAgentCommands();
});
}
async function viewAgentJoinKeyHosts(id, label) {
const $out = $('#agent-join-key-hosts').show().html('<i class="fa-solid fa-spinner fa-spin"></i> Loading…');
try {
const res = await app.api.get(`agent/join-keys/${id}/agents`);
const body = (res && (res.results || res)) || {};
const agents = body.agents || [];
if (!agents.length) {
$out.html(`<div class="alert alert-secondary py-2 small mb-0">No hosts have joined with <strong>${esc(label)}</strong> yet.</div>`);
return;
}
const rows = agents.map(a => {
const dot = a.isOnline ? 'text-success' : 'text-muted';
const seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
return `<tr><td><i class="fa-solid fa-circle ${dot}" style="font-size:8px"></i> ${esc(a.name)}</td><td>${esc(a.enrolled_on ? new Date(a.enrolled_on * 1000).toLocaleDateString() : '—')}</td><td>${esc(seen)}</td></tr>`;
}).join('');
$out.html(
`<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` +
'<table class="table table-sm mb-0"><thead><tr><th>Host</th><th>Joined</th><th>Last seen</th></tr></thead><tbody>' + rows + '</tbody></table>'
);
} catch (err) {
$out.html('<div class="alert alert-danger py-2 small mb-0">Could not load hosts: ' + esc(err.message || err) + '</div>');
}
}
// Inline, row-scoped confirm -- swaps the row's action buttons for
// "Revoke/Delete this key? Yes/No" in place. Deliberately not
// app.messages.confirm(): that renders into a single shared .actionMessage
// banner, so a second click before the first resolves leaves a dangling
// `$('body').one('click', ...)` handler from the first call and the banner
// can end up out of sync with which row it's actually confirming for.
// Scoping state to the row itself sidesteps that entirely.
function confirmAgentJoinKeyAction(btn, id, action) {
const isDelete = action === 'delete';
const label = isDelete ? 'Delete' : 'Revoke';
const cls = isDelete ? 'btn-danger' : 'btn-warning';
$(btn).closest('td').html(
`<span class="small me-1">${label}?</span>` +
`<button class="btn ${cls} btn-sm me-1" onclick="reallyDoAgentJoinKeyAction('${id}', '${action}')">Yes</button>` +
`<button class="btn btn-outline-secondary btn-sm" onclick="loadAgentJoinKeys()">No</button>`
);
}
async function reallyDoAgentJoinKeyAction(id, action) {
try {
if (action === 'delete') {
await app.api.delete(`agent/join-keys/${id}`);
app.messages.toast('Join key deleted.', 'success');
} else {
await app.api.post(`agent/join-keys/${id}/revoke`, {});
app.messages.toast('Join key revoked.', 'success');
}
} catch (err) {
app.messages.toast(`Could not ${action}: ` + (err.message || err), 'danger');
}
loadAgentJoinKeys();
}
async function mintAgentJoinKey() {
try {
const res = await app.api.post('agent/join-keys', { label: 'ui' });
const body = (res && (res.results || res)) || {};
if (!body.key) throw new Error(body.message || 'no key returned');
mintedJoinKey = body.key;
$('#agent-join-key-result').show().html(
'<div class="alert alert-success py-2 small mb-3">'
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> '
+ 'It is shown <strong>once</strong> — only its hash is stored. It is already in the command below.'
+ '</div>'
+ '<label class="form-label small fw-bold mb-1">Join key</label>'
+ '<div class="input-group input-group-sm mb-3">'
+ '<input type="text" class="form-control font-monospace" readonly value="' + esc(body.key) + '">'
+ '<button class="btn btn-outline-secondary" type="button" id="btn-copy-jk" onclick="copyAgentCommand(\'agent-jk-copy\', \'btn-copy-jk\')"><i class="fa-solid fa-copy"></i></button>'
+ '</div>'
+ '<span id="agent-jk-copy" class="d-none">' + esc(body.key) + '</span>'
);
loadAgentJoinKeys();
updateAgentCommands();
} catch (err) {
app.messages.toast('Could not create a join key: ' + (err.message || err), 'danger');
}
}
// Mint the token server-side, then reveal the install steps built from it.
async function enrollAgent() {
const name = ($('#agent-enroll-name').val() || '').trim();
+1 -1
View File
@@ -206,8 +206,8 @@
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
+ '<div class="card-body">'
+ '<h5 class="card-title d-flex align-items-start gap-2">'
+ iconHtml
+ '<span>' + esc(r.name) + '</span>'
+ iconHtml
+ '</h5>'
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
+110
View File
@@ -0,0 +1,110 @@
'use strict';
// End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
//
// Simulates the agent: enrolls one, connects to the SSO WSS with its token,
// sends a real LDAP bind request as raw bytes in an `ldap_tunnel` message, and
// verifies the SSO relays it into OpenLDAP and pipes the bind response back.
// This proves the SSO side of the tunnel without needing the agent binary.
const WebSocket = require('ws');
const SSO_URL = process.env.SSO_URL || 'http://sso:3001';
const WS_URL = SSO_URL.replace(/^http/, 'ws') + '/api/agent/ws';
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
const USER_DN = 'cn=test,ou=people,dc=test,dc=local';
function fail(msg) { console.error('E2E FAIL:', msg); process.exit(1); }
async function waitForSso() {
for (let i = 0; i < 60; i++) {
try {
const r = await fetch(`${SSO_URL}/health`);
if (r.ok) return;
} catch (_) {}
await new Promise((res) => setTimeout(res, 1000));
}
fail('SSO never became ready');
}
async function login() {
const r = await fetch(`${SSO_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(TEST_CREDS),
});
if (!r.ok) fail(`login failed: ${r.status}`);
const body = await r.json();
return body.token;
}
async function enrollAgent(authToken) {
const r = await fetch(`${SSO_URL}/api/agent/enroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'auth-token': authToken },
body: JSON.stringify({ name: `e2e-${Date.now().toString(36)}` }),
});
if (!r.ok) fail(`enroll failed: ${r.status}`);
const body = await r.json();
return body.token;
}
// Build a simple LDAP bind request (version 3) as raw BER bytes.
function buildBindRequest(dn, password) {
const dnBuf = Buffer.from(dn, 'utf8');
const pwBuf = Buffer.from(password, 'utf8');
const version = Buffer.from([0x02, 0x01, 0x03]);
const name = Buffer.concat([Buffer.from([0x04, dnBuf.length]), dnBuf]);
const simple = Buffer.concat([Buffer.from([0x80, pwBuf.length]), pwBuf]);
const bindContent = Buffer.concat([version, name, simple]);
const bindReq = Buffer.concat([Buffer.from([0x60, bindContent.length]), bindContent]);
const msgId = Buffer.from([0x02, 0x01, 0x01]);
const msgContent = Buffer.concat([msgId, bindReq]);
return Buffer.concat([Buffer.from([0x30, msgContent.length]), msgContent]);
}
// A successful bind response is a BindResponse (0x61) with resultCode 0 (0x0a 01 00).
function isSuccessBindResponse(buf) {
return buf.includes(Buffer.from([0x61])) && buf.includes(Buffer.from([0x0a, 0x01, 0x00]));
}
async function main() {
await waitForSso();
const authToken = await login();
const agentToken = await enrollAgent(authToken);
console.log('E2E: enrolled agent, connecting WSS...');
const ws = new WebSocket(`${WS_URL}?token=${agentToken}`);
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
console.log('E2E: WSS connected');
const bindBytes = buildBindRequest(USER_DN, TEST_CREDS.password);
ws.send(JSON.stringify({
type: 'ldap_tunnel',
payload: { conn_id: 'e2e-1', data: bindBytes.toString('base64') },
}));
console.log('E2E: sent bind request bytes');
const result = await new Promise((res, rej) => {
const timeout = setTimeout(() => rej(new Error('timed out waiting for bind response')), 10000);
ws.on('message', (data) => {
let msg;
try { msg = JSON.parse(data); } catch (_) { return; }
if (msg.type !== 'ldap_tunnel') return;
if (msg.payload.close) return;
const buf = Buffer.from(msg.payload.data || '', 'base64');
if (isSuccessBindResponse(buf)) {
clearTimeout(timeout);
res({ ok: true, bytes: buf.length });
}
});
ws.on('error', (e) => { clearTimeout(timeout); rej(e); });
});
console.log(`E2E: got successful bind response (${result.bytes} bytes)`);
ws.close();
console.log('E2E PASS');
process.exit(0);
}
main().catch((e) => fail(e.message));