sec: authenticate theta-agent enrollment; directory + discovery fixes (v1.29.0)
SECURITY /api/agent/ws authenticated nothing. There was no agent registry, so any client reaching the SSO could register as a node, publish discovery and telemetry into the admin view, and receive commands -- including a signed arbitrary_bash -- addressed to a token it guessed. Tokens were generated in the BROWSER and never recorded server-side, so there was nothing to validate against and no way to revoke one. Agents are now rows in a new Agent table, authenticated by SHA-256 token hash before the connection is registered or the welcome payload is sent. Tokens are minted by POST /api/agent/enroll and shown once. Revoke and rotate drop the live socket immediately. All agent actions are audited. The Ed25519 command-signing key was generated in the AgentManager constructor, so it changed on every restart and the public_key pinned in an agent's agent.yml stopped matching. It now lives in OpenBao at secret/agent/signing-key; if it cannot be loaded the SSO refuses to send high-risk commands rather than signing with a key no agent has seen. DIRECTORY Agents bind to a host resource instead of being matched by hostname, and a bound agent's discovery is written onto that resource -- previously the one source running ON the host contributed nothing to the directory. The resource tree is collapsible, with state persisted per browser. DISCOVERY The Proxmox plugin zipped MACs and IPs from two flat lists by index, attributing addresses to the wrong NIC on multi-NIC guests. NICs are now keyed by MAC. Adds an endpoint resource parenting each node, sourceId/ vmid/node identity, container-interface filtering, node IP/MAC, and offline-node handling. The reconciler could make a resource its own parent, named hosts after their MAC address, had a dead isIp() regex (\\. matches a backslash), merged across kinds, and re-read the whole inventory per resource. Dockerfile.test-runner never copied nodejs/plugins, so every plugin test suite failed in CI as "Cannot find module". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+125
-8
@@ -10,6 +10,54 @@ The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) e
|
||||
|
||||
---
|
||||
|
||||
## Enrollment (required)
|
||||
|
||||
An agent is only real if the SSO issued its token. **Tokens the server did not
|
||||
issue are rejected** at the WebSocket handshake.
|
||||
|
||||
Enroll from **Directory → Install Agent**:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Or via the API:
|
||||
|
||||
```bash
|
||||
curl -X POST https://<SSO_HOST>/api/agent/enroll \
|
||||
-H "Authorization: Bearer <admin-api-token>" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name": "web01", "resourceId": "<host-resource-uuid>"}'
|
||||
```
|
||||
|
||||
The response contains `token` (once only) and `publicKey`.
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| :--- | :--- |
|
||||
| `GET /api/agent/nodes` | Every enrolled agent, connected or not, plus the server public key |
|
||||
| `POST /api/agent/enroll` | Mint an agent + token |
|
||||
| `PUT /api/agent/nodes/:id` | Rename, or bind/unbind the host resource |
|
||||
| `POST /api/agent/nodes/:id/rotate` | Issue a new token; the old one stops working immediately |
|
||||
| `POST /api/agent/nodes/:id/revoke` | Disable the enrollment |
|
||||
| `DELETE /api/agent/nodes/:id` | Remove the enrollment |
|
||||
| `POST /api/agent/nodes/:id/command` | Send a command (signed automatically when high-risk) |
|
||||
|
||||
Revoke, rotate and delete **drop any live connection immediately** — they do not
|
||||
wait for the agent to reconnect. Commands are addressed by agent **id**, never by
|
||||
token: a token is a credential and has no business in a URL or a log.
|
||||
|
||||
Enrollment, revocation, rotation, every command, and every rejected connection
|
||||
are written to the application log as structured `"component":"agent"` records
|
||||
with the acting user.
|
||||
|
||||
> **Lost the token?** It cannot be recovered — only its hash is stored. Rotate
|
||||
> the agent to issue a new one.
|
||||
|
||||
---
|
||||
|
||||
## Core Functionality
|
||||
|
||||
### 1. Host Discovery & Inventory
|
||||
@@ -41,12 +89,31 @@ Directory shows a status dot in the row:
|
||||
| :--- | :--- |
|
||||
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
|
||||
| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). |
|
||||
| **Red** | Not connected (no agent, or the agent is offline). |
|
||||
| **Red** | **Enrolled but not connected.** The agent exists and is expected — this is a fault. |
|
||||
| **Grey** | No agent enrolled for this host, the enrollment is revoked, or the agent service is unreachable. |
|
||||
|
||||
Red and grey used to be the same colour, which made an ordinary directory of
|
||||
hosts look like an outage. Because the enrollment now outlives the connection,
|
||||
"installed but down" is distinguishable from "never had an agent".
|
||||
|
||||
Opening a host's resource modal reveals a **Metrics** tab with the agent's live
|
||||
telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location).
|
||||
The agent is joined to its host by hostname (`agent.discovery.hostname` ↔ the
|
||||
resource name), so name the Directory host the same as the machine's hostname.
|
||||
|
||||
An agent attaches to its host by its **enrollment binding** (`resourceId`), set
|
||||
when you enroll it or later via `PUT /api/agent/nodes/:id`. Agents enrolled
|
||||
without a binding fall back to matching their reported hostname against the
|
||||
resource name — the old behaviour, kept only as a fallback, because it silently
|
||||
failed whenever a Directory name differed from the machine's hostname and
|
||||
aliased two hosts that happened to share one.
|
||||
|
||||
### Agent discovery feeds the Directory
|
||||
|
||||
A bound agent's discovery payload is written onto its host resource (`os`,
|
||||
`kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`), tagged with
|
||||
`discovery_sources: ["theta-agent"]` and an `agentId` back-reference. An agent
|
||||
runs *on* the host it describes, so it is the most authoritative source the
|
||||
directory has. An unbound agent goes through the normal discovery reconciler
|
||||
instead, matching like any other source.
|
||||
|
||||
---
|
||||
|
||||
@@ -64,14 +131,31 @@ To protect hosts against unauthorized control, `theta-agent` enforces a **strict
|
||||
|
||||
---
|
||||
|
||||
## High-Risk Command Verification (Protocol v1.1.0)
|
||||
## High-Risk Command Verification (Protocol v1.2.0)
|
||||
|
||||
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
|
||||
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace).
|
||||
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace,
|
||||
no HTML escaping, `signature` omitted).
|
||||
2. The payload is signed with the SSO Manager's Ed25519 private key.
|
||||
3. The Base64 signature is appended to the message payload.
|
||||
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
|
||||
|
||||
**The signing key is persistent.** It lives in OpenBao at
|
||||
`secret/agent/signing-key` and survives restarts, so the `public_key` you pin in
|
||||
`agent.yml` keeps matching. (It used to be generated in memory at boot and
|
||||
changed on every restart, which made pinning impossible.) If the SSO cannot load
|
||||
or store a key it **refuses** to send high-risk commands rather than signing with
|
||||
one no agent has seen — `GET /api/agent/nodes` reports this as
|
||||
`signingAvailable: false`.
|
||||
|
||||
This requires the `sso-broker` OpenBao policy to grant `secret/agent/*`. Re-run
|
||||
`./setup.sh` from theta-suite if you are upgrading.
|
||||
|
||||
**Verification is fail-closed on the agent.** An agent with no `public_key`
|
||||
configured rejects every high-risk command. Earlier versions logged "skipping
|
||||
signature verification" and executed them, so an agent installed without a key
|
||||
would run `reboot`, `configure_ldap` and `arbitrary_bash` unverified.
|
||||
|
||||
---
|
||||
|
||||
## Installation & Deployment
|
||||
@@ -80,9 +164,14 @@ High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `a
|
||||
Run the following command as `root` on the target Linux host:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- --url "https://<SSO_HOST>" --token "<HOST_TOKEN>"
|
||||
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
|
||||
--url "https://<SSO_HOST>" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
|
||||
```
|
||||
|
||||
Both values come from enrollment. The **Install Agent** modal builds this line
|
||||
for you with them already filled in. Omitting `--public-key` leaves the agent
|
||||
able to report telemetry but unable to accept any high-risk command.
|
||||
|
||||
### Custom Config Wizard
|
||||
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
|
||||
|
||||
@@ -97,9 +186,14 @@ curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE
|
||||
```yaml
|
||||
# /etc/theta42/agent.yml
|
||||
server_url: "wss://sso.example.com"
|
||||
auth_token: "your-unique-host-token"
|
||||
# Issued by the SSO at enrollment. A token the server did not issue is rejected.
|
||||
auth_token: "c8181ce0e55bf7302b11d719a7ae39adcd7604de461e6e363f8bb4fadf126acb"
|
||||
location: "dc-01-rack-12"
|
||||
public_key: "MCowBQYDK2VwAyEA..."
|
||||
# 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
|
||||
# SPKI blob is 44 bytes, the agent requires 32, and it will refuse every signed
|
||||
# command if this is wrong.
|
||||
public_key: "D0cJB3iuStTzhXlu7tFDh/eEXFxRZwkuwQJJhFSqwlQ="
|
||||
|
||||
capabilities:
|
||||
telemetry: true
|
||||
@@ -111,6 +205,29 @@ capabilities:
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting: agent is rejected (`close 4001`)
|
||||
|
||||
If the agent logs that the server rejected its token, the enrollment — not the
|
||||
network — is the problem. The SSO accepts the WebSocket upgrade and then closes
|
||||
with an application code:
|
||||
|
||||
| Code | Meaning | Fix |
|
||||
| :--- | :--- | :--- |
|
||||
| `4001` | Token unknown, or never issued by this server | Enroll the host and put the issued token in `agent.yml` |
|
||||
| `4002` | Superseded — another connection authenticated as this agent | Normal; two copies of the agent are running |
|
||||
| `4003` | Enrollment revoked or deleted | Re-enroll |
|
||||
| `4004` | Token rotated; `agent.yml` has the old value | Copy the new token |
|
||||
|
||||
The agent backs off for 5 minutes on `4001`/`4003`/`4004` rather than retrying
|
||||
every 5 seconds — a credential that is wrong will not fix itself, and hammering
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting: agent can't connect (`dial tcp ... i/o timeout`)
|
||||
|
||||
If the agent host logs `Dial error: dial tcp <ip>:443: i/o timeout` while
|
||||
|
||||
+22
-2
@@ -78,7 +78,19 @@ Requests are decided by the resource's `owner`, or by any directory admin. Mark
|
||||
|
||||
## Navigating the UI
|
||||
|
||||
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
|
||||
The Directory Management interface nests your resources as a tree, making it easy
|
||||
to comprehend your network topography at a glance. You can filter, search, and
|
||||
sort your entire infrastructure inventory. Click the green `+` icon next to any
|
||||
resource to add a child resource beneath it.
|
||||
|
||||
**Collapsing the tree.** Any resource with children carries a caret; click it to
|
||||
fold that subtree away. The toolbar's double-chevron buttons expand or collapse
|
||||
everything at once. Collapsed state is remembered per browser, so the shape you
|
||||
arrange survives a refresh (and the self-heal reload that follows most edits).
|
||||
|
||||
While a search filter is active every match is shown regardless of collapsed
|
||||
ancestors — otherwise searching for something inside a folded subtree would
|
||||
silently return nothing. Clearing the box restores your saved shape.
|
||||
|
||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
|
||||
|
||||
@@ -102,9 +114,17 @@ You don't have to build the graph by hand — the theta42 tooling registers itse
|
||||
|
||||
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
|
||||
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
||||
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
||||
- the **hosts** for the proxy and jump host (`host_theta-proxy`, `host_theta-jump`)
|
||||
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), OpenResty Edge (the 80/443 data plane), and the SSH Jump Host — each with its address, internal port, and git repo
|
||||
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||
|
||||
Services are parented to the host that actually runs them: Proxy and OpenResty
|
||||
Edge under `host_theta-proxy`, the SSH Jump Host under `host_theta-jump`, and the
|
||||
rest under the stack host. Installs seeded before this was fixed had all of them
|
||||
under the stack host, leaving the two purpose-made host resources childless; the
|
||||
seed re-parents those on its next run, and only when the current parent is the
|
||||
one the old code set, so a layout you arranged deliberately is left alone.
|
||||
|
||||
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
||||
|
||||
### Linux hosts (ldap-client)
|
||||
|
||||
@@ -23,6 +23,44 @@ filename basename (without `.js`) is the `type`; the parent directory is the
|
||||
- `unifi` — UniFi Network controller (URL + username/password)
|
||||
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||
|
||||
### What the Proxmox plugin produces
|
||||
|
||||
One endpoint becomes one subtree:
|
||||
|
||||
```
|
||||
Proxmox endpoint (cluster name, or the endpoint hostname)
|
||||
└── node (hypervisor)
|
||||
├── VM / template
|
||||
└── LXC / template
|
||||
```
|
||||
|
||||
The endpoint resource stands for the cluster, not a machine, so it carries the
|
||||
API URL and a `sourceId` but deliberately no IP — giving it the address it is
|
||||
reached at made the reconciler merge it with the node answering on that address,
|
||||
which produced a resource that was its own parent.
|
||||
|
||||
Every guest carries:
|
||||
|
||||
- `interfaces[]` — one entry per NIC with its own `mac`, `ip`/`ips` and `name`.
|
||||
The MAC and the address on it are read from the same source, so they cannot be
|
||||
mismatched (an earlier version collected MACs and IPs into two flat lists and
|
||||
zipped them by index, which attributed addresses to the wrong NIC on any
|
||||
multi-NIC guest).
|
||||
- `macAddress` / `ip` — the primary NIC's values, preferring one that actually
|
||||
has an address.
|
||||
- `vmid`, `node` and `sourceId` (`<node>/qemu/<vmid>` or `<node>/lxc/<vmid>`), so
|
||||
a directory row traces back to the exact guest on the exact node.
|
||||
|
||||
Interfaces belonging to something running *inside* a guest — `docker0`, `veth*`,
|
||||
`br-*`, VPN tunnels — are filtered out. They are not NICs of the host, and their
|
||||
172.x addresses would otherwise give the reconciler spurious matches.
|
||||
|
||||
A stopped VM still reports its MAC (read from the VM config rather than the
|
||||
guest agent), and a DHCP-configured LXC gets its address from the running
|
||||
container's interface list. Offline nodes are recorded with `status` rather than
|
||||
skipped, so a hypervisor that is down does not look decommissioned and get
|
||||
garbage-collected after a week.
|
||||
|
||||
A module exports a **manifest**:
|
||||
|
||||
```javascript
|
||||
|
||||
Reference in New Issue
Block a user