Compare commits

...

7 Commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:51:37 -04:00
wmantly 03605267bc Merge pull request #171 from theta42/feat/agent-join-keys-directory-fixes
feat: agent join keys; fix directory collapse, plugin edit/delete, docs (v1.30.0)
2026-08-06 10:40:02 -04:00
wmantly 7e9a271090 feat: agent join keys; fix directory collapse, plugin edit/delete, docs (v1.30.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m25s
Pull Request Tests / Run Tests (20.x) (push) Failing after 26s
Pull Request Tests / Run Tests (22.x) (push) Failing after 30s
Pull Request Tests / Test Summary (push) Failing after 5s
JOIN KEYS

v1.29.0 required an admin to pre-register every machine before its agent
would be spoken to. The security model was right; the workflow was not --
installing the agent should be enough to add a host.

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 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.

DIRECTORY

Collapsing the tree did nothing. applyTreeCollapse found the caret with
`.tree-caret i` and returned early when absent -- Font Awesome's SVG mode
rewrites <i> to <svg>, so that selector matched nothing and the early
return skipped setting hideBelowDepth. State now lives on the caret
button and is rotated by CSS.

The Discovery Plugins delete button called deleteDiscoveryPlugin(), which
was never defined. The pane also had no .actionMessage, and confirmations
render into one -- without it the promise never settles, so an awaited
confirmation hangs forever and the action silently never happens.

Plugin instances can now be edited.

DISCOVERY

A fresh install presented its own five containers as unmanaged
discoveries. The Docker plugin now recognises the stack's compose project
and attaches each container to the service it implements. Container slugs
came from the container id, which changes on recreate, so every deploy
minted a new resource and orphaned the old one.

DOCS

/docs/discovery 404'd (no slug entry) and `agents` pointed at plugins.md,
leaving docs/agents.md unreachable. Adds docs/discovery.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:33:34 -04:00
wmantly b28a18064a Merge pull request #170 from theta42/fix/directory-discovery-profile
sec: authenticate theta-agent enrollment; directory + discovery fixes (v1.29.0)
2026-08-05 18:55:15 -04:00
26 changed files with 961 additions and 82 deletions
+59
View File
@@ -1,3 +1,62 @@
# 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`.
+2 -1
View File
@@ -200,7 +200,8 @@ If you are pointing the app at your own existing LDAP server, see
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
schema. The bundled Docker image and `install.sh` set all of that up for you.
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
OAuth clients only), `app_sso_invite` (invitation management) — see
OAuth clients only), `app_sso_invite` (invitation management),
`app_sso_directory_admin` (Directory/Plugins/Agent admin) — see
DEPLOYMENT.md for the full setup.
## Development
+53 -8
View File
@@ -10,20 +10,59 @@ The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) e
---
## 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 |
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.
### 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
@@ -186,8 +225,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 +267,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.
---
+15 -1
View File
@@ -16,13 +16,27 @@ deep-merges, in order (later wins):
`localhost`, `SSO Manager`).
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
4. **`app_*` environment variables** — the highest-precedence layer.
4. **`app_*` environment variables** — the highest-precedence layer among these
four.
Any env var whose name starts with `app_` overrides the merged config. The rest
of the name splits on **double-underscore** (`__`) into a nested path. Values are
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
raw strings otherwise.
### A fifth, higher-precedence layer: OpenBao + the Configuration UI
In a theta-suite deployment, `@simpleworkjs/bao-conf`'s `init()` deep-merges
`secret/sso-manager/conf` (from OpenBao) over the four layers above at boot —
this is the layer `setup.sh`/theta-suite actually manages, and it wins over
everything else here. On top of that, the admin **Configuration** page in the
UI writes straight to `secret/sso-manager/conf` (via `routes/api_conf.js`)
and applies the change to the live `conf` object immediately
(`applyToLiveConf`) — no restart, and it bypasses `conf/secrets.js` entirely.
If a value isn't behaving the way `conf/secrets.js` says it should, check the
Configuration UI / OpenBao before assuming a file edit didn't take — it's
almost certainly OpenBao (or a live UI edit) winning the merge.
## Examples
| Env var | Sets | Type |
+117
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 320 KiB

+3
View File
@@ -60,7 +60,10 @@ backend, that's the niche.
run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
- **[Discovery](discovery.html)** — the catalog-vs-discovered distinction, how scanned assets are matched/merged into existing resources, and how a discovery gets promoted into the catalog (and becomes reachable through the jump host).
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
- **[Vault secrets](vault.html)** — an OpenBao-backed key-value store built into the UI, for stashing passwords/API keys/credentials with encryption and access control.
- **[API tokens](concepts-api-tokens.html)** — self-service personal access tokens for calling the management API from scripts/CI without a browser session.
## Get it
+15 -1
View File
@@ -17,11 +17,25 @@ needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
filename basename (without `.js`) is the `type`; the parent directory is the
`category`. The built-ins ship under `plugins/discovery/`:
`category`. Two built-in categories ship today:
**`discovery`** — scheduled scans that sync external assets into the
directory catalog:
- `proxmox` — Proxmox VE (URL + API token)
- `unifi` — UniFi Network controller (URL + username/password)
- `nmap` — nmap OS + port scan (a target range; no credentials)
- `docker` — Docker daemon discovery (containers as directory resources)
**`messaging`** — on-demand delivery for alerts, 2FA codes, and
notifications:
- `twilio` — Twilio SMS
- `webhook` — universal REST webhook (custom JSON payload to Slack, Teams,
Discord, or any HTTP endpoint)
If no messaging plugin instance is enabled, the system falls back to the
legacy `voipms` integration configured directly in the SSO secrets.
### What the Proxmox plugin produces
+34 -4
View File
@@ -1,5 +1,13 @@
---
layout: default
title: Vault Secrets
description: OpenBao-backed personal, shared, and external-app secret storage built into the SSO Manager UI.
---
# Vault Secrets Management
[← Back to Home](index.html)
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
## Usage
@@ -26,7 +34,14 @@ You can access the Vault UI from the application's top navigation bar.
### OpenBao Integration
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
The secrets are stored in a real, initialized-and-unsealed OpenBao backend
(`setup.sh` handles init/unseal on first run) — not OpenBao's ephemeral dev
mode, which auto-unseals with an in-memory store and loses everything on
restart. The default KV (Key-Value) version 2 engine is mounted at `secret/`.
The built-in UI proxies through `/api/vault/secret/…`, authenticated the same
way as the rest of the app (session cookie or a personal API token) — the
server resolves your OpenBao access itself and injects the right scoped
token; you never see or handle a raw OpenBao token as a UI user.
## Apps tab (admin)
@@ -48,9 +63,24 @@ The **Shared** tab lets you share a secret with another user (or app) without co
## API Access
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
To read your own secrets programmatically, call the `/api/vault` proxy with
a [personal API token](concepts-api-tokens.html) — **not** a raw OpenBao
token. The server authenticates the request, resolves your own scoped
OpenBao access, and injects the real `X-Vault-Token` itself:
```bash
# Example: Read a secret via the API
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
# Example: Read a secret via the API (KV-v2, so the path includes /data/)
curl -H "Authorization: Bearer sso_<id>_<secret>" \
https://<your-sso-host>/api/vault/secret/data/<your-secret-path>
```
An **external app** reading its own config uses the scoped token minted for
it on the **Apps** tab instead of a personal token — see *Apps tab (admin)*
above for how that token is minted and what it's confined to.
Using the OpenBao **root token** directly (bypassing the SSO entirely) is
never the intended path for day-to-day secret access — it's an
operator/maintenance credential (seeding, disaster recovery), kept in
`setup.env` and never passed to a service container. See
[theta-env's Secrets doc](https://theta42.github.io/theta-env/secrets.html)
for the full token/policy model.
+72 -1
View File
@@ -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 };
+8 -1
View File
@@ -33,8 +33,15 @@ Mail.send = function(to, subject, message, from){
var transporter = nodemailer.createTransport(transportOpts);
// Most authenticated SMTP relays (and this bit the field: "554 5.7.1
// ...: Sender is not same as SMTP authenticate username") require the
// envelope/header From to equal the authenticated user, or reject the
// send outright. If the operator hasn't set an explicit smtp.from,
// defaulting to the SMTP username is far more likely to actually send
// than a made-up noreply@theta42.com address that no relay authorized
// this account to send as.
var mailOpts = {
from: from || conf.smtp.from || `${conf.name} Accounts <noreply@theta42.com>`,
from: from || conf.smtp.from || conf.smtp.user || `${conf.name} Accounts <noreply@theta42.com>`,
to: to,
subject: subject,
html: message
+2 -2
View File
@@ -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
]
});
+6 -1
View File
@@ -14,7 +14,12 @@ async function send(to, message) {
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
// @simpleworkjs/orm has no `find` -- the query method is `list({where})`.
// `PluginInstance.find(...)` threw "is not a function" on EVERY call into
// this sender, so SMS delivery never worked at all: not the test button, not
// OTP-by-SMS, not notifications. It failed before it could even fall back to
// the direct VoIP.ms path below.
const instances = await PluginInstance.list({ where: { category: 'messaging', enabled: true } });
if (instances.length > 0) {
const inst = instances[0];
const manifest = registry.getManifest(inst.pluginType);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.29.0",
"version": "1.30.2",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+48 -6
View File
@@ -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 });
+95 -10
View File
@@ -5,7 +5,7 @@ const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys');
const { Agent } = require('../models/agent');
const { Agent, AgentJoinKey } = require('../models/agent');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
@@ -172,6 +172,54 @@ 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); }
});
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 +275,37 @@ 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();
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) {}
@@ -294,16 +371,24 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
agentManager.unregisterAgent(agent.id, ws);
});
// 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) {}
});
};
+40 -31
View File
@@ -136,15 +136,25 @@ router.post('/test-email', async (req, res, next) => {
return res.status(400).json({ error: 'Recipient email address is required' });
}
// Use the email model to send the test message
const Email = require('../models/email');
// Send through the SAME sender every other feature uses (password reset,
// invites, OTP-by-email, notifications). A "test" that reimplements
// delivery proves nothing about whether real mail works.
//
// models/email.js exports `{Mail}`; requiring the module and calling
// `.send` on it directly -- as this did -- always threw
// "Email.send is not a function", so the button could never succeed.
const { Mail } = require('../models/email');
const testSubject = subject || 'SSO Manager Test Email';
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
await Email.send(to, testSubject, testBody);
await Mail.send(to, testSubject, testBody);
res.json({ success: true, message: `Test email sent to ${to}` });
} catch(err) {
next(err);
// A failed test is almost always a misconfiguration (wrong host, refused
// connection, bad credentials) -- the operator's to fix, and something the
// UI should be able to show them. Surfacing it as a 400 with the reason
// beats an opaque 500 carrying a raw stack-trace name.
return res.status(400).json({ error: err.message || 'Failed to send test email' });
}
});
@@ -156,38 +166,37 @@ router.post('/test-sms', async (req, res, next) => {
return res.status(400).json({ error: 'Recipient phone number is required' });
}
// Send through models/sms.js -- the same path every real SMS takes. It
// prefers a configured messaging plugin and falls back to VoIP.ms, and it
// normalizes the destination to E.164 digits.
//
// This used to POST to `https://api.voip.ms/v1.0/sms/send` with Basic auth.
// No such endpoint exists: VoIP.ms's REST API is a GET against
// `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and
// `method=sendSMS`. The fabricated URL returned an HTML page, so
// `response.json()` threw `Unexpected token '<', "<!DOCTYPE "...` and the
// button reported that as the failure. It could never have sent anything.
const { SMS } = require('../models/sms');
const { PluginInstance } = require('../models/plugin_instance');
// A messaging plugin, when present, supplies its own credentials -- so
// requiring conf.voipms unconditionally would block a perfectly working
// setup from testing itself.
const messagingPlugins = await PluginInstance.list({ where: { category: 'messaging', enabled: true } }).catch(() => []);
const voipmsConf = conf.voipms || {};
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
if (!messagingPlugins.length && (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did)) {
return res.status(400).json({ error: 'No messaging plugin is loaded and VoIP.ms credentials are not configured. Set username, DID and password in the SMS tab, or load a messaging plugin.' });
}
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`;
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your SMS configuration is working correctly.`;
// VoIP.ms SMS API endpoint
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
method: 'POST',
headers: {
'Authorization': `Basic ${authHeader}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
did: voipmsConf.did,
to: to,
message: testMessage
})
});
const result = await response.json();
if (result.status === 'success') {
res.json({ success: true, message: `Test SMS sent to ${to}` });
} else {
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
}
await SMS.send(to, testMessage);
res.json({ success: true, message: `Test SMS sent to ${to}` });
} catch(err) {
next(err);
// The sender rejects with a useful reason (`VoIP.ms error: <status>`, or a
// plugin's own error). Surface it as a 400 the UI can display rather than
// an opaque 500 -- a misconfiguration is the operator's to fix, not a bug.
return res.status(400).json({ error: err.message || 'Failed to send test SMS' });
}
});
+5 -1
View File
@@ -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')},
+118
View File
@@ -0,0 +1,118 @@
'use strict';
const fs = require('fs');
const path = require('path');
// @simpleworkjs/orm models expose `list`/`get`/`count`/`create` -- there is no
// `find`, `findOne`, `findAll` or `where`. Calling one is not a syntax error and
// nothing catches it until the line actually runs, so it can sit in a rarely
// exercised path indefinitely.
//
// It did: `models/sms.js` called `PluginInstance.find({...})`, which threw
// "is not a function" on EVERY SMS send -- the test button, OTP-by-SMS and
// notifications alike -- before it could even reach the VoIP.ms fallback. SMS
// delivery had simply never worked.
const ORM_MODELS = [
'Resource', 'ResourceEdge', 'ResourceGroup', 'AccessRequest', 'Webhook',
'PluginInstance', 'SharedSecret', 'SharedSecretGrant', 'VaultAppToken',
'Agent', 'AgentJoinKey',
];
const MISSING_STATICS = ['find', 'findOne', 'findAll', 'findAndCountAll', 'where'];
const ROOT = path.join(__dirname, '..');
const SCAN_DIRS = ['models', 'routes', 'services', 'utils', 'plugins', 'controller', 'middleware'];
function walk(dir, out = []) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
walk(full, out);
} else if (entry.name.endsWith('.js')) {
out.push(full);
}
}
return out;
}
// Strip comments so a line *describing* the bug (like the one in models/sms.js)
// isn't reported as the bug.
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
}
test('no source file calls an ORM static that does not exist', () => {
const pattern = new RegExp(
`\\b(${ORM_MODELS.join('|')})\\s*\\.\\s*(${MISSING_STATICS.join('|')})\\s*\\(`,
'g'
);
const offenders = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
const src = stripComments(fs.readFileSync(file, 'utf8'));
src.split('\n').forEach((line, i) => {
const m = line.match(pattern);
if (m) offenders.push(`${path.relative(ROOT, file)}:${i + 1}${m.join(', ')}`);
});
}
}
expect(offenders).toEqual([]);
});
// models/email.js exports `{Mail}`, not a bare sender. Requiring the module and
// calling `.send` on it -- as routes/api_conf.js's test-email did -- always
// threw "Email.send is not a function", so the Test Email button could never
// have worked.
test('the email module exports Mail.send and callers destructure it', () => {
const mod = require('../models/email');
expect(typeof mod.Mail).toBe('object');
expect(typeof mod.Mail.send).toBe('function');
// The bare module has no send() -- this is exactly the mistake to catch.
expect(mod.send).toBeUndefined();
const offenders = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
const src = stripComments(fs.readFileSync(file, 'utf8'));
// `X = require('...email')` followed by `X.send(` where X was not
// destructured.
const assigned = [...src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*require\([^)]*models\/email[^)]*\)/g)]
.map(m => m[1]);
for (const name of assigned) {
if (new RegExp(`\\b${name}\\s*\\.\\s*send\\s*\\(`).test(src)) {
offenders.push(`${path.relative(ROOT, file)}${name}.send(), but the module exports {Mail}`);
}
}
}
}
expect(offenders).toEqual([]);
});
// The VoIP.ms REST API is a GET against voip.ms/api/v1/rest.php with
// api_username/api_password and method=sendSMS. `api.voip.ms/v1.0/sms/send`
// (which test-sms used to POST to with Basic auth) does not exist -- it
// returned an HTML page, so response.json() threw
// `Unexpected token '<', "<!DOCTYPE "...` and the button reported that.
test('nothing targets the non-existent api.voip.ms host', () => {
const offenders = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
// Comments stripped: the note in routes/api_conf.js explaining this
// very bug names the bad host, and describing a mistake is not
// making it.
const src = stripComments(fs.readFileSync(file, 'utf8'));
src.split('\n').forEach((line, i) => {
if (line.includes('api.voip.ms')) {
offenders.push(`${path.relative(ROOT, file)}:${i + 1}`);
}
});
}
}
expect(offenders).toEqual([]);
});
+265 -10
View File
@@ -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">
@@ -230,6 +241,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>
@@ -914,13 +932,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');
}
});
}
@@ -1612,6 +1640,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 +1734,67 @@
</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>
<!-- ── 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 +1938,8 @@
</div>
</div>
</div>
</div><!-- /pre-register pane -->
</div><!-- /tab-content -->
`;
app.modal.open({
@@ -1850,6 +1948,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 +1970,55 @@
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 = [];
var mintedJoinKey = null; // in-memory, for the command shown right now
function loadAgentJoinKeys() {
app.api.get('agent/join-keys', function(err, res) {
agentJoinKeys = (res && res.joinKeys ? res.joinKeys : []).filter(k => !k.revoked);
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})`));
});
}
updateAgentCommands();
});
}
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 +2112,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 +2216,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 +2296,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();
+1 -1
View File
@@ -206,8 +206,8 @@
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
+ '<div class="card-body">'
+ '<h5 class="card-title d-flex align-items-start gap-2">'
+ iconHtml
+ '<span>' + esc(r.name) + '</span>'
+ iconHtml
+ '</h5>'
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'