feat: agent join keys; fix directory collapse, plugin edit/delete, docs (v1.30.0)
JOIN KEYS v1.29.0 required an admin to pre-register every machine before its agent would be spoken to. The security model was right; the workflow was not -- installing the agent should be enough to add a host. POST /api/agent/join-keys mints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued its OWN per-agent token plus the public key it must pin, delivered in the config frame. The join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable. DIRECTORY Collapsing the tree did nothing. applyTreeCollapse found the caret with `.tree-caret i` and returned early when absent -- Font Awesome's SVG mode rewrites <i> to <svg>, so that selector matched nothing and the early return skipped setting hideBelowDepth. State now lives on the caret button and is rotated by CSS. The Discovery Plugins delete button called deleteDiscoveryPlugin(), which was never defined. The pane also had no .actionMessage, and confirmations render into one -- without it the promise never settles, so an awaited confirmation hangs forever and the action silently never happens. Plugin instances can now be edited. DISCOVERY A fresh install presented its own five containers as unmanaged discoveries. The Docker plugin now recognises the stack's compose project and attaches each container to the service it implements. Container slugs came from the container id, which changes on recreate, so every deploy minted a new resource and orphaned the old one. DOCS /docs/discovery 404'd (no slug entry) and `agents` pointed at plugins.md, leaving docs/agents.md unreachable. Adds docs/discovery.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+53
-8
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
layout: default
|
||||
title: Discovery & Inventory
|
||||
nav_order: 6
|
||||
---
|
||||
|
||||
# Discovery & Inventory
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user