Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e748bfa66 | |||
| a78db906e8 | |||
| e7e3eeb6cd | |||
| f178f1a972 | |||
| 03605267bc | |||
| 7e9a271090 | |||
| b28a18064a |
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
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.
|
||||
|
||||
@@ -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
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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.30.2",
|
||||
"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 });
|
||||
|
||||
@@ -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) {}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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([]);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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>'
|
||||
|
||||