Implements protocol v1.2.0. verifySignature() returned true when no public_key was configured, logging "skipping signature verification". The SSO installer never wrote a public_key, so a default install executed reboot, service_restart, configure_ldap, arbitrary_bash and update_binary UNVERIFIED from anything that could reach its socket. An agent that cannot verify now refuses. Canonicalization also disagreed with the server. Go's encoding/json escapes <, > and & by default; JSON.stringify does not. Any payload containing them hashed differently on each side and failed verification -- for arbitrary_bash that is most real scripts (`>` redirection, `&&`). Now uses json.Encoder with SetEscapeHTML(false), trailing newline trimmed. The SSO now rejects tokens it did not issue. Handles its close codes (4001/4002/4003/4004) and backs off 5 minutes on an enrollment failure instead of retrying every 5s forever. The connect log no longer prints the URL, which carried ?token=. install.sh gains --public-key and warns when none is configured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.9 KiB
Theta Agent Protocol Specification (v1.2.0)
This document defines the communication protocol between the theta-agent (Client) and the sso-manager (Server).
1. Connection Establishment
The agent establishes a persistent outbound WebSocket connection.
- Endpoint:
wss://<manager-url>/api/agent/ws - Authentication: The agent must provide its enrollment token as a query parameter:
wss://<manager-url>/api/agent/ws?token=<AGENT_TOKEN>
1.1 Enrollment (changed in v1.2.0)
The token must be issued by the server. An administrator enrolls the agent in
the SSO (Directory → Agents, or POST /api/agent/enroll), which mints the token,
stores only its SHA-256, and displays the raw value once. That value goes into
auth_token in agent.yml.
Up to v1.1.0 the token was generated in the browser and never recorded
server-side, so the server accepted any string: anyone who could reach
/api/agent/ws could register as a node, publish discovery/telemetry, and
receive commands addressed to a token they guessed. Tokens the server did not
issue are now rejected.
The server accepts the WebSocket upgrade before authenticating, so an authentication failure arrives as a close frame, not an HTTP status:
| Code | Meaning | Agent behaviour |
|---|---|---|
4001 |
Token unknown, or not issued by this server | Back off (5 min); the credential will not fix itself |
4002 |
Superseded — another connection authenticated as this agent | Normal reconnect |
4003 |
Enrollment revoked or deleted by an administrator | Back off (5 min) |
4004 |
Token rotated — agent.yml holds the superseded value |
Back off (5 min); re-copy the token |
Revocation and rotation both drop any live socket immediately, so they take effect without waiting for the agent to reconnect.
2. Message Format
All messages are exchanged as JSON objects following the WSMessage structure.
{
"type": "string",
"payload": {
"key": "value"
}
}
3. Client \rightarrow Server Messages
3.1 Discovery (One-time & On-Change)
Sent immediately upon connection and whenever the agent detects a change in its own network IP addresses.
- Type:
discovery - Payload:
hostname: (string) System hostname.ip_addresses: (array of strings) List of all non-loopback IPv4 addresses.os: (string) OS and Platform.kernel: (string) Kernel version.cpu: (string) CPU model.ram_total_gb: (float) Total system RAM in GB.disk_total_gb: (float) Total root disk capacity in GB.location: (string) Physical location from config.
3.2 Telemetry (Periodic)
Sent every 30 seconds.
- Type:
telemetry - Payload:
cpu_usage_percent: (float) Current CPU load.ram_usage_percent: (float) Current RAM utilization.disk_usage_percent: (float) Current root disk utilization.zfs_health: (string) Primary ZFS pool status (e.g., "ONLINE").gpu_usage_percent: (float) Average NVIDIA GPU utilization (-1.0 if unavailable).timestamp: (string) RFC3339 timestamp.
3.3 Heartbeat (Periodic)
Sent every 60 seconds to maintain the connection and signal health.
- Type:
heartbeat - Payload:
timestamp: (string) RFC3339 timestamp.
3.4 Command Response
Sent in response to any command received from the server.
- Type:
response(Implicitly handled as the answer to a command) - Payload:
status: (string) Either"ok"or"error".message: (string) Human-readable result or error description.output: (string, optional) Stdout/stderr for execution commands.
4. Server \rightarrow Client Messages
4.1 Standard Commands
These commands are executed if the corresponding capability is enabled in agent.yml.
| Command | Payload | Effect |
|---|---|---|
reload_config |
{} |
Agent re-reads /etc/theta42/agent.yml from disk. |
fetch_logs |
{} |
Agent returns the last 100 lines of journalctl -u theta-agent. |
4.2 High-Risk Commands (Signed)
These commands require an Ed25519 signature in the payload. The agent verifies the signature against the public_key in its config.
Signature Format:
- The
signaturefield contains the base64-encoded Ed25519 signature of the payload (with thesignaturekey removed).
| Command | Payload | Effect |
|---|---|---|
reboot |
{ "signature": "..." } |
Triggers system reboot. |
service_restart |
{ "service": "...", "signature": "..." } |
Restarts specific systemd service. |
configure_ldap |
{ "config": "...", "signature": "..." } |
Writes /etc/sssd/sssd.conf and restarts sssd. |
arbitrary_bash |
{ "script": "...", "signature": "..." } |
Executes raw bash script. |
update_binary |
{ "url": "...", "sha256": "...", "signature": "..." } |
Downloads, verifies, and replaces the agent binary. |
5. Cryptographic Verification Process
To send a high-risk command:
- Create the payload (e.g.,
{"script": "uptime"}). - Canonicalize the JSON (see 5.1).
- Sign the canonical bytes using the private Ed25519 key.
- Add the base64 signature to the payload:
{"script": "uptime", "signature": "..."}. - Send as a
WSMessage.
The agent performs the reverse process to verify authenticity before execution.
5.1 Canonical form
Both sides must produce byte-identical input to sign/verify:
- keys sorted alphabetically
- no insignificant whitespace
- the
signaturekey omitted - no HTML escaping —
<,>and&are emitted literally - no trailing newline
The escaping rule is load-bearing. Go's encoding/json escapes those three
characters by default while JavaScript's JSON.stringify does not, so a payload
containing any of them hashed differently on each side and verification failed.
For arbitrary_bash that is most real scripts (> redirection, &&). The Go
client uses json.Encoder with SetEscapeHTML(false).
Example — payload {"script": "echo a > b && c", "comment": "x&y"} canonicalizes to:
{"comment":"x&y","script":"echo a > b && c"}
5.2 The server signing key (changed in v1.2.0)
The server's Ed25519 key pair is persistent, stored in OpenBao at
secret/agent/signing-key. public_key in agent.yml is the base64-encoded raw
32-byte public key, available from the enrollment response or
GET /api/agent/nodes.
Previously the pair was generated in memory at process start, so it changed on every restart and no agent could meaningfully pin it. If the server cannot load or persist a key it now refuses to send high-risk commands rather than signing with a key no agent has seen.
5.3 Agent-side verification is fail-closed (changed in v1.2.0)
An agent with no public_key configured rejects every high-risk command.
Until v1.1.0 it logged "skipping signature verification" and executed them,
which meant an agent installed without a key would run reboot,
configure_ldap and arbitrary_bash from anything that reached its socket.