Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d9ce1078 | |||
| 181ca8c9cb | |||
| 6e748bfa66 | |||
| a78db906e8 | |||
| e7e3eeb6cd | |||
| f178f1a972 | |||
| 03605267bc | |||
| 7e9a271090 | |||
| b28a18064a |
@@ -1,3 +1,117 @@
|
||||
# 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 (8–128 chars) populating input fields with security notices.
|
||||
- **Multi-Level Secret Inheritance.** Dynamic secret resolution across any depth of the resource tree (`Services / Apps -> Hosts / Nodes -> Global Sites`).
|
||||
- **Non-Blocking UI Confirmations.** Replaced browser blocking dialogs with async `app.messages.confirm()` banners.
|
||||
- **UI Directory Layout Improvements.** Fixed Directory table resource name and badge order for enhanced readability.
|
||||
|
||||
### Fixed
|
||||
- **SSSD `sshPublicKey` Mapping.** Included `ldap_user_ssh_public_key = sshPublicKey` in generated agent `sssd.conf` template.
|
||||
|
||||
# Unreleased — LDAP-over-HTTPS API + agent LDAP byte-pump relay
|
||||
|
||||
### Added
|
||||
|
||||
- **`POST /api/v1/ldap/bind` and `POST /api/v1/ldap/search`** — an LDAP-over-HTTPS
|
||||
API (DESIGN.md §3). A client stops speaking LDAP and instead does an HTTPS call
|
||||
to the SSO, which performs the real bind/search against its own OpenLDAP. This
|
||||
kills the hostname / cross-network / LDAPS-cert-chain pain. Caller auth is a
|
||||
Bearer token: an agent token or a self-service API token (PAT). `/search` is
|
||||
restricted to agent callers (the SSSD user/group-resolution use case) and runs
|
||||
under the admin bind — see DESIGN.md §9.5 for the scoped-service-account
|
||||
follow-up.
|
||||
- **LDAP byte-pump relay** (`utils/ldap_tunnel.js`) — the SSO relays raw LDAP
|
||||
bytes from an agent's local socket into its real OpenLDAP and pipes the
|
||||
response back, over the existing agent WSS channel (`ldap_tunnel` messages).
|
||||
The SSO does not parse LDAP; it is a transparent socket relay. See DESIGN.md §4.
|
||||
- **`POST /api/v1/agent/secrets`** — an agent fetches its own node-scoped OpenBao
|
||||
secrets (DESIGN.md §5). The agent may only read under `secret/data/nodes/<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.
|
||||
|
||||
### theta-agent — enrollment without pre-registering
|
||||
|
||||
- feat: **join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued **its own** per-agent token plus the public key it must pin, delivered in the `config` frame; the agent persists both and blanks the join key. v1.29.0 required an admin to pre-register every machine before its agent would be spoken to, which made adding a host a two-system chore — the security model was right, the workflow was not.
|
||||
- feat: a join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable and a compromised host yields nothing that works elsewhere. Revoking a join key stops new hosts joining and leaves already-enrolled agents alone.
|
||||
- feat: join keys support a label and optional expiry, record their use count, and are stored as a SHA-256 (`AgentJoinKey`). Issue/revoke/delete and every self-enrollment are audited.
|
||||
|
||||
### Directory
|
||||
|
||||
- fix: **collapsing the tree did nothing.** `applyTreeCollapse` located the caret with `$row.find('.tree-caret i')` and returned early when it found nothing. Font Awesome runs in SVG-with-JS mode and its mutation observer rewrites every `<i class="fa-…">` into an `<svg>`, so moments after a render that selector matched nothing — and the early return skipped setting `hideBelowDepth`, so no row was ever hidden. Collapse state now lives on the caret *button* and is rotated by CSS, and the hide decision is made from the collapsed set alone. Never key behaviour to an element another library is free to replace.
|
||||
- fix: **the Discovery Plugins delete button did nothing.** It called `deleteDiscoveryPlugin()`, which was never defined — clicking it only threw a `ReferenceError`.
|
||||
- fix: the plugins pane had no `.actionMessage` element, and `app.messages` confirmations render into one. Without it the returned promise **never settles**, so an awaited confirmation hangs forever and the action it gates silently never happens. Added, along with a note that any pane asking for confirmation needs it.
|
||||
- feat: **discovery plugin instances can be edited.** Name, schedule, loaded state and configuration, with secrets on their own endpoint and left blank ("unchanged") rather than prefilled with the mask — submitting `********` back would otherwise store the asterisks as the secret.
|
||||
|
||||
### Discovery
|
||||
|
||||
- fix: **a fresh install no longer presents its own containers as things to triage.** The Docker plugin recognises containers belonging to the stack's own compose project, records them as managed, and attaches each to the service it implements. `setup.sh` deploys `sso-manager`, `proxy`, `jump-host`, `openbao` and `bao-renewer`; all five arrived as unmanaged discoveries awaiting promotion.
|
||||
- fix: **Docker container slugs were derived from the container id**, which changes on every recreate — so each `docker compose up` minted a brand-new resource and orphaned the previous one. Slugs now come from compose project + service, falling back to the container name.
|
||||
- feat: discovered containers carry `composeProject`, `composeService`, `containerName` and `sourceId`.
|
||||
|
||||
### Docs
|
||||
|
||||
- fix: `/docs/discovery` 404'd — the slug had no entry, though the Discovery tab's help icon linked to it. New `docs/discovery.md` covering the catalog/discovered distinction, how sources are matched and merged, naming precedence, promotion and garbage collection.
|
||||
- fix: the `agents` slug pointed at `plugins.md`, so `docs/agents.md` was unreachable in the app.
|
||||
|
||||
# v1.29.0
|
||||
|
||||
**Breaking:** theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the `sso-broker` OpenBao policy must grant `secret/agent/*`); re-run `./setup.sh`.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -6,24 +6,93 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Enrollment (required)
|
||||
## Enrollment
|
||||
|
||||
An agent is only real if the SSO issued its token. **Tokens the server did not
|
||||
issue are rejected** at the WebSocket handshake.
|
||||
An agent is only real if the SSO issued its credential. **Tokens the server did
|
||||
not issue are rejected** at the WebSocket handshake.
|
||||
|
||||
Enroll from **Directory → Install Agent**:
|
||||
There are two ways to get a host enrolled, and the first is the normal one.
|
||||
|
||||
1. Give the agent a name and, ideally, **bind it to a host resource**. The
|
||||
binding is what links telemetry, status and commands to a Directory entry.
|
||||
### Join key — install the agent and the host appears
|
||||
|
||||
Hand the machine a **join key** and nothing else. On first connect the SSO
|
||||
enrolls the host, issues it its own per-agent token plus the public key it must
|
||||
pin, and the agent **writes both into its own `agent.yml`** and blanks the join
|
||||
key. From then on it authenticates as itself.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
|
||||
--url "https://<SSO_HOST>" --join-key "tjk_..."
|
||||
```
|
||||
|
||||
That is the whole procedure — no pre-registering the machine, no copying a
|
||||
public key by hand. `setup.sh` mints a key and configures the stack's own host
|
||||
this way automatically.
|
||||
|
||||
The join key is a *bootstrap* credential, not the host's identity. That
|
||||
distinction is what keeps one key convenient without making it a fleet-wide
|
||||
skeleton key: every host still ends up individually revocable, and a compromised
|
||||
host does not yield a credential that works anywhere else.
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| :--- | :--- |
|
||||
| `GET /api/agent/join-keys` | List keys (prefix + usage only; never the key) |
|
||||
| `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
|
||||
from **Directory → Install Agent**:
|
||||
|
||||
1. Give the agent a name and **bind it to a host resource**. The binding is what
|
||||
links telemetry, status and commands to a Directory entry.
|
||||
2. Press **Enroll & issue token**. The SSO mints a 256-bit token, stores only its
|
||||
SHA-256, and shows the raw value **once**.
|
||||
3. Copy the generated install command — it already carries the token and the
|
||||
server's public key.
|
||||
|
||||
A host that self-enrolls with a join key arrives unbound; bind it afterwards with
|
||||
`PUT /api/agent/nodes/:id` or from the Directory.
|
||||
|
||||
Or via the API:
|
||||
|
||||
```bash
|
||||
@@ -128,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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -158,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
|
||||
@@ -186,8 +426,12 @@ curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE
|
||||
```yaml
|
||||
# /etc/theta42/agent.yml
|
||||
server_url: "wss://sso.example.com"
|
||||
# Issued by the SSO at enrollment. A token the server did not issue is rejected.
|
||||
# Issued by the SSO. Left empty when installing with a join key -- the agent
|
||||
# fills it in itself once the server enrolls it.
|
||||
auth_token: "c8181ce0e55bf7302b11d719a7ae39adcd7604de461e6e363f8bb4fadf126acb"
|
||||
# Bootstrap credential. Used only while auth_token is empty, and blanked by the
|
||||
# agent once it has its own token.
|
||||
join_key: ""
|
||||
location: "dc-01-rack-12"
|
||||
# Base64 of the RAW 32-byte Ed25519 public key -- exactly the `publicKey` value
|
||||
# from enrollment or GET /api/agent/nodes. Not a PEM body: a base64-decoded
|
||||
@@ -224,7 +468,9 @@ the SSO only floods its audit log.
|
||||
|
||||
An agent installed before protocol v1.2.0 carries a token generated in the
|
||||
browser that the server never recorded, so it will be rejected with `4001` until
|
||||
re-enrolled.
|
||||
re-enrolled. The quickest fix is to put a **join key** in its `agent.yml` as
|
||||
`join_key` and blank `auth_token` — it will re-enroll itself on the next
|
||||
reconnect.
|
||||
|
||||
---
|
||||
|
||||
@@ -249,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).
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
layout: default
|
||||
title: Discovery & Inventory
|
||||
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:
|
||||
|
||||
- **Catalog resources** — what you have declared. Created by hand, seeded by
|
||||
`setup.sh`, or *promoted* from a discovery result. These get LDAP access
|
||||
groups, appear in the Catalog, and are the only hosts the
|
||||
[jump host](https://github.com/theta42/jump-host) will connect you to.
|
||||
- **Discovered resources** — what the network reports. Produced by
|
||||
[discovery plugins](plugins.html) and shown on the **Discovered Inventory**
|
||||
tab. They are a queue of "this exists, do you want to manage it?", not
|
||||
infrastructure you have committed to.
|
||||
|
||||
A resource is discovery-only when its `metadata.discovery_sources` is non-empty
|
||||
and it has never been promoted. Promoting sets `metadata.managed = true`, at
|
||||
which point it becomes catalog content like any other resource.
|
||||
|
||||
> Nothing grants access to a discovered resource. It carries no groups until it
|
||||
> is promoted, and the jump host applies the same rule — an unpromoted Proxmox
|
||||
> guest is not a jump target.
|
||||
|
||||
---
|
||||
|
||||
## Where discovered data comes from
|
||||
|
||||
| Source | What it reports |
|
||||
| :--- | :--- |
|
||||
| [Proxmox](plugins.html) | The cluster endpoint, its nodes, and every VM/LXC with NICs, `vmid` and node |
|
||||
| [UniFi](plugins.html) | Network devices and connected clients, by MAC |
|
||||
| [nmap](plugins.html) | Hosts and open ports on a target range |
|
||||
| [Docker](plugins.html) | Containers on a local or remote daemon |
|
||||
| [theta-agent](agents.html) | The host it runs on — OS, kernel, CPU, RAM, disk, addresses |
|
||||
| [ldap-client](directory.html) | A Linux host registering itself when it joins |
|
||||
|
||||
An agent is the most authoritative of these: it runs *on* the machine it
|
||||
describes. A network scan is the least — it only knows what answered.
|
||||
|
||||
---
|
||||
|
||||
## How results are matched to existing resources
|
||||
|
||||
Every source runs through one reconciler, so two sources seeing the same
|
||||
machine converge on one resource instead of creating duplicates. Matching is
|
||||
tried in order of precision:
|
||||
|
||||
1. **MAC address** — the strongest signal, compared across every interface.
|
||||
2. **IP address** — any address on any interface, plus `metadata.address`.
|
||||
3. **Slug, name, or base hostname** — last resort.
|
||||
|
||||
A candidate must also be **the same kind**. Without that guard a discovered VM
|
||||
named `gitea-runner` would match a hand-created *service* of the same name on
|
||||
rule 3 and overwrite it. (`template` counts as `host`: converting a VM to a
|
||||
template is the same machine.)
|
||||
|
||||
When a match is found the metadata is merged, interfaces are unioned by MAC, and
|
||||
the source is added to `discovery_sources` — so a resource can legitimately read
|
||||
`["unifi", "proxmox"]`, meaning two independent sources agree it exists.
|
||||
|
||||
### Naming
|
||||
|
||||
Sources disagree about names, so the most human one wins: a **hostname** beats
|
||||
an **IP-shaped** name, which beats a **MAC-shaped** name; length is only a
|
||||
tie-break within a rank. This is why a device UniFi knows only as
|
||||
`ac:16:2d:b3:da:80` is renamed `dl380-0` once Proxmox reports it.
|
||||
|
||||
### Relationships
|
||||
|
||||
Plugins emit edges as well as resources (a Proxmox node under its cluster
|
||||
endpoint, a guest under its node). The reconciler refuses any edge that would
|
||||
make a resource its own parent, or that would close a loop — a cycle renders as
|
||||
an infinitely nested tree and breaks every ancestor walk in the app.
|
||||
|
||||
---
|
||||
|
||||
## Promoting a discovered resource
|
||||
|
||||
On the **Discovered Inventory** tab, press **Promote**. The resource form opens
|
||||
pre-filled with what was discovered — name, kind, address, subtype — so you can
|
||||
correct it before committing. Saving marks it managed and provisions its
|
||||
[LDAP groups](groups.html).
|
||||
|
||||
Each row shows what the directory knows about the device: its source(s), its
|
||||
`vmid` where applicable, the identifier it has at that source (`sourceId`, e.g.
|
||||
`dl380-0/qemu/234`), and every interface with its MAC and address. If a row
|
||||
looks wrong, that detail is where to start.
|
||||
|
||||
---
|
||||
|
||||
## Stale results
|
||||
|
||||
Resources that are *only* auto-discovered are garbage-collected: if a source
|
||||
stops reporting one for long enough it is marked
|
||||
`lifecycle_state: "archived"` rather than deleted. Anything you created or
|
||||
promoted is never touched — `manual` in `discovery_sources` exempts it.
|
||||
|
||||
A Proxmox node that is powered off is still reported (with its `status`), so
|
||||
downtime does not look like decommissioning.
|
||||
|
||||
---
|
||||
|
||||
## What the stack discovers about itself
|
||||
|
||||
`setup.sh` seeds its own components as catalog resources — the site, the stack
|
||||
host, `theta-proxy` and `theta-jump`, and the services under them. The Docker
|
||||
discovery plugin then finds the containers backing them. Containers belonging to
|
||||
the theta-suite compose project are recognised and attached to the service they
|
||||
implement rather than appearing as unmanaged strangers, so a fresh install has an
|
||||
empty Discovered Inventory rather than five things demanding attention.
|
||||
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 320 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -108,4 +108,75 @@ class Agent extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Agent };
|
||||
// A join key: the one credential an operator hands out so a host can enroll
|
||||
// itself. Requiring an admin to pre-register every machine before the agent
|
||||
// would talk to them made adding a host a two-system chore -- installing the
|
||||
// agent should be enough.
|
||||
//
|
||||
// A join key is NOT the agent's long-term credential. On first connect the
|
||||
// server auto-enrolls the host and issues it a unique per-agent token, which
|
||||
// the agent persists and uses from then on (PROTOCOL.md 1.2). That keeps the
|
||||
// operator experience to "one key" while still giving every host its own
|
||||
// revocable identity -- revoking a single agent means something, and a host
|
||||
// that is compromised does not hand over the credential for the whole fleet.
|
||||
class AgentJoinKey extends Model {
|
||||
static hashKey(raw) {
|
||||
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
static generateKey() {
|
||||
// `tjk_` so an operator can tell a join key from an agent token at a
|
||||
// glance -- they are handled very differently.
|
||||
return 'tjk_' + crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Resolve a presented key to a usable join key, or null. Expiry and
|
||||
// revocation are both enforced here so no caller can forget one.
|
||||
static async authenticate(rawKey) {
|
||||
if (!rawKey || typeof rawKey !== 'string') return null;
|
||||
const keyHash = this.hashKey(rawKey);
|
||||
const matches = await this.list({ where: { keyHash } });
|
||||
const key = matches && matches[0];
|
||||
if (!key) return null;
|
||||
if (key.revoked) return null;
|
||||
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
|
||||
return key;
|
||||
}
|
||||
|
||||
static async issue({ label, createdBy, expiresInDays }) {
|
||||
const raw = this.generateKey();
|
||||
const key = await this.create({
|
||||
id: crypto.randomUUID(),
|
||||
label: label || 'default',
|
||||
keyHash: this.hashKey(raw),
|
||||
keyPrefix: raw.slice(0, 12),
|
||||
revoked: false,
|
||||
created_by: createdBy || null,
|
||||
created_on: Math.floor(Date.now() / 1000),
|
||||
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
|
||||
use_count: 0
|
||||
});
|
||||
return { key, raw };
|
||||
}
|
||||
|
||||
static fields = {
|
||||
id: { type: 'uuid', primaryKey: true },
|
||||
label: { type: 'string', isRequired: true },
|
||||
keyHash: { type: 'string', isRequired: true },
|
||||
keyPrefix: { type: 'string' },
|
||||
revoked: { type: 'boolean', default: false },
|
||||
created_by: { type: 'string' },
|
||||
created_on: { type: 'integer' },
|
||||
expires_on: { type: 'integer' },
|
||||
use_count: { type: 'integer', default: 0 },
|
||||
last_used_on: { type: 'integer' }
|
||||
};
|
||||
|
||||
toPublic() {
|
||||
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||
delete data.keyHash;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Agent, AgentJoinKey };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,7 +20,7 @@ const { PluginInstance } = require('./plugin_instance');
|
||||
const { SharedSecret } = require('./shared_secret');
|
||||
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||
const { VaultAppToken } = require('./vault_app_token');
|
||||
const { Agent } = require('./agent');
|
||||
const { Agent, AgentJoinKey } = require('./agent');
|
||||
async function initORM() {
|
||||
const ormConf = conf.orm || {
|
||||
dialect: 'sqlite',
|
||||
@@ -35,7 +35,7 @@ async function initORM() {
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||
SharedSecret, SharedSecretGrant, VaultAppToken, Agent,
|
||||
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.29.0",
|
||||
"version": "1.30.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.29.0",
|
||||
"version": "1.30.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.29.0",
|
||||
"version": "1.31.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,15 @@ module.exports = {
|
||||
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
||||
configSchema: [
|
||||
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
|
||||
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' }
|
||||
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' },
|
||||
// Containers in this compose project are the stack's own. They are already
|
||||
// represented in the catalog as services, so they are recorded as managed
|
||||
// and linked to the service they implement instead of arriving as
|
||||
// unmanaged strangers a fresh install has to triage.
|
||||
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
|
||||
// The catalog host these containers run on, so they land in the tree
|
||||
// instead of as roots.
|
||||
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' }
|
||||
],
|
||||
|
||||
validate: async (config) => {
|
||||
@@ -48,23 +56,57 @@ module.exports = {
|
||||
const resources = [];
|
||||
const edges = [];
|
||||
|
||||
const stackProject = (config.stackProject || '').trim();
|
||||
const hostSlug = (config.hostSlug || '').trim();
|
||||
|
||||
for (const c of containers) {
|
||||
const labels = c.Labels || {};
|
||||
const composeProject = labels['com.docker.compose.project'] || '';
|
||||
const composeService = labels['com.docker.compose.service'] || '';
|
||||
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
|
||||
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
|
||||
|
||||
|
||||
// A container id changes every time the container is recreated,
|
||||
// so an id-derived slug made `docker compose up` mint a brand-new
|
||||
// resource on every deploy and orphan the previous one. Prefer
|
||||
// identifiers that survive a recreate: the compose project+service
|
||||
// it belongs to, else its name.
|
||||
const stableKey = composeProject && composeService
|
||||
? `${composeProject}-${composeService}`
|
||||
: (name || c.Id.substring(0, 12));
|
||||
const slug = `docker-${stableKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||
|
||||
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||
|
||||
const isOwnStack = !!(stackProject && composeProject === stackProject);
|
||||
|
||||
resources.push({
|
||||
kind: 'container',
|
||||
name: name,
|
||||
name: composeService || name,
|
||||
slug: slug,
|
||||
metadata: {
|
||||
image: c.Image,
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
ports: ports
|
||||
ports: ports,
|
||||
composeProject: composeProject || undefined,
|
||||
composeService: composeService || undefined,
|
||||
containerName: name,
|
||||
sourceId: stableKey,
|
||||
// Part of the deployment we are running inside: already
|
||||
// accounted for, not something to promote.
|
||||
managed: isOwnStack ? true : undefined
|
||||
}
|
||||
});
|
||||
|
||||
// Attach the container to the service it implements when the
|
||||
// catalog already has one under that slug (the bootstrap seeds
|
||||
// `sso-manager`, `proxy`, `jump-host`, … using the same names
|
||||
// compose uses). The reconciler drops an edge whose parent does
|
||||
// not resolve, so an unmatched name is simply not linked.
|
||||
if (isOwnStack && composeService) {
|
||||
edges.push({ parentSlug: composeService, childSlug: slug, relation: 'runs' });
|
||||
} else if (hostSlug) {
|
||||
edges.push({ parentSlug: hostSlug, childSlug: slug, relation: 'hosts' });
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ resources, edges });
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -5,14 +5,15 @@ const middleware = require('../middleware/auth');
|
||||
const permission = require('../utils/permission');
|
||||
const agentManager = require('../utils/agent_manager');
|
||||
const agentKeys = require('../utils/agent_keys');
|
||||
const { Agent } = require('../models/agent');
|
||||
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'];
|
||||
|
||||
// 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
|
||||
@@ -172,6 +173,71 @@ router.delete('/nodes/:id', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Join keys ---
|
||||
// One key an operator hands out; hosts that present it enroll themselves and
|
||||
// are immediately issued their own per-agent token. Listing never returns the
|
||||
// key itself -- only its prefix and usage.
|
||||
router.get('/join-keys', async (req, res, next) => {
|
||||
try {
|
||||
const keys = await AgentJoinKey.list();
|
||||
res.json({ status: 'ok', joinKeys: keys.map(k => k.toPublic()) });
|
||||
} 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 || {};
|
||||
const { key, raw } = await AgentJoinKey.issue({
|
||||
label: (label && String(label).trim()) || 'default',
|
||||
createdBy: req.user.uid,
|
||||
expiresInDays: expiresInDays ? Number(expiresInDays) : null
|
||||
});
|
||||
logAgentAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||
// Shown once; only the hash is stored.
|
||||
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/join-keys/:id/revoke', 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' });
|
||||
await key.update({ revoked: true });
|
||||
logAgentAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||
// Agents already enrolled keep working -- they hold their own tokens now,
|
||||
// which is the whole point of exchanging the join key rather than using it
|
||||
// as the long-term credential.
|
||||
res.json({ status: 'ok' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.delete('/join-keys/:id', 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' });
|
||||
await key.delete();
|
||||
logAgentAudit('join_key_deleted', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||
res.json({ status: 'ok' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// --- Commands ---
|
||||
// Addressed by agent id, not by token: a token is a credential and has no
|
||||
// business travelling in a URL, being logged, or sitting in browser history.
|
||||
@@ -227,8 +293,48 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||
// the peer is an anonymous stranger, and the old code treated it as a
|
||||
// trusted node purely for presenting a non-empty string.
|
||||
let agent = null;
|
||||
let issuedToken = null; // set when this connection auto-enrolled
|
||||
try {
|
||||
agent = await Agent.authenticate(token);
|
||||
|
||||
// Not a known agent token -- try it as a join key. This is what makes
|
||||
// "install the agent with a key and the host appears" work without an
|
||||
// admin pre-registering every machine. The join key is exchanged for a
|
||||
// per-agent token below, so it never becomes the host's long-term
|
||||
// credential.
|
||||
if (!agent) {
|
||||
const joinKey = await AgentJoinKey.authenticate(token);
|
||||
if (joinKey) {
|
||||
const hostname = (url.searchParams.get('hostname') || '').trim();
|
||||
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)
|
||||
}).catch(() => {});
|
||||
logAgentAudit('join', {
|
||||
agentId: agent.id, agentName: agent.name, remoteAddr,
|
||||
joinKeyLabel: joinKey.label, joinKeyPrefix: joinKey.keyPrefix
|
||||
});
|
||||
console.log(`[Theta Agent] "${agent.name}" self-enrolled with join key ${joinKey.keyPrefix}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Theta Agent] authentication lookup failed:', err.message);
|
||||
try { ws.close(1011, 'Authentication unavailable'); } catch (e) {}
|
||||
@@ -250,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);
|
||||
@@ -269,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);
|
||||
@@ -281,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}`);
|
||||
}
|
||||
@@ -292,18 +479,27 @@ 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
|
||||
// Send initial welcome/config payload. When this connection enrolled via a
|
||||
// join key it also carries the credentials the agent should persist and use
|
||||
// from now on: its own token, and the public key it must pin to verify
|
||||
// signed commands. Handing the public key over here is what removes the
|
||||
// last manual step -- an agent installed with only a join key ends up fully
|
||||
// configured without anyone copying values between two machines.
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'config',
|
||||
payload: {
|
||||
message: 'Connected to SSO Manager C2',
|
||||
protocol_version: '1.2.0',
|
||||
agent_id: agent.id
|
||||
}
|
||||
}));
|
||||
const payload = {
|
||||
message: 'Connected to SSO Manager C2',
|
||||
protocol_version: '1.2.0',
|
||||
agent_id: agent.id
|
||||
};
|
||||
if (issuedToken) {
|
||||
payload.enrolled = true;
|
||||
payload.auth_token = issuedToken;
|
||||
payload.public_key = await agentManager.publicKeyBase64();
|
||||
}
|
||||
ws.send(JSON.stringify({ type: 'config', payload }));
|
||||
} catch (e) {}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -34,8 +34,12 @@ const DOCS = {
|
||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||
// `agents` pointed at plugins.md, so docs/agents.md -- the theta-agent
|
||||
// guide the Directory links to -- was unreachable in the app.
|
||||
agents: {title: 'Theta Agent', file: path.join(__dirname, '../../docs/agents.md')},
|
||||
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||
// The Discovery tab's help icon links here; without an entry it 404'd.
|
||||
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
|
||||
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,5 +1,16 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<style>
|
||||
/* The caret's rotation is driven by a class on the BUTTON, not by swapping
|
||||
icon classes on its child: Font Awesome's SVG-with-JS mode replaces the
|
||||
<i> with an <svg>, so anything keyed to the child element stops working
|
||||
the moment its observer runs. Targeting both covers either state. */
|
||||
.tree-caret > i,
|
||||
.tree-caret > svg { transition: transform .12s ease-in-out; }
|
||||
.tree-caret.tree-caret-collapsed > i,
|
||||
.tree-caret.tree-caret-collapsed > svg { transform: rotate(-90deg); }
|
||||
</style>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
@@ -37,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>
|
||||
@@ -79,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}}
|
||||
@@ -230,6 +245,13 @@
|
||||
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- app.messages confirmations render into a `.actionMessage` inside
|
||||
the target and do NOTHING without one: the returned promise never
|
||||
settles, so an awaited confirmation hangs forever and the action
|
||||
it gates silently never happens. This pane had no such element,
|
||||
which is why Delete appeared dead. Any pane that asks the
|
||||
operator to confirm something needs this. -->
|
||||
<div class="actionMessage" style="display:none"></div>
|
||||
<div id="discovery-plugins-list" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -489,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/<slug>/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).
|
||||
@@ -501,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: {
|
||||
@@ -509,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) {
|
||||
@@ -706,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 {
|
||||
@@ -780,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) ||
|
||||
@@ -914,13 +1056,23 @@
|
||||
hideBelowDepth = null;
|
||||
$row.show();
|
||||
|
||||
const $icon = $row.find('.tree-caret i');
|
||||
if (!$icon.length) return;
|
||||
// Visual state lives on the .tree-caret BUTTON, rotated by CSS, and the
|
||||
// hide decision is made from `collapsed` alone.
|
||||
//
|
||||
// This used to read `.tree-caret i` and bail out when it found nothing.
|
||||
// Font Awesome runs in SVG-with-JS mode here: its mutation observer
|
||||
// rewrites every <i class="fa-..."> into an <svg>, so moments after a
|
||||
// render that selector matches nothing, the function returned early
|
||||
// WITHOUT setting hideBelowDepth, and collapsing silently did nothing at
|
||||
// all. Never make the collapse logic depend on an element another library
|
||||
// is free to replace.
|
||||
const $caret = $row.find('.tree-caret');
|
||||
if (!$caret.length) return; // leaf row: nothing to collapse
|
||||
if (collapsed.has(id)) {
|
||||
$icon.removeClass('fa-chevron-down').addClass('fa-chevron-right');
|
||||
$caret.addClass('tree-caret-collapsed');
|
||||
hideBelowDepth = depth;
|
||||
} else {
|
||||
$icon.removeClass('fa-chevron-right').addClass('fa-chevron-down');
|
||||
$caret.removeClass('tree-caret-collapsed');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1326,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
|
||||
@@ -1612,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}"` : '');
|
||||
@@ -1691,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">
|
||||
@@ -1842,6 +2264,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /pre-register pane -->
|
||||
</div><!-- /tab-content -->
|
||||
`;
|
||||
|
||||
app.modal.open({
|
||||
@@ -1850,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();
|
||||
@@ -1870,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();
|
||||
@@ -1963,6 +2528,7 @@
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
||||
${logsBtn}
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Edit" onclick="openEditDiscoveryPluginModal('${p.id}')"><i class="fa-solid fa-pen"></i> Edit</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
||||
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||
@@ -2066,18 +2632,27 @@
|
||||
var raw = document.getElementById(prefix + 'cron');
|
||||
return (raw && raw.value.trim()) || '0 * * * *';
|
||||
}
|
||||
function dpConfigFormHtml(type, prefix) {
|
||||
// `values` pre-fills the form for edit mode. Secret fields are never returned
|
||||
// by the API in the clear (they live in OpenBao and come back masked), so
|
||||
// they are rendered EMPTY with a "leave blank to keep" hint rather than
|
||||
// prefilled with `********` -- submitting the mask back would otherwise store
|
||||
// the literal asterisks as the secret.
|
||||
function dpConfigFormHtml(type, prefix, values) {
|
||||
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
|
||||
var schema = t && t.configSchema;
|
||||
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
||||
values = values || {};
|
||||
var html = '';
|
||||
schema.forEach(function(f) {
|
||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||
var req = f.required ? ' required' : '';
|
||||
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
|
||||
var req = (f.required && !f.secret) ? ' required' : '';
|
||||
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
|
||||
var val = '';
|
||||
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
|
||||
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged — type a new value to replace"';
|
||||
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
|
||||
html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
|
||||
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + '></div>';
|
||||
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
@@ -2137,6 +2712,102 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Edit an existing instance. Non-secret config goes to PUT /plugins/:id;
|
||||
// secrets go to PUT /plugins/:id/secrets and only when the operator actually
|
||||
// typed a new value -- they are two endpoints because the DB row must never
|
||||
// hold a secret (see routes/api_plugins.js).
|
||||
function openEditDiscoveryPluginModal(id) {
|
||||
const p = discoveryPlugins.find(x => x.id === id);
|
||||
if (!p) return;
|
||||
app.api.get('plugins/types', function(err, res) {
|
||||
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
|
||||
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
|
||||
const values = Object.assign({}, p.config || {}, { __isEdit: true });
|
||||
const bodyHtml = `
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Plugin Type</label>
|
||||
<input type="text" class="form-control" value="${esc(p.pluginType)}" disabled>
|
||||
<div class="form-text">The type is fixed once an instance exists — create a new instance to use a different one.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Instance Name</label>
|
||||
<input type="text" id="edit-plugin-name" class="form-control shadow-sm" value="${esc(p.name)}">
|
||||
<div class="form-text">Slug <code>${esc(p.slug)}</code> is stable and does not change.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Schedule</label>
|
||||
${dpCronSelectHtml('ep-', p.cron)}
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="edit-plugin-enabled" ${p.enabled ? 'checked' : ''}>
|
||||
<label class="form-check-label fw-semibold" for="edit-plugin-enabled">Loaded (runs on its schedule)</label>
|
||||
</div>
|
||||
<hr><h6 class="fw-bold">Configuration</h6>
|
||||
<div id="edit-plugin-config-fields">${dpConfigFormHtml(p.pluginType, 'ep-', values)}</div>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button>
|
||||
</div>
|
||||
`;
|
||||
app.modal.open({ title: 'Edit Discovery Plugin — ' + p.name, bodyHtml: bodyHtml, size: 'lg' });
|
||||
});
|
||||
}
|
||||
|
||||
async function saveEditedDiscoveryPlugin(id) {
|
||||
const p = discoveryPlugins.find(x => x.id === id);
|
||||
if (!p) return;
|
||||
const name = ($('#edit-plugin-name').val() || '').trim();
|
||||
if (!name) { app.messages.toast('Name is required', 'warning'); return; }
|
||||
|
||||
const flat = dpCollectConfig(p.pluginType, 'ep-');
|
||||
const type = discoveryPluginTypes.find(t => t.type === p.pluginType);
|
||||
const schema = (type && type.configSchema) || [];
|
||||
|
||||
// Split by the schema so a secret never rides along in the DB payload, and
|
||||
// an untouched secret field is not sent at all.
|
||||
const config = {};
|
||||
const secrets = {};
|
||||
schema.forEach(f => {
|
||||
const v = flat[f.key];
|
||||
if (f.secret) { if (v) secrets[f.key] = v; }
|
||||
else config[f.key] = v;
|
||||
});
|
||||
|
||||
try {
|
||||
await app.api.put(`plugins/${id}`, {
|
||||
name,
|
||||
cron: dpCronFromForm('ep-'),
|
||||
enabled: $('#edit-plugin-enabled').is(':checked'),
|
||||
config
|
||||
});
|
||||
if (Object.keys(secrets).length) await app.api.put(`plugins/${id}/secrets`, secrets);
|
||||
app.modal.close();
|
||||
app.messages.toast('Plugin updated', 'success');
|
||||
loadDiscoveryPlugins();
|
||||
} catch (e) {
|
||||
app.messages.toast('Error saving plugin: ' + (e.message || e), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Was referenced by the card's trash button but never defined, so clicking it
|
||||
// only threw a ReferenceError -- delete appeared to do nothing.
|
||||
async function deleteDiscoveryPlugin(id) {
|
||||
const p = discoveryPlugins.find(x => x.id === id);
|
||||
const label = p ? (p.name || p.slug) : 'this plugin';
|
||||
const $card = $('#plugins-tab-pane');
|
||||
const confirmed = await app.messages.confirm(
|
||||
`Delete discovery plugin "${label}"? Its schedule stops and its stored secrets are removed. Resources it already discovered stay in the Directory.`,
|
||||
$card, 'warning');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await app.api.delete(`plugins/${id}`);
|
||||
app.messages.toast('Plugin deleted', 'success');
|
||||
loadDiscoveryPlugins();
|
||||
} catch (e) {
|
||||
app.messages.toast('Error deleting plugin: ' + (e.message || e), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNewDiscoveryPlugin() {
|
||||
const type = $('#new-plugin-type').val();
|
||||
const name = $('#new-plugin-name').val().trim();
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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));
|
||||