Merge release branch release-v1.7.0

This commit is contained in:
2026-08-08 15:38:53 -04:00
31 changed files with 2972 additions and 91 deletions
+5
View File
@@ -0,0 +1,5 @@
# Binaries and build output
theta-agent
theta-agent-*
dist/
*.exe
+114
View File
@@ -5,6 +5,120 @@ All notable changes to the `theta-agent` daemon will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v1.7.0] - 2026-08-08
### Added
- **Multi-Architecture & Multi-OS Binaries.** Built cross-platform targets for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon M1/M2/M3/M4).
- **Cross-Compilation Pipeline (`build_all.sh`).** Automated Go build toolchain generating static binaries for all 7 target platforms.
- **Installer OS & Architecture Auto-Detection.** Updated `install.sh` to auto-detect `uname -s` and `uname -m` to download matching release binaries.
### Fixed
- **Systemd & Docker Command Dispatching.** Handled systemd actions (`start`, `stop`, `restart`, `reload`) and Docker container metrics/actions cleanly across Linux distributions.
## [v1.6.0] - 2026-08-07
### Added
- **On-demand CLI Secret Fetching (`theta-agent get-secret <key>`).** Fetch raw secret values directly over TLS without writing plaintext files to disk. Supports `theta-agent get-secrets --env` (formatted for Systemd `EnvironmentFile`) and `theta-agent get-secrets --json`.
- **CLI Self-Update and Re-enrollment Commands.** Added `theta-agent update` and `theta-agent reinitialize [--join-key <key>]` CLI options with automated service restarts (`sssd`, `sshd`).
- **Zero-Trust LDAP WebSocket Tunnel (`ldap_tunnel`).** Auto-starts local `/run/theta/ldap.sock` and `127.0.0.1:3890` loopback listeners.
- **Dynamic Site Matching.** Auto-detects WAN IP for public site matching and discovery.
### Fixed
- **SSSD Socket Activation Exit Code 17.** Removed legacy `services` key in generated `sssd.conf` to satisfy modern systemd socket activation requirements.
## [Unreleased] - LDAP byte-pump tunnel (DESIGN.md §4)
The agent now serves a local LDAP socket for SSSD/PAM. It is a **pure byte
pump**: it forwards raw LDAP bytes to the SSO over the WSS channel, and the SSO
relays them into its real OpenLDAP and pipes the response back. The agent never
parses LDAP.
### Added
- **`ldap_tunnel` capability + `ldap_socket` config.** When enabled, the agent
binds a unix socket (default `/run/theta/ldap.sock`, root:theta `0660`) and
relays bytes bidirectionally as `ldap_tunnel` messages over the existing WSS
channel. Point SSSD at it with `ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock`.
- **`safeWriter`** — serializes WebSocket writes. Gorilla allows only one
concurrent writer, but telemetry, heartbeat, the LDAP tunnel and command
responses all write to the same socket; without this, concurrent writes
corrupt the stream.
- **Offline behavior:** when the WSS is down the agent cannot forward bytes, so
it closes local socket connections; SSSD sees a connection failure and falls
back to its local cache.
### Added — secrets engine (DESIGN.md §5)
- **`secrets` capability + `secrets` config.** The agent renders local templates
that embed OpenBao secrets (`{{ bao "secret/data/nodes/<id>/<name>#<key>" }}`).
It parses the placeholders, fetches the values from the SSO (which holds the
OpenBao access; the agent never holds a Vault token), renders each target
atomically at `0600`, and runs the configured reload. Triggered by a signed
`render_secrets` command.
### Added — capability reporting
- **The agent reports its enabled capabilities in its `discovery` frame.** The
SSO stores them and the Directory UI shows them as badges on the host's Metrics
tab, so an operator can see at a glance what an agent is allowed to do
(telemetry, LDAP tunnel, secrets, IAM, reboot, bash, service control).
### Added — IAM engine (DESIGN.md §6)
- **`iam` capability.** The SSO pushes node-scoped identity config as a signed
`iam_apply` command; the agent verifies the Ed25519 signature (fail-closed) and
applies it locally:
- **Sudo rules** — writes `/etc/sudoers.d/theta-iam-<node_id>`, validates with
`visudo -c`, atomic swap.
- **SSH keys** — stores per-user keys and installs the `AuthorizedKeysCommand`
script (`/usr/local/bin/theta-authorized-keys`) that sshd calls per login.
- **Access control** — writes `/etc/security/access.conf` with allowed login
groups.
- **Revocation** — flushes the SSSD cache (`sss_cache -E`) and drops active
sessions (`pkill -u`) for revoked users.
## [v1.5.1] - 2026-08-06
### Fixed
- **Rebuilt the prebuilt `theta-agent-linux-amd64`.** theta-suite's `setup.sh` installs that committed binary rather than building from source, so a stale one means the fix in this repo never reaches the host. The v1.5.0 binary predated join-key support: an install would have written a `join_key` into `agent.yml` that the running agent did not understand, and it would have looped on `close 4001: Unauthorized`. (Same trap as the v1.3.0 heartbeat fix.)
## [v1.5.0] - 2026-08-06
Join-key enrollment (protocol v1.2.0 §1.1). Installing the agent with one key is now all it takes to add a host.
### Added
- **`join_key` config field.** Presented while `auth_token` is empty. The SSO exchanges it for this agent's own token and the public key it must pin, both delivered in the `config` frame; the agent writes them into `agent.yml` and blanks the join key. No value has to be copied between two machines by hand any more.
- `ConfigManager.PersistEnrollment` rewrites only the credential lines, line-based rather than a YAML round-trip, so operator comments, the capability matrix and formatting survive. Re-reads the file afterwards, so the new credential is live without a restart, and keeps the file at `0600`.
- `Config.Credential()` — the agent's own token when it has one, otherwise the join key.
- The connect URL carries `?hostname=`, so a self-enrolling host is named after itself instead of a generated placeholder.
- `install.sh --join-key`.
### Fixed
- The agent now refuses to connect (with a clear message and a long back-off) when it has neither an `auth_token` nor a `join_key`, rather than repeatedly presenting an empty credential.
## [v1.4.0] - 2026-08-05
Implements **Protocol v1.2.0**. See `PROTOCOL.md` §1.1, §5.15.3.
### Security
- **Fail-closed signature verification.** `verifySignature` returned `true` when no `public_key` was configured, logging "skipping signature verification". Combined with an installer that never wrote a `public_key`, that meant a default install would execute `reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash` and `update_binary` **unverified** from anything that could reach its socket. An agent that cannot verify a high-risk command now refuses it.
- **The token must be issued by the server.** The SSO now rejects tokens it did not mint (close code `4001`). Agents carrying a token generated by the old browser-side installer will not connect until re-enrolled.
### Fixed
- **Canonicalization mismatch broke signatures for most real scripts.** Go's `encoding/json` escapes `<`, `>` and `&` by default; the server's `JSON.stringify` does not. Any payload containing them — an `arbitrary_bash` script using `>` redirection or `&&`, which is most of them — hashed differently on each side and failed verification. `canonicalize()` now uses `json.Encoder` with `SetEscapeHTML(false)` and trims the encoder's trailing newline.
- **Auth failures no longer hot-loop.** A rejected credential was retried every 5 seconds forever, flooding the SSO and its audit log. Close codes `4001`/`4003`/`4004` now back off for 5 minutes and log what to do about it.
- **The auth token no longer appears in logs.** The connect line logged the full URL, including `?token=...`. It now logs only host + path, and the token is URL-escaped.
### Added
- Close-code handling for the SSO's enrollment signals: `4001` unauthorized, `4002` superseded, `4003` revoked, `4004` token rotated.
- `install.sh --public-key <base64>`, written into the generated `agent.yml`. The installer warns loudly when no public key is configured, since such an agent can report telemetry but will refuse every high-risk command.
- Tests: fail-closed with no key, wrong key, payload tampered after signing, shell metacharacters (`>`, `&&`, `<`) round-tripping, and canonical-form equality with the server. `interop_check_test.go` verifies a signature produced by the live SSO against the agent's own verifier (skipped unless `INTEROP_FIXTURE` is set).
### Changed
- Existing tests no longer rely on verification being skipped; high-risk cases now sign with a real test key.
- `agent.yml.example`, `README.md`, `INSTALL.md`: enrollment is a prerequisite, and `public_key` is the base64 of the **raw 32-byte** Ed25519 key — not a PEM body. The previous documented example (`MCowBQYDK2VwAyEA...`) decodes to 44 bytes and would have been rejected.
## [v1.3.0] - 2026-08-04
### Fixed
- **Silently ignore `heartbeat_ack`** — the server replies to the agent's own periodic heartbeat with `heartbeat_ack`. The agent had no case for it, so it fell through to the unknown-command handler, logged `Unknown command type: heartbeat_ack` every minute, and answered with a spurious error response. Heartbeat acks are fire-and-forget; the agent now ignores them silently.
## [v1.2.0] - 2026-08-03 ## [v1.2.0] - 2026-08-03
### Added ### Added
+211
View File
@@ -0,0 +1,211 @@
# theta-agent v2 — Simplified Architecture (LDAP-over-HTTPS)
**Status:** Draft for review · **Supersedes:** the WebRTC/SCTP spec (WebRTC dropped)
This document defines the v2 architecture for `theta-agent`. It replaces the
earlier WebRTC/SCTP design with a strictly simpler model: **one agent per node,
one outbound WSS channel, and the agent provides local LDAP + secrets + IAM.**
The core problem it solves is the original ask — *LDAP binds are painful across
hostnames, networks, and TLS cert chains* — by making the client stop speaking
LDAP and instead do an HTTPS call to the SSO, where the directory is reachable.
---
## 1. Overview
```
APP (no agent) SSO
HTTPS POST /ldap/bind ──────────────► [LDAP-over-HTTPS API] ──► OpenLDAP
HTTPS POST /ldap/search ───────────► ▲
│ raw bytes
NODE (with theta-agent) │
SSSD ──► /run/theta/ldap.sock ──► agent ──► WSS (ldap_tunnel) ─┘
secrets: /etc/theta/templates/*.tpl ──► agent ──► HTTPS ──► OpenBao
IAM: sudoers.d / authorized_keys ──► agent ◄── WSS ◄── IAM engine
```
Two ways to reach the directory, both simple:
- **Apps (no agent):** call the HTTPS LDAP API directly — one bind/search
contract, no LDAP protocol.
- **Nodes (with agent):** the agent is a **pure byte pump** — it forwards raw
LDAP bytes from a local socket to the SSO, which relays them into OpenLDAP.
The agent never parses LDAP.
---
## 2. Transport
- **Single transport: persistent outbound WebSocket (WSS) over TCP 443.** No
WebRTC, no SCTP, no UDP, no ICE, no fallback logic. The agent dials out; the
SSO never needs an inbound port.
- **Authentication:** the existing token / join-key enrollment model carries
over unchanged (see `PROTOCOL.md` §1.1). The agent presents its per-agent
token; the SSO rejects anything it did not issue.
- **Message framing:** the existing `WSMessage` JSON envelope (`type` + `payload`)
carries all traffic. Message types are extended for LDAP, secrets, and IAM
(below). No binary framing, no stream multiplexing. The LDAP tunnel carries
raw bytes as base64 in `ldap_tunnel` messages (see §4).
---
## 3. LDAP-over-HTTPS API (SSO side)
A small service (or routes in the existing proxy/sso) that performs the real
LDAP operation against OpenLDAP, which is reachable from the SSO network. This
is the pinepain/ldap-auth-proxy model: the client never speaks LDAP.
| Endpoint | Request | Response |
| :--- | :--- | :--- |
| `POST /api/v1/ldap/bind` | `{username, password}` | `200 {dn, attributes}` or `401` |
| `POST /api/v1/ldap/search` | `{base_dn, scope, filter, attributes}` | `200 {entries: [...]}` |
- **bind** performs a real LDAP bind server-side and returns the bound DN and
identity attributes.
- **search** runs a real LDAP search server-side and returns entries.
- **Authorization:** the API authorizes the *caller* (an app, or an agent acting
for a node). OpenLDAP enforces the actual directory ACLs. This resolves the
original spec's contradiction — we do not parse BER to enforce per-operation
policy; the directory does.
### 3.1 Consumers
- **Apps/services:** call the API directly over HTTPS. No LDAP hostname, no
LDAPS cert chain, no cross-network LDAP firewall rule.
- **theta-agent:** does **not** use this API. It tunnels raw LDAP bytes to the
SSO's OpenLDAP over the WSS channel instead (see §4) — a byte pump, not a
translation. The HTTPS API is for apps that have no agent on their network.
---
## 4. Agent local LDAP socket — a pure byte pump (for SSSD/PAM)
The agent provides a local LDAP endpoint so SSSD/PAM on the node can
authenticate without direct LDAP connectivity. **The agent does not speak LDAP
at all.** It is a dumb byte pump: whatever bytes land on the local socket are
forwarded to the SSO, which relays them into its real OpenLDAP and pipes the
response back.
```
SSSD ──► /run/theta/ldap.sock ──► agent ──► WSS (ldap_tunnel) ──► SSO ──► OpenLDAP
◄───────────────────────────────────────────────────────────────────────◄
```
- **Socket:** a **Unix domain socket** at `/run/theta/ldap.sock`, owned by root,
mode `0660` (root + theta group). A unix socket is preferred over
`127.0.0.1:389` because filesystem permissions restrict *which local processes*
can connect — any process can reach a TCP port, only root/theta can reach the
socket.
- **Tunnel framing:** each local connection gets a `conn_id`. Bytes are carried
over the existing WSS channel as `ldap_tunnel` messages:
`{type:"ldap_tunnel", payload:{conn_id, data:<base64>, close:bool}}`. The
agent reads the socket and sends chunks up; the SSO relays them into OpenLDAP
and sends OpenLDAP's response chunks back down; the agent writes them to the
socket. `close:true` ends a connection.
- **SSSD config:** `ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock`, StartTLS off
(the transport to the SSO is already TLS/WSS; the local hop is plaintext on a
root-owned socket).
### 4.1 Offline boot handling (validated)
If the agent cannot reach the SSO (WSS down), it cannot forward bytes, so it
closes local socket connections. SSSD sees a connection failure and falls back
to its local cache. **This has been validated against real SSSD behavior:** any
user/group seen in the last N days can log in offline — a laptop user can log in
on the road, and an admin can still reach a broken service host to fix it.
### 4.2 Open question — `ldapi://` support
SSSD's `ldap_uri` accepts `ldapi://` unix-socket URIs on modern versions, but
this must be confirmed on the target SSSD build. If unsupported, fall back to
`127.0.0.1:389` with a local firewall rule restricting the port to loopback.
---
## 5. Secrets engine
The agent renders OpenBao secrets to local files, reusing the existing
`@simpleworkjs/bao-conf` patterns rather than inventing a parallel mechanism.
- **Templates:** `/etc/theta/templates/*.tpl` declare the secrets a service
needs, e.g. `DB_PASS="{{ bao "secret/data/nodes/node-42/db#password" }}"`.
- **Flow:** the agent requests the secret paths over the WSS channel → the SSO
fetches from OpenBao (node-scoped to `/secret/data/nodes/${NODE_ID}/*`) →
the agent renders the file **atomically** (write temp + rename) with mode
`0600` → runs the configured post-render action (`systemctl reload <svc>`).
- **Rotation:** on a rotation/invalidation event pushed down the channel, the
agent re-fetches, re-renders, and reloads.
- **Note:** OpenBao KV v2 secrets have no leases; "renewal" is a re-read on
invalidation, not a lease renewal. (Dynamic secrets, if used, are a separate
path.)
---
## 6. IAM engine
The SSO pushes node-scoped identity config down the WSS channel; the agent
applies it locally.
- **Sudo rules:** write `/etc/sudoers.d/theta-iam-<node_id>`, run `visudo -c`,
and atomically swap on success.
- **SSH keys:** sync user public keys via `AuthorizedKeysCommand` (the agent
implements the command the SSH daemon calls per login) — *not* a non-standard
`/etc/ssh/authorized_keys.d/` directory.
- **Access control:** configure SSSD / `/etc/security/access.conf` for allowed
login groups.
- **Revocation:** on account disable / group drop, flush local SSSD caches and
drop active sessions for the affected user (mechanism TBD — `loginctl` vs
`pkill -u`; high-risk, needs a defined trigger/event model).
### 6.1 Security — signed IAM payloads
Sudo rules and SSH keys grant root-equivalent access, so **every IAM push is
Ed25519-signed** using the existing signature model (`PROTOCOL.md` §5). The agent
verifies the signature against its pinned `public_key` before applying anything.
Unsigned or invalid IAM payloads are rejected fail-closed.
---
## 7. Security model
- **Fail-closed, capability-matrix philosophy carries over** from v1: the agent
only applies what its local config permits; the SSO cannot override local
settings.
- **Local socket auth:** only root/theta can reach `/run/theta/ldap.sock`.
- **Signed high-risk operations:** IAM pushes (and any new high-risk command)
require the Ed25519 signature.
- **Node-scoped secrets:** OpenBao access is restricted to the node's own path
prefix.
- **Blast radius:** the agent runs as root; the unix socket + signature model +
node-scoped secrets contain the damage if the agent is compromised.
---
## 8. What this drops from the original spec
- WebRTC / SCTP / DTLS / UDP / ICE — **gone**, WSS only.
- Three SCTP streams — **replaced** by one WSS channel with message types.
- The "node-scoped authorization" contradiction — **resolved**: the API
authorizes the caller, OpenLDAP enforces ACLs.
- LDAP parsing in the agent — **gone**. The agent is a byte pump; it never
parses LDAP. The SSO relays raw bytes into its real OpenLDAP.
---
## 9. Open questions / verification items
1. **SSSD `ldapi://` unix-socket support** on the target build (§4.2).
2. **Revocation mechanism** — implemented as `sss_cache -E` + `pkill -u <user>`
(§6). The event model (what triggers a push) is still to be wired into the
SSO UI/engine.
3. **`AuthorizedKeysCommand`** — implemented: the agent installs
`/usr/local/bin/theta-authorized-keys` which cats the user's key file
(`/etc/theta/authorized_keys/<user>`). sshd must be configured with
`AuthorizedKeysCommand /usr/local/bin/theta-authorized-keys %u` (§6).
4. **Versioning/migration** — is v2 a replacement for v1, or a parallel mode?
The existing `/api/agent/ws` vs the new `/api/v1/ldap/*` paths need a story.
5. **SSO relay target** — the SSO relays tunnel bytes into its local OpenLDAP
(slapd). The target address comes from `conf.ldap.url`; confirm it is a
plaintext LDAP port reachable from the SSO process (§4).
6. **Secrets node scope** — the agent's node scope is its agent id
(`secret/data/nodes/<agent-id>/*`). Confirm this matches how node secrets are
provisioned in OpenBao (§5).
+14 -1
View File
@@ -2,6 +2,18 @@
Theta Agent is designed for rapid deployment across the fleet. The recommended method is via the "One-Liner" install, which marries the agent to a specific SSO Manager instance. Theta Agent is designed for rapid deployment across the fleet. The recommended method is via the "One-Liner" install, which marries the agent to a specific SSO Manager instance.
## Prerequisite: enroll the host
The agent's token is issued by the SSO, not chosen by you. In the SSO open
**Directory → Install Agent**, name the host, bind it to a host resource, and
press **Enroll & issue token**. You get:
- the **agent token** — shown once; only its hash is stored
- the **SSO public key** — pinned by the agent to verify high-risk commands
The modal builds the install command below with both already filled in. A token
the SSO did not issue is rejected at connect time with close code `4001`.
## Quick Start (The One-Liner) ## Quick Start (The One-Liner)
The SSO Manager provides a pre-generated installation command. Copy and paste it into your terminal as root: The SSO Manager provides a pre-generated installation command. Copy and paste it into your terminal as root:
@@ -15,7 +27,8 @@ curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- "
### Option B: Minimal Setup ### Option B: Minimal Setup
Use this for rapid deployment with basic telemetry: Use this for rapid deployment with basic telemetry:
```bash ```bash
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- --url "https://sso.example.com" --token "your-host-token" curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- \
--url "https://sso.example.com" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
``` ```
### What this does: ### What this does:
+126 -4
View File
@@ -1,4 +1,4 @@
# Theta Agent Protocol Specification (v1.1.0) # Theta Agent Protocol Specification (v1.2.0)
This document defines the communication protocol between the `theta-agent` (Client) and the `sso-manager` (Server). This document defines the communication protocol between the `theta-agent` (Client) and the `sso-manager` (Server).
@@ -7,8 +7,55 @@ This document defines the communication protocol between the `theta-agent` (Clie
The agent establishes a persistent outbound WebSocket connection. The agent establishes a persistent outbound WebSocket connection.
- **Endpoint**: `wss://<manager-url>/api/agent/ws` - **Endpoint**: `wss://<manager-url>/api/agent/ws`
- **Authentication**: The agent must provide a unique host token as a query parameter: - **Authentication**: The agent must provide its enrollment token as a query parameter:
- `wss://<manager-url>/api/agent/ws?token=<HOST_TOKEN>` - `wss://<manager-url>/api/agent/ws?token=<AGENT_TOKEN>`
### 1.1 Enrollment (changed in v1.2.0)
Two credentials can appear in `agent.yml`. The agent presents `auth_token` when
it has one, otherwise `join_key`:
| Field | Meaning |
| :--- | :--- |
| `auth_token` | This agent's own token, issued by the server. Long-term identity. |
| `join_key` | Bootstrap credential (`tjk_…`), exchanged for an `auth_token` on first connect. |
**Join-key flow.** The agent connects presenting a join key and
`?hostname=<its hostname>`. The server enrolls the host and answers with a
`config` frame carrying `enrolled: true`, `auth_token` and `public_key`. The
agent writes both into `agent.yml`, blanks `join_key`, and uses its own token
from then on. This is what makes "install the agent with a key" sufficient to
add a host — no value has to be copied between two machines by hand.
The public key is accepted on first connect (trust on first use) over the same
channel that issued the token. Pre-register the host instead if you need the
trust anchor pinned out of band.
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` | Credential unknown — neither an issued token nor a valid join key | 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 ## 2. Message Format
@@ -23,6 +70,24 @@ All messages are exchanged as JSON objects following the `WSMessage` structure.
} }
``` ```
### 2.1 `ldap_tunnel` — the LDAP byte pump (DESIGN.md §4)
The agent serves a local LDAP socket for SSSD/PAM. It is a **pure byte pump**:
the agent forwards raw LDAP bytes to the SSO, which relays them into its real
OpenLDAP and pipes the response back. Neither side parses LDAP.
- **Type**: `ldap_tunnel` (bidirectional — sent by both agent and SSO)
- **Payload**:
- `conn_id`: (string) correlates one local LDAP connection.
- `data`: (string, optional) base64-encoded raw LDAP bytes.
- `close`: (bool, optional) ends the connection.
The agent reads its local socket and sends `data` chunks up; the SSO relays them
into OpenLDAP and sends OpenLDAP's response chunks back down; the agent writes
them to the socket. `close:true` ends a connection. When the WSS is down the
agent cannot forward bytes, so it closes local socket connections and SSSD falls
back to its local cache.
## 3. Client $\rightarrow$ Server Messages ## 3. Client $\rightarrow$ Server Messages
### 3.1 Discovery (One-time & On-Change) ### 3.1 Discovery (One-time & On-Change)
@@ -71,6 +136,20 @@ Sent in response to any command received from the server.
## 4. Server $\rightarrow$ Client Messages ## 4. Server $\rightarrow$ Client Messages
### 4.0 `config`
Sent immediately on a successful connection.
- **Type**: `config`
- **Payload**:
- `message`: (string) human-readable greeting.
- `protocol_version`: (string) the server's protocol version.
- `agent_id`: (string) this agent's id in the SSO.
- `enrolled`: (bool, optional) present and `true` only when this connection
just enrolled via a join key.
- `auth_token`: (string, optional) the issued per-agent token — **persist it**.
- `public_key`: (string, optional) the key to pin — **persist it**.
### 4.1 Standard Commands ### 4.1 Standard Commands
These commands are executed if the corresponding capability is enabled in `agent.yml`. These commands are executed if the corresponding capability is enabled in `agent.yml`.
@@ -92,14 +171,57 @@ These commands **require** an Ed25519 signature in the payload. The agent verifi
| `configure_ldap` | `{ "config": "...", "signature": "..." }` | Writes `/etc/sssd/sssd.conf` and restarts `sssd`. | | `configure_ldap` | `{ "config": "...", "signature": "..." }` | Writes `/etc/sssd/sssd.conf` and restarts `sssd`. |
| `arbitrary_bash` | `{ "script": "...", "signature": "..." }` | Executes raw bash script. | | `arbitrary_bash` | `{ "script": "...", "signature": "..." }` | Executes raw bash script. |
| `update_binary` | `{ "url": "...", "sha256": "...", "signature": "..." }` | Downloads, verifies, and replaces the agent binary. | | `update_binary` | `{ "url": "...", "sha256": "...", "signature": "..." }` | Downloads, verifies, and replaces the agent binary. |
| `render_secrets` | `{ "signature": "..." }` | Renders the configured secret templates to their targets (DESIGN.md §5). |
| `iam_apply` | `{ "node_id", "revision", "access_control", "signature" }` | Applies node IAM: sudo rules, SSH keys, access control, revocation (DESIGN.md §6). |
## 5. Cryptographic Verification Process ## 5. Cryptographic Verification Process
To send a high-risk command: To send a high-risk command:
1. Create the payload (e.g., `{"script": "uptime"}`). 1. Create the payload (e.g., `{"script": "uptime"}`).
2. Canonicalize the JSON (sort keys alphabetically, remove whitespace). 2. Canonicalize the JSON (see 5.1).
3. Sign the canonical bytes using the private Ed25519 key. 3. Sign the canonical bytes using the private Ed25519 key.
4. Add the base64 signature to the payload: `{"script": "uptime", "signature": "..."}`. 4. Add the base64 signature to the payload: `{"script": "uptime", "signature": "..."}`.
5. Send as a `WSMessage`. 5. Send as a `WSMessage`.
The agent performs the reverse process to verify authenticity before execution. 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 `signature` key 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.
+40 -2
View File
@@ -4,6 +4,17 @@ Theta Agent is a unified endpoint management daemon for the theta42 stack. It re
The agent dials out to the central SSO Manager via a persistent WebSocket connection, enabling real-time telemetry, dynamic discovery, and secure remote operations. The agent dials out to the central SSO Manager via a persistent WebSocket connection, enabling real-time telemetry, dynamic discovery, and secure remote operations.
## What you get
Install the agent on a node and it becomes a managed member of the directory — over a **single outbound connection**, with no inbound ports, no LDAP hostname/firewall/TLS setup, and no manual secret copying.
- **Directory logins (LDAP byte pump).** SSSD/PAM on the node authenticates through the agent's local socket, which forwards raw LDAP bytes to the SSO's OpenLDAP. OS logins work across any network — laptops, CGNAT, cloud VMs — and fall back to the local SSSD cache when offline.
- **Secrets delivered on-demand.** Services, scripts, and Docker containers fetch secrets dynamically via `theta-agent get-secret DB_PASSWORD` or `theta-agent get-secrets --env`. Zero plaintext secrets on disk! Multi-level secret inheritance (Global Site -> Host -> Service) is resolved automatically.
- **IAM managed centrally.** Sudo rules, SSH keys, and login access are pushed from the SSO to the node. Add a user to a group and their access appears on the right hosts; revoke them and their sessions are dropped.
- **Telemetry & remote operations.** Host discovery, live metrics, and signed remote commands (reboot, service control, config, self-update) — the original C2 capabilities.
Everything is gated by a strict, local-first capability matrix and high-risk operations are Ed25519-signed (see below).
## Core Functionality ## Core Functionality
### 1. Telemetry & Observability ### 1. Telemetry & Observability
@@ -39,12 +50,26 @@ The agent will **only** execute commands that are explicitly enabled in its loca
### Cryptographic Hardening ### Cryptographic Hardening
All high-risk commands require an Ed25519 signature. The agent verifies the signature against the `public_key` provided in the local config. If the signature is missing or invalid, the command is rejected regardless of the capability matrix. All high-risk commands require an Ed25519 signature. The agent verifies the signature against the `public_key` provided in the local config. If the signature is missing or invalid, the command is rejected regardless of the capability matrix.
Verification is **fail-closed**: an agent with no `public_key` configured rejects
every high-risk command. (Before protocol v1.2.0 it logged "skipping signature
verification" and executed them, so an agent installed without a key would run
`reboot`, `configure_ldap` and `arbitrary_bash` unverified.)
### Enrollment
The agent's token must be **issued by the SSO**. The server stores only its
SHA-256 and rejects anything else at the WebSocket handshake, so a token cannot
be minted client-side, and an enrollment can be revoked or rotated centrally —
either drops the agent's live connection immediately. See `PROTOCOL.md` §1.1.
### Capability Matrix ### Capability Matrix
| Capability | Risk Level | Description | Impact | | Capability | Risk Level | Description | Impact |
|------------|------------|-------------|---------| |------------|------------|-------------|---------|
| `telemetry` | Safe | Read-only metrics. | Pushes system health to SSO Manager. | | `telemetry` | Safe | Read-only metrics. | Pushes system health to SSO Manager. |
| `configure_ldap` | Moderate | Configures SSSD. | Updates `/etc/sssd/sssd.conf` and restarts `sssd`. | | `configure_ldap` | Moderate | Configures SSSD. | Updates `/etc/sssd/sssd.conf` and restarts `sssd`. |
| `ldap_tunnel` | Moderate | Local LDAP byte-pump socket. | Forwards raw LDAP bytes to the SSO for SSSD/PAM (DESIGN.md §4). |
| `secrets` | Moderate | Renders OpenBao secrets. | Renders `/etc/theta/templates/*.tpl` to targets, atomic + reload (DESIGN.md §5). |
| `iam` | High | Applies node IAM. | Writes sudo rules, SSH keys, access control; revokes sessions (DESIGN.md §6). |
| `reboot` | High | System reboot. | Triggers an immediate host reboot. | | `reboot` | High | System reboot. | Triggers an immediate host reboot. |
| `service_control` | High | Service management. | Restarts services listed in the allowed list. | | `service_control` | High | Service management. | Restarts services listed in the allowed list. |
| `arbitrary_bash` | CRITICAL | Raw bash execution. | Executes any script sent by the manager as root. | | `arbitrary_bash` | CRITICAL | Raw bash execution. | Executes any script sent by the manager as root. |
@@ -56,7 +81,7 @@ Configuration is stored in YAML format at `/etc/theta42/agent.yml`.
### Example `agent.yml` ### Example `agent.yml`
```yaml ```yaml
server_url: "wss://sso.theta42.local" server_url: "wss://sso.theta42.local"
auth_token: "your-unique-host-token" auth_token: "issued-by-the-sso-at-enrollment"
public_key: "base64-encoded-ed25519-public-key" public_key: "base64-encoded-ed25519-public-key"
location: "dc-01-rack-12" location: "dc-01-rack-12"
capabilities: capabilities:
@@ -71,7 +96,12 @@ capabilities:
1. **Build**: Compile for your target architecture (see CI/CD artifacts). 1. **Build**: Compile for your target architecture (see CI/CD artifacts).
2. **Deploy**: Place the binary in `/usr/local/bin/theta-agent`. 2. **Deploy**: Place the binary in `/usr/local/bin/theta-agent`.
3. **Configure**: Create `/etc/theta42/agent.yml` with the required token and capabilities. 3. **Enroll**: In the SSO, open **Directory → Install Agent**, name the host, bind
it to a host resource, and press **Enroll & issue token**. The SSO mints the
token (shown once) and gives you its public key. Tokens the server did not
issue are rejected.
4. **Configure**: Create `/etc/theta42/agent.yml` with the issued `auth_token`,
the SSO's `public_key`, and your capabilities.
4. **Service**: Set up as a systemd unit (example: `/etc/systemd/system/theta-agent.service`). 4. **Service**: Set up as a systemd unit (example: `/etc/systemd/system/theta-agent.service`).
For the fastest deployment, use the installation script: For the fastest deployment, use the installation script:
@@ -79,6 +109,14 @@ For the fastest deployment, use the installation script:
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- "BASE64_ENCODED_CONFIG" curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- "BASE64_ENCODED_CONFIG"
``` ```
The **Install Agent** modal generates that command for you after enrollment,
with the token and public key already embedded. The equivalent flag form is:
```bash
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- \
--url "https://sso.example.com" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
```
## Development & Testing ## Development & Testing
The agent uses a decoupled execution engine for safety and testability. The agent uses a decoupled execution engine for safety and testability.
+47 -3
View File
@@ -2,19 +2,63 @@
# Default location: /etc/theta42/agent.yml # Default location: /etc/theta42/agent.yml
server_url: "https://sso.example.com" server_url: "https://sso.example.com"
auth_token: "REPLACE_WITH_AGENT_TOKEN"
# This agent's own token. Leave EMPTY when installing with a join key -- the
# agent fills it in itself once the SSO enrolls it. The server records only a
# hash and rejects any token it did not issue, so a locally invented value will
# never connect.
auth_token: ""
# The one credential you need to add a host. Used only while auth_token is
# empty: the SSO exchanges it for this agent's own token + public key on first
# connect, and the agent then blanks this line. Get one from the SSO
# (Directory -> Install Agent, or POST /api/agent/join-keys).
join_key: ""
# Base64 of the SSO's RAW 32-byte Ed25519 public key (NOT a PEM body). Filled in
# automatically when enrolling with a join key; set it by hand only if you
# pre-registered this host.
#
# Required for any high-risk command. Without it the agent still reports
# telemetry, but REFUSES reboot / service_restart / configure_ldap /
# arbitrary_bash / update_binary, because it has no way to verify them.
public_key: ""
location: "default" # Location identifier (e.g., site, datacenter) for naming location: "default" # Location identifier (e.g., site, datacenter) for naming
# Local LDAP byte-pump socket (DESIGN.md §4). The agent forwards raw LDAP bytes
# from this socket to the SSO, which relays them into its OpenLDAP. The agent
# never parses LDAP. Point SSSD at it with:
# ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock
ldap_socket: "/run/theta/ldap.sock"
capabilities: capabilities:
# --------------------------------------------------------- # ---------------------------------------------------------
# Basic Capabilities (Safe, read-only or infrastructure management) # Basic Capabilities (Safe, read-only or infrastructure management)
# --------------------------------------------------------- # ---------------------------------------------------------
# Push CPU, RAM, GPU, and ZFS metrics to the SSO Manager # Push CPU, RAM, GPU, and ZFS metrics to the SSO Manager
telemetry: true telemetry: true
# Allow the SSO Manager to push down SSSD and SSH keys configuration # Allow the SSO Manager to push down SSSD and SSH keys configuration
configure_ldap: true configure_ldap: true
# Serve the local LDAP byte-pump socket for SSSD/PAM (DESIGN.md §4)
ldap_tunnel: true
# Render OpenBao secrets to local files from /etc/theta/templates (DESIGN.md §5)
secrets: true
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md §6)
iam: true
# Secret templates to render (DESIGN.md §5). Each maps a local template to a
# target file and an optional post-render reload. The template embeds secrets as
# {{ bao "secret/data/nodes/<node-id>/<name>#<key>" }}.
# secrets:
# - template: /etc/theta/templates/db.env.tpl
# target: /etc/theta/db.env
# reload: systemctl reload app
# --------------------------------------------------------- # ---------------------------------------------------------
# Advanced Capabilities (High risk, remote operations) # Advanced Capabilities (High risk, remote operations)
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -e
# Cross-compilation script for Theta Agent across Linux (amd64, arm64, armv7), Windows (amd64, arm64), and macOS (amd64, arm64).
DIST_DIR="./dist"
mkdir -p "$DIST_DIR"
LDFLAGS="-s -w"
echo "Building Theta Agent binaries..."
echo " -> linux/amd64..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-linux-amd64"
echo " -> linux/arm64..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-linux-arm64"
echo " -> linux/armv7..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-linux-armv7"
echo " -> windows/amd64..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-amd64.exe"
echo " -> windows/arm64..."
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-arm64.exe"
echo " -> darwin/amd64 (macOS Intel)..."
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-amd64"
echo " -> darwin/arm64 (macOS Apple Silicon)..."
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-arm64"
echo ""
echo "Build complete! Artifacts in $DIST_DIR:"
ls -lh "$DIST_DIR"
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
)
func handleCLI(args []string) bool {
if len(args) == 0 {
return false
}
arg := strings.ToLower(args[0])
switch arg {
case "get-secret", "secret-get":
runGetSecret(args[1:])
return true
case "get-secrets", "secret-list", "secrets":
runGetSecrets(args[1:])
return true
case "--update", "update":
runSelfUpdate(args[1:])
return true
case "--reinitialize", "reinitialize", "--reinit", "reinit":
runReinitialize(args[1:])
return true
case "--version", "version", "-v":
fmt.Println("Theta Agent v1.7.0")
return true
case "--help", "help", "-h":
printUsage()
return true
}
return false
}
func printUsage() {
fmt.Println("Theta Agent - Unified Endpoint Management CLI")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" theta-agent Run agent daemon in foreground")
fmt.Println(" theta-agent get-secret <key> Fetch single secret value from OpenBao")
fmt.Println(" theta-agent get-secrets [flags] Fetch all host/resource secrets (flags: --json, --env)")
fmt.Println(" theta-agent update Self-update binary from SSO Manager")
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register")
fmt.Println(" theta-agent version Show version info")
fmt.Println()
fmt.Println("Reinitialize Flags:")
fmt.Println(" --join-key <key> Supply new join key for re-enrollment")
fmt.Println()
}
func runSelfUpdate(args []string) {
configPath := "/etc/theta42/agent.yml"
cm, err := NewConfigManager(configPath)
if err != nil {
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
}
cfg := cm.Get()
serverURL := strings.TrimRight(cfg.ServerURL, "/")
if serverURL == "" {
log.Fatalf("[!] Update failed: server_url is empty in %s", configPath)
}
downloadURL := fmt.Sprintf("%s/resources/theta-agent/theta-agent-linux-amd64", serverURL)
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(downloadURL)
if err != nil || resp.StatusCode != 200 {
log.Fatalf("[!] Failed to download update binary from %s (HTTP %d): %v", downloadURL, resp.StatusCode, err)
}
defer resp.Body.Close()
binPath := "/usr/local/bin/theta-agent"
if selfPath, err := os.Executable(); err == nil && selfPath != "" {
binPath = selfPath
}
tmpPath := binPath + ".tmp"
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
log.Fatalf("[!] Cannot write binary to %s: %v", tmpPath, err)
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close()
log.Fatalf("[!] Error writing binary update: %v", err)
}
out.Close()
if err := os.Rename(tmpPath, binPath); err != nil {
log.Fatalf("[!] Cannot replace binary at %s: %v", binPath, err)
}
log.Printf("[+] Binary updated successfully at %s.", binPath)
exec := &SystemExecutor{}
restartAffectedServices(exec)
os.Exit(0)
}
func runReinitialize(args []string) {
configPath := "/etc/theta42/agent.yml"
joinKey := ""
for i := 0; i < len(args); i++ {
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
joinKey = args[i+1]
i++
}
}
raw, err := os.ReadFile(configPath)
if err != nil {
log.Fatalf("[!] Cannot read %s: %v", configPath, err)
}
content := string(raw)
// Clear auth_token
reToken := regexp.MustCompile(`(?m)^auth_token:.*$`)
content = reToken.ReplaceAllString(content, `auth_token: ""`)
if joinKey != "" {
reKey := regexp.MustCompile(`(?m)^join_key:.*$`)
if reKey.MatchString(content) {
content = reKey.ReplaceAllString(content, fmt.Sprintf(`join_key: "%s"`, joinKey))
} else {
content += fmt.Sprintf("\njoin_key: \"%s\"\n", joinKey)
}
}
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
log.Fatalf("[!] Failed to update %s: %v", configPath, err)
}
log.Printf("[+] Cleared token in %s and reset enrollment status.", configPath)
exec := &SystemExecutor{}
restartAffectedServices(exec)
os.Exit(0)
}
func restartAffectedServices(exec Executor) {
log.Printf("[+] Restarting theta-agent service...")
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
log.Printf("[+] Restarting sssd service...")
_, _ = exec.Execute("systemctl", "restart", "sssd")
}
if _, err := exec.Execute("systemctl", "is-active", "sshd"); err == nil {
log.Printf("[+] Reloading sshd service...")
_, _ = exec.Execute("systemctl", "reload", "sshd")
} else if _, err := exec.Execute("systemctl", "is-active", "ssh"); err == nil {
log.Printf("[+] Reloading ssh service...")
_, _ = exec.Execute("systemctl", "reload", "ssh")
}
}
func runGetSecret(args []string) {
if len(args) < 1 {
fmt.Fprintf(os.Stderr, "[!] Error: secret key name required (e.g. theta-agent get-secret DB_PASSWORD)\n")
os.Exit(1)
}
key := args[0]
secrets, err := fetchAgentSecrets()
if err != nil {
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
os.Exit(1)
}
val, exists := secrets[key]
if !exists {
fmt.Fprintf(os.Stderr, "[!] Error: secret '%s' not found for this host/resource\n", key)
os.Exit(1)
}
// Print raw secret value to stdout without trailing newline
fmt.Print(val)
os.Exit(0)
}
func runGetSecrets(args []string) {
jsonMode := false
envMode := false
for _, arg := range args {
if arg == "--json" {
jsonMode = true
} else if arg == "--env" {
envMode = true
}
}
secrets, err := fetchAgentSecrets()
if err != nil {
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
os.Exit(1)
}
if jsonMode {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(secrets); err != nil {
fmt.Fprintf(os.Stderr, "[!] JSON encode error: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
if envMode {
for k, v := range secrets {
escaped := strings.ReplaceAll(v, `"`, `\"`)
fmt.Printf("%s=\"%s\"\n", k, escaped)
}
os.Exit(0)
}
if len(secrets) == 0 {
fmt.Println("No secrets configured for this host/resource.")
os.Exit(0)
}
fmt.Printf("%-30s %s\n", "SECRET KEY", "VALUE STATUS")
fmt.Println(strings.Repeat("-", 60))
for k, v := range secrets {
status := fmt.Sprintf("Configured (%d chars)", len(v))
fmt.Printf("%-30s %s\n", k, status)
}
os.Exit(0)
}
func fetchAgentSecrets() (map[string]string, error) {
configPath := "/etc/theta42/agent.yml"
cm, err := NewConfigManager(configPath)
if err != nil {
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
}
cfg := cm.Get()
serverURL := strings.TrimRight(cfg.ServerURL, "/")
if serverURL == "" {
return nil, fmt.Errorf("server_url is empty in %s", configPath)
}
token := cfg.AuthToken
if token == "" {
return nil, fmt.Errorf("agent is not enrolled (auth_token empty in %s)", configPath)
}
reqBody, _ := json.Marshal(map[string]interface{}{})
url := fmt.Sprintf("%s/api/v1/agent/secrets", serverURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bodyBytes))
}
var resData struct {
Status string `json:"status"`
Secrets map[string]map[string]interface{} `json:"secrets"`
}
if err := json.NewDecoder(resp.Body).Decode(&resData); err != nil {
return nil, fmt.Errorf("failed to decode JSON response: %w", err)
}
mergedSecrets := make(map[string]string)
for _, pathMap := range resData.Secrets {
for k, v := range pathMap {
if strV, ok := v.(string); ok {
mergedSecrets[k] = strV
} else if v != nil {
mergedSecrets[k] = fmt.Sprintf("%v", v)
}
}
}
return mergedSecrets, nil
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"testing"
)
func TestHandleCLIHelpAndVersion(t *testing.T) {
if !handleCLI([]string{"version"}) {
t.Errorf("expected handleCLI('version') to return true")
}
if !handleCLI([]string{"--help"}) {
t.Errorf("expected handleCLI('--help') to return true")
}
if handleCLI([]string{"unknown-command"}) {
t.Errorf("expected handleCLI('unknown-command') to return false")
}
}
+94 -5
View File
@@ -3,6 +3,8 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"regexp"
"strings"
"sync" "sync"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -14,20 +16,47 @@ type Capabilities struct {
Reboot bool `yaml:"reboot"` Reboot bool `yaml:"reboot"`
ServiceControl []string `yaml:"service_control"` ServiceControl []string `yaml:"service_control"`
ArbitraryBash bool `yaml:"arbitrary_bash"` ArbitraryBash bool `yaml:"arbitrary_bash"`
LdapTunnel bool `yaml:"ldap_tunnel"`
Secrets bool `yaml:"secrets"`
IAM bool `yaml:"iam"`
}
// SecretTarget maps a local template to a rendered target file and an optional
// post-render reload command (DESIGN.md §5).
type SecretTarget struct {
Template string `yaml:"template"`
Target string `yaml:"target"`
Reload string `yaml:"reload"`
} }
type Config struct { type Config struct {
ServerURL string `yaml:"server_url"` ServerURL string `yaml:"server_url"`
AuthToken string `yaml:"auth_token"` AuthToken string `yaml:"auth_token"`
// A join key is the one credential an operator hands out. On first connect
// the server exchanges it for a per-agent AuthToken (written back to this
// file), so it is a bootstrap value, not a long-term credential. Used only
// when AuthToken is empty.
JoinKey string `yaml:"join_key"`
Location string `yaml:"location"` Location string `yaml:"location"`
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
Capabilities Capabilities `yaml:"capabilities"` LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
Capabilities Capabilities `yaml:"capabilities"`
}
// Credential returns the value to present when connecting: our own token once
// enrolled, otherwise the join key.
func (c *Config) Credential() string {
if c.AuthToken != "" {
return c.AuthToken
}
return c.JoinKey
} }
// ConfigManager handles thread-safe access and reloading of the agent configuration. // ConfigManager handles thread-safe access and reloading of the agent configuration.
type ConfigManager struct { type ConfigManager struct {
mu sync.RWMutex mu sync.RWMutex
current *Config current *Config
configPath string configPath string
} }
@@ -61,6 +90,62 @@ func (cm *ConfigManager) Reload() error {
return nil return nil
} }
// PersistEnrollment writes the credentials the server issued during join-key
// enrollment back into agent.yml, then reloads. Only the auth_token and
// public_key lines are rewritten (added if absent); every other line, including
// operator comments and the capability matrix, is preserved -- this file is
// hand-edited, so a naive marshal-and-write would destroy it.
//
// The join key is blanked once we hold our own token: leaving a fleet-wide
// credential on every host after it has stopped being needed is exactly the
// blast radius the per-agent token exists to avoid.
func (cm *ConfigManager) PersistEnrollment(token, publicKey string) error {
if token == "" {
return fmt.Errorf("server reported enrollment but sent no token")
}
cm.mu.Lock()
defer cm.mu.Unlock()
raw, err := os.ReadFile(cm.configPath)
if err != nil {
return fmt.Errorf("read %s: %w", cm.configPath, err)
}
out := setYamlScalar(string(raw), "auth_token", token)
if publicKey != "" {
out = setYamlScalar(out, "public_key", publicKey)
}
out = setYamlScalar(out, "join_key", "")
// Same permissions the installer sets: this file now holds a credential.
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
return fmt.Errorf("write %s: %w", cm.configPath, err)
}
cfg, err := LoadConfig(cm.configPath)
if err != nil {
return fmt.Errorf("reload after enrollment: %w", err)
}
cm.current = cfg
return nil
}
// setYamlScalar replaces the value of a top-level `key: "..."` line, or appends
// the key when it is absent. Deliberately line-based rather than a YAML
// round-trip so comments and formatting survive.
func setYamlScalar(doc, key, value string) string {
re := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*:.*$`)
line := fmt.Sprintf("%s: %q", key, value)
if re.MatchString(doc) {
return re.ReplaceAllString(doc, line)
}
if !strings.HasSuffix(doc, "\n") {
doc += "\n"
}
return doc + line + "\n"
}
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
@@ -74,6 +159,10 @@ func LoadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("failed to decode YAML config: %w", err) return nil, fmt.Errorf("failed to decode YAML config: %w", err)
} }
if cfg.Capabilities.ConfigureLDAP {
cfg.Capabilities.LdapTunnel = true
}
return &cfg, nil return &cfg, nil
} }
+106
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -96,3 +97,108 @@ func TestCanManageService(t *testing.T) {
}) })
} }
} }
// PersistEnrollment rewrites a hand-edited file, so it must replace exactly the
// credential lines and leave everything else -- comments, capabilities,
// formatting -- untouched.
func TestPersistEnrollmentPreservesFile(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
original := `# theta-agent configuration file
server_url: "https://sso.example.com"
# Bootstrap credential, exchanged on first connect.
join_key: "tjk_abc123"
public_key: ""
location: "rack-4"
capabilities:
telemetry: true
# keep this comment
reboot: false
service_control: ["nginx"]
`
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
t.Fatal(err)
}
cm, err := NewConfigManager(path)
if err != nil {
t.Fatal(err)
}
if err := cm.PersistEnrollment("issued-token-xyz", "PUBKEYBASE64"); err != nil {
t.Fatalf("PersistEnrollment: %v", err)
}
out, _ := os.ReadFile(path)
got := string(out)
for _, want := range []string{
`auth_token: "issued-token-xyz"`,
`public_key: "PUBKEYBASE64"`,
`join_key: ""`, // blanked: a fleet-wide key must not linger once unneeded
"# theta-agent configuration file",
"# keep this comment",
`location: "rack-4"`,
`service_control: ["nginx"]`,
} {
if !strings.Contains(got, want) {
t.Errorf("expected %q in rewritten config, got:\n%s", want, got)
}
}
// and the in-memory config is live without a restart
if cm.Get().AuthToken != "issued-token-xyz" {
t.Errorf("config not reloaded: AuthToken = %q", cm.Get().AuthToken)
}
if cm.Get().Credential() != "issued-token-xyz" {
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential())
}
// file must stay 0600 -- it now holds a credential
fi, _ := os.Stat(path)
if fi.Mode().Perm() != 0600 {
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
}
}
func TestPersistEnrollmentAddsMissingKeys(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
// No auth_token or public_key lines at all.
if err := os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\njoin_key: \"tjk_x\"\n"), 0600); err != nil {
t.Fatal(err)
}
cm, err := NewConfigManager(path)
if err != nil {
t.Fatal(err)
}
if err := cm.PersistEnrollment("tok", "pk"); err != nil {
t.Fatalf("PersistEnrollment: %v", err)
}
if cm.Get().AuthToken != "tok" || cm.Get().PublicKey != "pk" {
out, _ := os.ReadFile(path)
t.Errorf("keys not appended; file:\n%s", out)
}
}
func TestCredentialPrefersAuthToken(t *testing.T) {
c := &Config{JoinKey: "tjk_x"}
if c.Credential() != "tjk_x" {
t.Errorf("unenrolled agent should present the join key, got %q", c.Credential())
}
c.AuthToken = "own-token"
if c.Credential() != "own-token" {
t.Errorf("enrolled agent must present its own token, not the join key, got %q", c.Credential())
}
}
func TestPersistEnrollmentRejectsEmptyToken(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
os.WriteFile(path, []byte("server_url: \"x\"\n"), 0600)
cm, _ := NewConfigManager(path)
if err := cm.PersistEnrollment("", "pk"); err == nil {
t.Error("expected an error when the server sends no token")
}
}
+6
View File
@@ -0,0 +1,6 @@
FROM node:20-alpine
RUN apk add --no-cache bash
WORKDIR /demo
COPY get-secret.sh get-secret.js ./
RUN chmod +x get-secret.sh
CMD ["sh", "-c", "sh /demo/get-secret.sh && node /demo/get-secret.js"]
+17
View File
@@ -0,0 +1,17 @@
// Demo: a 3rd-party node app reads the secret the theta-agent rendered to disk.
const fs = require('fs');
const path = '/etc/theta/rendered/db.env';
console.log('=== node app reads the rendered secret ===');
if (fs.existsSync(path)) {
const env = fs.readFileSync(path, 'utf8');
const db = {};
for (const line of env.split('\n')) {
const m = /^(\w+)="(.*)"$/.exec(line.trim());
if (m) db[m[1]] = m[2];
}
console.log('DB_USER=' + db.DB_USER);
console.log('DB_PASS=' + db.DB_PASS);
} else {
console.error('rendered secret not found — run render_secrets first');
process.exit(1);
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Demo: a 3rd-party bash app reads the secret the theta-agent rendered to disk.
# The agent rendered /etc/theta/rendered/db.env from a template + OpenBao.
echo "=== bash app reads the rendered secret ==="
if [ -f /etc/theta/rendered/db.env ]; then
. /etc/theta/rendered/db.env
echo "DB_USER=$DB_USER"
echo "DB_PASS=$DB_PASS"
else
echo "rendered secret not found — run render_secrets first"
exit 1
fi
+6 -3
View File
@@ -2,16 +2,19 @@ module github.com/theta42/theta-agent
go 1.22.2 go 1.22.2
require (
github.com/gorilla/websocket v1.5.3
github.com/shirou/gopsutil/v3 v3.24.5
gopkg.in/yaml.v3 v3.0.1
)
require ( require (
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/sys v0.20.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+13
View File
@@ -1,16 +1,26 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -23,7 +33,10 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
// IAM engine (DESIGN.md §6). The SSO pushes node-scoped identity config down the
// WSS channel as a signed `iam_apply` command; the agent verifies the signature
// (fail-closed) and applies it locally: sudo rules, SSH keys, access control,
// and session revocation.
// Paths are package-level vars so tests can redirect them to temp dirs.
var (
iamSudoersDir = "/etc/sudoers.d"
iamKeysDir = "/etc/theta/authorized_keys"
iamKeysCommand = "/usr/local/bin/theta-authorized-keys"
iamAccessConf = "/etc/security/access.conf"
)
// IAMPayload is the signed body of an `iam_apply` command.
type IAMPayload struct {
NodeID string `json:"node_id"`
Revision int `json:"revision"`
AccessControl AccessControl `json:"access_control"`
}
type AccessControl struct {
AllowedLoginGroups []string `json:"allowed_login_groups"`
SudoRules []SudoRule `json:"sudo_rules"`
SSHKeys []SSHKey `json:"ssh_keys"`
RevokeUsers []string `json:"revoke_users"`
}
type SudoRule struct {
Group string `json:"group"`
RunAs string `json:"run_as"`
Commands []string `json:"commands"`
Nopasswd bool `json:"nopasswd"`
}
type SSHKey struct {
User string `json:"user"`
Keys []string `json:"keys"`
}
// applyIAM applies a verified IAM payload. The caller must have already verified
// the Ed25519 signature.
func applyIAM(payload IAMPayload, exec Executor) error {
if len(payload.AccessControl.SudoRules) > 0 {
if err := applySudoRules(payload.AccessControl.SudoRules, payload.NodeID, exec); err != nil {
return fmt.Errorf("sudo rules: %w", err)
}
}
if len(payload.AccessControl.SSHKeys) > 0 {
if err := applySSHKeys(payload.AccessControl.SSHKeys, exec); err != nil {
return fmt.Errorf("ssh keys: %w", err)
}
}
if len(payload.AccessControl.AllowedLoginGroups) > 0 {
if err := applyAccessControl(payload.AccessControl.AllowedLoginGroups, exec); err != nil {
return fmt.Errorf("access control: %w", err)
}
}
if len(payload.AccessControl.RevokeUsers) > 0 {
applyRevocation(payload.AccessControl.RevokeUsers, exec)
}
return nil
}
// applySudoRules writes /etc/sudoers.d/theta-iam-<node_id>, verifies with
// `visudo -c`, and atomically swaps it in on success.
func applySudoRules(rules []SudoRule, nodeID string, exec Executor) error {
var b strings.Builder
for _, r := range rules {
if r.Group == "" {
continue
}
runAs := r.RunAs
if runAs == "" {
runAs = "ALL"
}
cmds := strings.Join(r.Commands, ", ")
if cmds == "" {
cmds = "ALL"
}
prefix := ""
if r.Nopasswd {
prefix = "NOPASSWD:"
}
fmt.Fprintf(&b, "%%%s ALL=(%s) %s%s\n", r.Group, runAs, prefix, cmds)
}
content := b.String()
// Make sure the sudoers.d dir exists (a minimal host may not have it).
if err := os.MkdirAll(iamSudoersDir, 0755); err != nil {
return err
}
// Write to a temp file in the sudoers.d dir, verify, then rename.
tmp, err := os.CreateTemp(iamSudoersDir, ".theta-iam-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.WriteString(content); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
os.Chmod(tmpName, 0440)
// visudo -c -f <file> validates a single file without touching the rest.
if _, err := exec.Execute("visudo", "-c", "-f", tmpName); err != nil {
os.Remove(tmpName)
return fmt.Errorf("visudo rejected the rules: %w", err)
}
target := filepath.Join(iamSudoersDir, "theta-iam-"+nodeID)
if err := os.Rename(tmpName, target); err != nil {
os.Remove(tmpName)
return err
}
log.Printf("IAM: wrote %s", target)
return nil
}
// applySSHKeys stores per-user keys and installs the AuthorizedKeysCommand
// script that sshd calls per login.
func applySSHKeys(keys []SSHKey, exec Executor) error {
if err := os.MkdirAll(iamKeysDir, 0755); err != nil {
return err
}
for _, k := range keys {
if k.User == "" {
continue
}
path := filepath.Join(iamKeysDir, k.User)
content := strings.Join(k.Keys, "\n") + "\n"
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return err
}
}
// AuthorizedKeysCommand script: cat the user's key file.
script := "#!/bin/sh\ncat \"" + iamKeysDir + "/$1\" 2>/dev/null\n"
if err := os.WriteFile(iamKeysCommand, []byte(script), 0755); err != nil {
return err
}
log.Printf("IAM: wrote %d ssh key file(s) + %s", len(keys), iamKeysCommand)
return nil
}
// applyAccessControl writes /etc/security/access.conf with the allowed login
// groups (PAM access). Format: `+:group:ALL` for each allowed group, then deny
// everything else.
func applyAccessControl(groups []string, exec Executor) error {
var b strings.Builder
for _, g := range groups {
if g != "" {
fmt.Fprintf(&b, "+:%s:ALL\n", g)
}
}
b.WriteString("-:ALL:ALL\n")
if err := os.MkdirAll(filepath.Dir(iamAccessConf), 0755); err != nil {
return err
}
if err := os.WriteFile(iamAccessConf, []byte(b.String()), 0644); err != nil {
return err
}
log.Printf("IAM: wrote %s", iamAccessConf)
return nil
}
// applyRevocation flushes the SSSD cache and drops active sessions for the
// revoked users.
func applyRevocation(users []string, exec Executor) {
// Flush the whole SSSD cache once — a revoked user must not be resolvable
// from cache.
if _, err := exec.Execute("sss_cache", "-E"); err != nil {
log.Printf("IAM: sss_cache -E failed: %v", err)
}
for _, u := range users {
if u == "" {
continue
}
if _, err := exec.Execute("pkill", "-u", u); err != nil {
log.Printf("IAM: pkill -u %s failed (no sessions?): %v", u, err)
}
log.Printf("IAM: revoked %s", u)
}
}
// parseIAMPayload extracts an IAMPayload from a WSMessage payload map.
func parseIAMPayload(payload map[string]interface{}) (IAMPayload, error) {
raw, err := json.Marshal(payload)
if err != nil {
return IAMPayload{}, err
}
var p IAMPayload
if err := json.Unmarshal(raw, &p); err != nil {
return IAMPayload{}, err
}
return p, nil
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// TestApplyIAM verifies the agent applies sudo rules, SSH keys, access control,
// and revocation from a signed IAM payload.
func TestApplyIAM(t *testing.T) {
dir := t.TempDir()
iamSudoersDir = dir
iamKeysDir = filepath.Join(dir, "keys")
iamKeysCommand = filepath.Join(dir, "theta-authorized-keys")
iamAccessConf = filepath.Join(dir, "access.conf")
payload := IAMPayload{
NodeID: "node-42",
Revision: 82,
AccessControl: AccessControl{
AllowedLoginGroups: []string{"sysadmins", "node-operators"},
SudoRules: []SudoRule{
{Group: "sysadmins", RunAs: "ALL", Commands: []string{"ALL"}},
{Group: "node-operators", RunAs: "ALL", Commands: []string{"ALL"}, Nopasswd: true},
},
SSHKeys: []SSHKey{
{User: "admin", Keys: []string{"ssh-ed25519 AAAA admin@theta"}},
},
RevokeUsers: []string{"olduser"},
},
}
exec := &MockExecutor{}
if err := applyIAM(payload, exec); err != nil {
t.Fatalf("applyIAM: %v", err)
}
// Sudo rules file.
sudoers, err := os.ReadFile(filepath.Join(dir, "theta-iam-node-42"))
if err != nil {
t.Fatalf("read sudoers: %v", err)
}
wantSudoers := "%sysadmins ALL=(ALL) ALL\n%node-operators ALL=(ALL) NOPASSWD:ALL\n"
if string(sudoers) != wantSudoers {
t.Fatalf("sudoers mismatch:\n got: %q\nwant: %q", sudoers, wantSudoers)
}
// SSH key file + AuthorizedKeysCommand script.
keyFile, err := os.ReadFile(filepath.Join(iamKeysDir, "admin"))
if err != nil {
t.Fatalf("read key file: %v", err)
}
if string(keyFile) != "ssh-ed25519 AAAA admin@theta\n" {
t.Fatalf("key file mismatch: %q", keyFile)
}
script, err := os.ReadFile(iamKeysCommand)
if err != nil {
t.Fatalf("read keys command: %v", err)
}
if string(script) != "#!/bin/sh\ncat \""+iamKeysDir+"/$1\" 2>/dev/null\n" {
t.Fatalf("keys command mismatch: %q", script)
}
// Access control.
access, err := os.ReadFile(iamAccessConf)
if err != nil {
t.Fatalf("read access.conf: %v", err)
}
wantAccess := "+:sysadmins:ALL\n+:node-operators:ALL\n-:ALL:ALL\n"
if string(access) != wantAccess {
t.Fatalf("access.conf mismatch:\n got: %q\nwant: %q", access, wantAccess)
}
// Commands: visudo -c -f, sss_cache -E, pkill -u olduser.
ran := map[string]bool{}
for _, c := range exec.ExecutedCommands {
if c[0] == "visudo" {
ran["visudo"] = true
}
if c[0] == "sss_cache" {
ran["sss_cache"] = true
}
if c[0] == "pkill" && len(c) >= 3 && c[2] == "olduser" {
ran["pkill"] = true
}
}
for _, k := range []string{"visudo", "sss_cache", "pkill"} {
if !ran[k] {
t.Errorf("expected %s to run, got commands %v", k, exec.ExecutedCommands)
}
}
}
// TestParseIAMPayload verifies the payload parses from a WSMessage payload map.
func TestParseIAMPayload(t *testing.T) {
payload := map[string]interface{}{
"node_id": "node-42",
"revision": float64(82),
"access_control": map[string]interface{}{
"allowed_login_groups": []interface{}{"sysadmins"},
"sudo_rules": []interface{}{
map[string]interface{}{"group": "sysadmins", "run_as": "ALL", "commands": []interface{}{"ALL"}, "nopasswd": true},
},
"ssh_keys": []interface{}{
map[string]interface{}{"user": "admin", "keys": []interface{}{"ssh-ed25519 AAAA"}},
},
"revoke_users": []interface{}{"olduser"},
},
}
p, err := parseIAMPayload(payload)
if err != nil {
t.Fatalf("parseIAMPayload: %v", err)
}
if p.NodeID != "node-42" || p.Revision != 82 {
t.Fatalf("bad node/revision: %+v", p)
}
if len(p.AccessControl.SudoRules) != 1 || p.AccessControl.SudoRules[0].Group != "sysadmins" {
t.Fatalf("bad sudo rules: %+v", p.AccessControl.SudoRules)
}
if len(p.AccessControl.SSHKeys) != 1 || p.AccessControl.SSHKeys[0].User != "admin" {
t.Fatalf("bad ssh keys: %+v", p.AccessControl.SSHKeys)
}
if len(p.AccessControl.RevokeUsers) != 1 || p.AccessControl.RevokeUsers[0] != "olduser" {
t.Fatalf("bad revoke users: %+v", p.AccessControl.RevokeUsers)
}
}
+116 -14
View File
@@ -19,16 +19,46 @@ log() { echo -e "${GREEN}[+]${NC} $1"; }
error() { echo -e "${RED}[!]${NC} $1"; exit 1; } error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
# 1. Root check # 1. Root check
if [ "$EUID" -ne 0 ]; then if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
error "This script must be run as root." error "This script must be run as root."
fi fi
# Install SSSD and PAM integration packages if missing
install_sssd_deps() {
if ! command -v sssd >/dev/null 2>&1; then
log "Installing SSSD and PAM integration dependencies..."
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
if command -v pam-auth-update >/dev/null 2>&1; then
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
fi
elif command -v dnf >/dev/null 2>&1; then
dnf install -y sssd sssd-ldap sssd-tools || true
elif command -v yum >/dev/null 2>&1; then
yum install -y sssd sssd-ldap sssd-tools || true
elif command -v pacman >/dev/null 2>&1; then
pacman -S --noconfirm sssd || true
elif command -v zypper >/dev/null 2>&1; then
zypper in -y sssd || true
fi
else
log "SSSD is already installed."
fi
mkdir -p /etc/sssd
chmod 755 /etc/sssd
}
# 2. Argument Parsing # 2. Argument Parsing
URL="" URL=""
TOKEN="" TOKEN=""
JOIN_KEY=""
PUBLIC_KEY=""
B64_CONFIG="" B64_CONFIG=""
INSTALL_SSSD=0
while [[ $# -gt 0 ]]; do while [ $# -gt 0 ]; do
case $1 in case $1 in
--url) --url)
URL="$2" URL="$2"
@@ -38,6 +68,25 @@ while [[ $# -gt 0 ]]; do
TOKEN="$2" TOKEN="$2"
shift 2 shift 2
;; ;;
# Base64 of the SSO's raw Ed25519 public key. The agent verifies high-risk
# commands (reboot, configure_ldap, arbitrary_bash, update_binary) against
# it and REFUSES them when it is absent, so an install without this key can
# stream telemetry but cannot be acted on.
--public-key)
PUBLIC_KEY="$2"
shift 2
;;
# The one credential an operator hands out. The server exchanges it for a
# per-agent token on first connect, which the agent writes back into
# agent.yml -- so this is all you need to add a host.
--join-key)
JOIN_KEY="$2"
shift 2
;;
--install-sssd|--ldap)
INSTALL_SSSD=1
shift
;;
*) *)
B64_CONFIG="$1" B64_CONFIG="$1"
shift shift
@@ -45,21 +94,58 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
# Validation # Validation: require credentials ONLY if config file does not already exist
if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token." error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
echo "Usage examples:" echo "Usage examples:"
echo " sh install.sh \"BASE64_CONFIG\"" echo " sh install.sh \"BASE64_CONFIG\""
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\"" echo " sh install.sh --url \"https://sso.local\" --join-key \"tjk_...\" --install-sssd"
echo " sh install.sh --url \"https://sso.local\" --token \"ISSUED_TOKEN\" --public-key \"BASE64_KEY\""
echo ""
echo "--join-key is the normal path: the host enrolls itself on first connect"
echo "and the SSO issues it its own token + public key, which the agent writes"
echo "back into agent.yml. Get a key from Directory -> Install Agent."
exit 1 exit 1
fi fi
log "Starting Theta Agent installation..." log "Starting Theta Agent installation..."
# Architecture and OS detection
OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH_NAME="$(uname -m)"
BINARY_NAME="theta-agent-linux-amd64"
case "$OS_NAME" in
linux*)
case "$ARCH_NAME" in
x86_64|amd64) BINARY_NAME="theta-agent-linux-amd64" ;;
aarch64|arm64) BINARY_NAME="theta-agent-linux-arm64" ;;
armv7*|armhf) BINARY_NAME="theta-agent-linux-armv7" ;;
*) BINARY_NAME="theta-agent-linux-amd64" ;;
esac
;;
darwin*)
case "$ARCH_NAME" in
x86_64|amd64) BINARY_NAME="theta-agent-darwin-amd64" ;;
arm64|aarch64) BINARY_NAME="theta-agent-darwin-arm64" ;;
*) BINARY_NAME="theta-agent-darwin-arm64" ;;
esac
;;
mingw*|msys*|cygwin*)
case "$ARCH_NAME" in
aarch64|arm64) BINARY_NAME="theta-agent-windows-arm64.exe" ;;
*) BINARY_NAME="theta-agent-windows-amd64.exe" ;;
esac
;;
esac
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${BINARY_NAME}"
# 3. Install binary # 3. Install binary
log "Downloading binary from $BINARY_URL..." log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary." curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL"
chmod +x "$BIN_PATH" chmod +x "$BIN_PATH.tmp"
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
# 4. Setup configuration # 4. Setup configuration
log "Preparing configuration directory $CONFIG_DIR..." log "Preparing configuration directory $CONFIG_DIR..."
@@ -69,23 +155,41 @@ chmod 755 "$CONFIG_DIR"
if [ -n "$B64_CONFIG" ]; then if [ -n "$B64_CONFIG" ]; then
log "Decoding and writing configuration from base64..." log "Decoding and writing configuration from base64..."
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration." echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
else elif [ ! -f "$CONFIG_FILE" ]; then
log "Generating minimal configuration from arguments..." log "Generating minimal configuration from arguments..."
# Create a minimal yaml with the provided URL and Token
cat <<EOF > "$CONFIG_FILE" cat <<EOF > "$CONFIG_FILE"
server_url: "$URL" server_url: "$URL"
auth_token: "$TOKEN" auth_token: "$TOKEN"
join_key: "$JOIN_KEY"
public_key: "$PUBLIC_KEY"
location: "unknown" location: "unknown"
capabilities: capabilities:
telemetry: true telemetry: true
configure_ldap: false configure_ldap: true
ldap_tunnel: true
reboot: false reboot: false
service_control: [] service_control: []
arbitrary_bash: false arbitrary_bash: false
EOF EOF
else
log "Preserving existing configuration at $CONFIG_FILE"
fi fi
chmod 600 "$CONFIG_FILE" chmod 600 "$CONFIG_FILE"
# An agent with no public_key cannot verify signed commands and will refuse
# every one of them. That is the safe default, but it is silent at run time, so
# say it plainly here where the operator is watching.
if ! grep -qE '^public_key:[[:space:]]*"[^"]+"' "$CONFIG_FILE" 2>/dev/null; then
log "WARNING: no public_key configured — this agent will report telemetry but"
log " REFUSE reboot / configure_ldap / arbitrary_bash / update_binary."
log " Re-run with --public-key \"<base64 key>\" (shown at enrollment)."
fi
# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled
if [ "$INSTALL_SSSD" -eq 1 ] || grep -qE -i 'configure_ldap:[[:space:]]*true' "$CONFIG_FILE" 2>/dev/null; then
install_sssd_deps
fi
# 5. Setup systemd service # 5. Setup systemd service
log "Creating systemd service unit..." log "Creating systemd service unit..."
cat <<EOF > "$SERVICE_FILE" cat <<EOF > "$SERVICE_FILE"
@@ -98,8 +202,6 @@ Type=simple
ExecStart=$BIN_PATH ExecStart=$BIN_PATH
Restart=always Restart=always
RestartSec=5 RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent SyslogIdentifier=theta-agent
[Install] [Install]
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"encoding/json"
"os"
"testing"
)
// Cross-implementation check: a payload signed by the Node server (utils/
// agent_manager.js) must verify with the agent's own verifySignature. Skips
// unless the fixture is present, so it never breaks a normal `go test`.
func TestInteropWithServerSignature(t *testing.T) {
raw, err := os.ReadFile(os.Getenv("INTEROP_FIXTURE"))
if err != nil {
t.Skip("no INTEROP_FIXTURE provided")
}
var fx struct {
Pub string `json:"pub"`
Payload map[string]interface{} `json:"payload"`
Sig string `json:"sig"`
}
if err := json.Unmarshal(raw, &fx); err != nil {
t.Fatalf("bad fixture: %v", err)
}
payload := map[string]interface{}{}
for k, v := range fx.Payload {
payload[k] = v
}
payload["signature"] = fx.Sig
cfg := &Config{PublicKey: fx.Pub}
if !verifySignature(cfg, WSMessage{Type: "arbitrary_bash", Payload: payload}) {
t.Fatal("agent REJECTED a signature produced by the SSO server")
}
t.Log("agent accepted the server-produced signature")
}
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/gorilla/websocket"
)
// ldapTunnel is the agent's local LDAP socket (DESIGN.md §4). It is a pure byte
// pump: bytes from a local LDAP client (SSSD) are forwarded to the SSO over the
// WSS channel as `ldap_tunnel` messages, and the SSO's responses are written
// back to the socket. The agent never parses LDAP — it does not know or care
// what the bytes mean.
//
// Each local connection gets a conn_id. The agent reads the socket and sends
// chunks up; the SSO relays them into its real OpenLDAP and sends the response
// chunks back down; the agent writes them to the socket. `close:true` ends a
// connection.
type ldapTunnel struct {
mu sync.Mutex
conns map[string]net.Conn
send func(WSMessage) error
}
func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
return &ldapTunnel{
conns: make(map[string]net.Conn),
send: send,
}
}
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
os.Remove(socketPath)
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
os.MkdirAll(dir, 0755)
}
// 1. UNIX Domain Socket Listener
lnUnix, err := net.Listen("unix", socketPath)
if err == nil {
os.Chmod(socketPath, 0666)
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
go t.acceptLoop(lnUnix, stopCh)
} else {
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
}
// 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890)
lnTcp, errTcp := net.Listen("tcp", "127.0.0.1:389")
if errTcp != nil {
lnTcp, errTcp = net.Listen("tcp", "127.0.0.1:3890")
}
if errTcp == nil {
log.Printf("LDAP tunnel: listening on tcp %s", lnTcp.Addr().String())
go t.acceptLoop(lnTcp, stopCh)
} else {
log.Printf("LDAP tunnel: cannot bind tcp loopback: %v", errTcp)
}
}
func (t *ldapTunnel) acceptLoop(ln net.Listener, stopCh <-chan struct{}) {
defer ln.Close()
go func() {
<-stopCh
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
return
}
go t.handleConn(conn)
}
}
// handleConn pumps one local LDAP connection up to the SSO.
func (t *ldapTunnel) handleConn(conn net.Conn) {
connID := newConnID()
t.mu.Lock()
t.conns[connID] = conn
t.mu.Unlock()
buf := make([]byte, 32*1024)
for {
n, err := conn.Read(buf)
if n > 0 {
msg := WSMessage{
Type: "ldap_tunnel",
Payload: map[string]interface{}{
"conn_id": connID,
"data": base64.StdEncoding.EncodeToString(buf[:n]),
},
}
if err := t.send(msg); err != nil {
break
}
}
if err != nil {
break
}
}
// Signal end of connection to the SSO so it can close its OpenLDAP relay.
t.send(WSMessage{
Type: "ldap_tunnel",
Payload: map[string]interface{}{
"conn_id": connID,
"close": true,
},
})
t.mu.Lock()
delete(t.conns, connID)
t.mu.Unlock()
conn.Close()
}
// handleMessage writes SSO→agent tunnel bytes to the matching local socket.
// Called from handleCommand when an `ldap_tunnel` message arrives.
func (t *ldapTunnel) handleMessage(payload map[string]interface{}) {
connID, _ := payload["conn_id"].(string)
if connID == "" {
return
}
if closeFlag, _ := payload["close"].(bool); closeFlag {
t.mu.Lock()
conn := t.conns[connID]
delete(t.conns, connID)
t.mu.Unlock()
if conn != nil {
conn.Close()
}
return
}
dataStr, _ := payload["data"].(string)
if dataStr == "" {
return
}
data, err := base64.StdEncoding.DecodeString(dataStr)
if err != nil {
return
}
t.mu.Lock()
conn := t.conns[connID]
t.mu.Unlock()
if conn != nil {
conn.Write(data)
}
}
var connCounter uint64
func newConnID() string {
connCounter++
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), connCounter)
}
// safeWriter serializes writes to the WebSocket. Gorilla allows only one
// concurrent writer, but the agent has several (telemetry, heartbeat, the LDAP
// tunnel, command responses) — without this, concurrent WriteMessage calls
// corrupt the stream.
type safeWriter struct {
mu sync.Mutex
c *websocket.Conn
}
func (w *safeWriter) WriteMessage(messageType int, data []byte) error {
w.mu.Lock()
defer w.mu.Unlock()
return w.c.WriteMessage(messageType, data)
}
// sendTunnelMessage marshals a WSMessage and writes it as a text frame.
func sendTunnelMessage(w MessageWriter, msg WSMessage) error {
payload, err := json.Marshal(msg)
if err != nil {
return err
}
return w.WriteMessage(websocket.TextMessage, payload)
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"encoding/base64"
"net"
"path/filepath"
"sync"
"testing"
"time"
)
// TestLdapTunnelBytePump verifies the agent is a pure byte pump: bytes written
// to the local socket are forwarded up as ldap_tunnel messages, and bytes sent
// back down are written to the socket. No LDAP parsing anywhere.
func TestLdapTunnelBytePump(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "ldap.sock")
var mu sync.Mutex
var sent []WSMessage
tunnel := newLdapTunnel(func(msg WSMessage) error {
mu.Lock()
sent = append(sent, msg)
mu.Unlock()
return nil
})
stopCh := make(chan struct{})
defer close(stopCh)
go tunnel.start(socketPath, stopCh)
// Wait for the socket to exist.
deadline := time.Now().Add(2 * time.Second)
for {
if _, err := net.Dial("unix", socketPath); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("socket never became reachable")
}
time.Sleep(10 * time.Millisecond)
}
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Write bytes up to the SSO.
if _, err := conn.Write([]byte("hello-ldap")); err != nil {
t.Fatalf("write: %v", err)
}
// The tunnel should forward them as a base64 ldap_tunnel message.
var upMsg WSMessage
deadline = time.Now().Add(2 * time.Second)
for {
mu.Lock()
if len(sent) > 0 {
upMsg = sent[0]
mu.Unlock()
break
}
mu.Unlock()
if time.Now().After(deadline) {
t.Fatal("no ldap_tunnel message was sent")
}
time.Sleep(10 * time.Millisecond)
}
if upMsg.Type != "ldap_tunnel" {
t.Fatalf("expected ldap_tunnel type, got %q", upMsg.Type)
}
connID, _ := upMsg.Payload["conn_id"].(string)
if connID == "" {
t.Fatal("missing conn_id")
}
dataStr, _ := upMsg.Payload["data"].(string)
decoded, err := base64.StdEncoding.DecodeString(dataStr)
if err != nil {
t.Fatalf("bad base64: %v", err)
}
if string(decoded) != "hello-ldap" {
t.Fatalf("expected 'hello-ldap', got %q", decoded)
}
// Send bytes back down from the SSO; the client should receive them.
tunnel.handleMessage(map[string]interface{}{
"conn_id": connID,
"data": base64.StdEncoding.EncodeToString([]byte("world-ldap")),
})
buf := make([]byte, 32)
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(buf[:n]) != "world-ldap" {
t.Fatalf("expected 'world-ldap', got %q", buf[:n])
}
// Closing the client should emit a close signal up to the SSO.
conn.Close()
deadline = time.Now().Add(2 * time.Second)
for {
mu.Lock()
closed := false
for _, m := range sent {
if m.Type == "ldap_tunnel" {
if c, _ := m.Payload["close"].(bool); c {
closed = true
}
}
}
mu.Unlock()
if closed {
break
}
if time.Now().After(deadline) {
t.Fatal("no close signal was sent")
}
time.Sleep(10 * time.Millisecond)
}
}
+6 -1
View File
@@ -5,15 +5,20 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
) )
func main() { func main() {
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
return
}
log.Println("Starting Theta Agent...") log.Println("Starting Theta Agent...")
// Attempt to load configuration // Attempt to load configuration
configPath := "/etc/theta42/agent.yml" configPath := "/etc/theta42/agent.yml"
if len(os.Args) > 1 { if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
configPath = os.Args[1] configPath = os.Args[1]
} }
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
// Secrets engine (DESIGN.md §5). The agent renders local templates that embed
// OpenBao secrets, e.g.:
//
// # /etc/theta/templates/db.env.tpl
// DB_USER="{{ bao "secret/data/nodes/node-42/db#username" }}"
// DB_PASS="{{ bao "secret/data/nodes/node-42/db#password" }}"
//
// The agent parses the `{{ bao "path#key" }}` placeholders, fetches the secret
// values from the SSO (which holds the OpenBao access), renders each template to
// its target atomically (0600), and runs the configured reload. The agent never
// holds a Vault token.
var baoRe = regexp.MustCompile(`\{\{\s*bao\s+"([^"]+)"\s*\}\}`)
// renderSecrets renders every configured secret template. Called on a
// `render_secrets` command (signed) and on boot.
func renderSecrets(cfg *Config, exec Executor) error {
if len(cfg.Secrets) == 0 {
return nil
}
// Collect the unique secret paths referenced across all templates.
pathSet := map[string]bool{}
var paths []string
for _, t := range cfg.Secrets {
content, err := os.ReadFile(t.Template)
if err != nil {
log.Printf("Secrets: cannot read template %s: %v", t.Template, err)
continue
}
for _, m := range baoRe.FindAllStringSubmatch(string(content), -1) {
path := refPath(m[1])
if !pathSet[path] {
pathSet[path] = true
paths = append(paths, path)
}
}
}
if len(paths) == 0 {
return nil
}
secrets, err := fetchSecrets(cfg, paths)
if err != nil {
return err
}
for _, t := range cfg.Secrets {
if err := renderOne(t, secrets, exec); err != nil {
log.Printf("Secrets: render %s failed: %v", t.Template, err)
}
}
return nil
}
func renderOne(t SecretTarget, secrets map[string]map[string]interface{}, exec Executor) error {
content, err := os.ReadFile(t.Template)
if err != nil {
return err
}
out := baoRe.ReplaceAllStringFunc(string(content), func(match string) string {
ref := baoRe.FindStringSubmatch(match)[1]
path, key := refPath(ref), refKey(ref)
if v, ok := secrets[path][key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
})
// Atomic write: temp file in the target's directory, then rename. 0600 — the
// rendered file holds secrets.
tmp, err := os.CreateTemp(filepath.Dir(t.Target), ".theta-secret-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := io.WriteString(tmp, out); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
os.Chmod(tmpName, 0600)
if err := os.Rename(tmpName, t.Target); err != nil {
os.Remove(tmpName)
return err
}
if t.Reload != "" {
if _, err := exec.Execute("sh", "-c", t.Reload); err != nil {
log.Printf("Secrets: reload %q failed: %v", t.Reload, err)
}
}
return nil
}
// fetchSecrets asks the SSO for the given node-scoped secret paths.
func fetchSecrets(cfg *Config, paths []string) (map[string]map[string]interface{}, error) {
base := strings.Replace(cfg.ServerURL, "wss://", "https://", 1)
base = strings.Replace(base, "ws://", "http://", 1)
url := strings.TrimRight(base, "/") + "/api/v1/agent/secrets"
body, _ := json.Marshal(map[string]interface{}{"paths": paths})
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.Credential())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("secrets fetch failed: %s", resp.Status)
}
var out struct {
Secrets map[string]map[string]interface{} `json:"secrets"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out.Secrets, nil
}
// refPath returns the secret path from a `path#key` reference.
func refPath(ref string) string {
path := ref
if i := strings.Index(ref, "#"); i >= 0 {
path = ref[:i]
}
if strings.HasPrefix(path, "resource/") {
return "secret/data/resources/" + strings.TrimPrefix(path, "resource/") + "/conf"
}
if strings.HasPrefix(path, "resources/") {
return "secret/data/resources/" + strings.TrimPrefix(path, "resources/") + "/conf"
}
if !strings.HasPrefix(path, "secret/") {
return "secret/data/resources/" + path + "/conf"
}
return path
}
// refKey returns the key from a `path#key` reference.
func refKey(ref string) string {
if i := strings.Index(ref, "#"); i >= 0 {
return ref[i+1:]
}
return ""
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// TestRenderSecrets verifies the agent parses `{{ bao "path#key" }}` placeholders,
// fetches the secrets from the SSO, renders the template to its target
// atomically, and runs the reload.
func TestRenderSecrets(t *testing.T) {
dir := t.TempDir()
tpl := filepath.Join(dir, "db.env.tpl")
target := filepath.Join(dir, "db.env")
os.WriteFile(tpl, []byte("DB_USER=\"{{ bao \"secret/data/nodes/n1/db#username\" }}\"\nDB_PASS=\"{{ bao \"secret/data/nodes/n1/db#password\" }}\"\n"), 0600)
// Fake SSO secrets endpoint.
var gotPaths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agent/secrets" {
w.WriteHeader(404)
return
}
var req struct{ Paths []string `json:"paths"` }
json.NewDecoder(r.Body).Decode(&req)
gotPaths = req.Paths
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"secrets": map[string]interface{}{
"secret/data/nodes/n1/db": map[string]interface{}{
"username": "alice",
"password": "s3cret",
},
},
})
}))
defer srv.Close()
cfg := &Config{
ServerURL: srv.URL,
AuthToken: "tok",
Secrets: []SecretTarget{
{Template: tpl, Target: target, Reload: ""},
},
}
exec := &MockExecutor{}
if err := renderSecrets(cfg, exec); err != nil {
t.Fatalf("renderSecrets: %v", err)
}
// The requested path should be the one in the template.
if len(gotPaths) != 1 || gotPaths[0] != "secret/data/nodes/n1/db" {
t.Fatalf("expected to request secret/data/nodes/n1/db, got %v", gotPaths)
}
// The target should be rendered with the secret values.
content, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read target: %v", err)
}
expected := "DB_USER=\"alice\"\nDB_PASS=\"s3cret\"\n"
if string(content) != expected {
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
}
// The target should be 0600 (holds secrets).
info, _ := os.Stat(target)
if info.Mode().Perm() != 0600 {
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
}
}
// TestRenderSecretsReload verifies the reload command runs after rendering.
func TestRenderSecretsReload(t *testing.T) {
dir := t.TempDir()
tpl := filepath.Join(dir, "app.tpl")
target := filepath.Join(dir, "app.conf")
os.WriteFile(tpl, []byte("KEY={{ bao \"secret/data/nodes/n1/app#key\" }}"), 0600)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"secrets": map[string]interface{}{
"secret/data/nodes/n1/app": map[string]interface{}{"key": "v"},
},
})
}))
defer srv.Close()
cfg := &Config{
ServerURL: srv.URL,
AuthToken: "tok",
Secrets: []SecretTarget{
{Template: tpl, Target: target, Reload: "systemctl reload app"},
},
}
exec := &MockExecutor{}
if err := renderSecrets(cfg, exec); err != nil {
t.Fatalf("renderSecrets: %v", err)
}
if len(exec.ExecutedCommands) != 1 {
t.Fatalf("expected 1 reload command, got %v", exec.ExecutedCommands)
}
cmd := exec.ExecutedCommands[0]
if len(cmd) != 3 || cmd[0] != "sh" || cmd[2] != "systemctl reload app" {
t.Fatalf("expected reload 'sh -c systemctl reload app', got %v", cmd)
}
}
+280 -35
View File
@@ -3,8 +3,13 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net" "net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings" "strings"
"time" "time"
@@ -15,24 +20,223 @@ import (
"github.com/shirou/gopsutil/v3/mem" "github.com/shirou/gopsutil/v3/mem"
) )
type CPUDetails struct {
Model string `json:"model"`
Cores int `json:"cores"`
Threads int `json:"threads"`
MHz float64 `json:"mhz"`
}
type RAMDetails struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
BuffersCacheBytes uint64 `json:"buffers_cache_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedPercent float64 `json:"used_percent"`
BuffersCachePercent float64 `json:"buffers_cache_percent"`
FreePercent float64 `json:"free_percent"`
}
type DiskItem struct {
Mountpoint string `json:"mountpoint"`
Device string `json:"device"`
FSType string `json:"fstype"`
DriveType string `json:"drivetype"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsagePercent float64 `json:"usage_percent"`
}
type DiscoveryData struct { type DiscoveryData struct {
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
IPs []string `json:"ip_addresses"` IPs []string `json:"ip_addresses"`
OS string `json:"os"` PublicIP string `json:"public_ip"`
Kernel string `json:"kernel"` OS string `json:"os"`
CPUModel string `json:"cpu"` Kernel string `json:"kernel"`
RAMTotalGB float64 `json:"ram_total_gb"` CPUModel string `json:"cpu"`
DiskTotalGB float64 `json:"disk_total_gb"` CPUDetails CPUDetails `json:"cpu_details"`
Location string `json:"location"` RAMTotalGB float64 `json:"ram_total_gb"`
RAMDetails RAMDetails `json:"ram_details"`
DiskTotalGB float64 `json:"disk_total_gb"`
Disks []DiskItem `json:"disks"`
Location string `json:"location"`
Capabilities map[string]interface{} `json:"capabilities"`
} }
type TelemetryData struct { type TelemetryData struct {
CPUUsagePercent float64 `json:"cpu_usage_percent"` CPUUsagePercent float64 `json:"cpu_usage_percent"`
RAMUsagePercent float64 `json:"ram_usage_percent"` CPUDetails CPUDetails `json:"cpu_details"`
DiskUsagePercent float64 `json:"disk_usage_percent"` RAMUsagePercent float64 `json:"ram_usage_percent"`
ZFSHealth string `json:"zfs_health,omitempty"` RAMDetails RAMDetails `json:"ram_details"`
GPUUsage float64 `json:"gpu_usage_percent,omitempty"` DiskUsagePercent float64 `json:"disk_usage_percent"`
Timestamp string `json:"timestamp"` Disks []DiskItem `json:"disks"`
ZFSHealth string `json:"zfs_health,omitempty"`
GPUUsage float64 `json:"gpu_usage_percent,omitempty"`
Timestamp string `json:"timestamp"`
}
func getPublicIP() string {
client := &http.Client{Timeout: 3 * time.Second}
endpoints := []string{
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
}
for _, ep := range endpoints {
resp, err := client.Get(ep)
if err == nil && resp.StatusCode == 200 {
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err == nil {
ip := strings.TrimSpace(string(body))
if net.ParseIP(ip) != nil {
return ip
}
}
}
}
return ""
}
func collectCPUDetails() CPUDetails {
cpuInfo, _ := cpu.Info()
model := "Unknown"
cores := 0
mhz := 0.0
if len(cpuInfo) > 0 {
model = cpuInfo[0].ModelName
if model == "" {
model = cpuInfo[0].Model
}
cores = int(cpuInfo[0].Cores)
mhz = cpuInfo[0].Mhz
}
threads := runtime.NumCPU()
if t, err := cpu.Counts(true); err == nil && t > 0 {
threads = t
}
if cores <= 0 {
if c, err := cpu.Counts(false); err == nil && c > 0 {
cores = c
} else {
cores = threads
}
}
return CPUDetails{
Model: model,
Cores: cores,
Threads: threads,
MHz: mhz,
}
}
func collectRAMDetails() RAMDetails {
vm, err := mem.VirtualMemory()
if err != nil || vm == nil {
return RAMDetails{}
}
bufCache := vm.Buffers + vm.Cached
total := float64(vm.Total)
usedPct := 0.0
bufPct := 0.0
freePct := 0.0
if total > 0 {
usedPct = (float64(vm.Used) / total) * 100.0
bufPct = (float64(bufCache) / total) * 100.0
freePct = (float64(vm.Free) / total) * 100.0
}
return RAMDetails{
TotalBytes: vm.Total,
UsedBytes: vm.Used,
BuffersCacheBytes: bufCache,
FreeBytes: vm.Free,
UsedPercent: usedPct,
BuffersCachePercent: bufPct,
FreePercent: freePct,
}
}
func getDriveType(device string) string {
devName := filepath.Base(device)
devName = strings.TrimRight(devName, "0123456789p")
if strings.HasPrefix(devName, "nvme") {
return "NVMe"
}
rotPath := filepath.Join("/sys/block", devName, "queue/rotational")
data, err := os.ReadFile(rotPath)
if err == nil {
val := strings.TrimSpace(string(data))
if val == "0" {
return "SSD"
} else if val == "1" {
return "HDD"
}
}
return "SSD/HDD"
}
func collectDiskItems() []DiskItem {
var items []DiskItem
partitions, err := disk.Partitions(false)
if err != nil || len(partitions) == 0 {
d, err2 := disk.Usage("/")
if err2 == nil {
items = append(items, DiskItem{
Mountpoint: "/",
FSType: d.Fstype,
DriveType: getDriveType(d.Path),
TotalBytes: d.Total,
UsedBytes: d.Used,
FreeBytes: d.Free,
UsagePercent: d.UsedPercent,
})
}
return items
}
seen := make(map[string]bool)
for _, p := range partitions {
if strings.HasPrefix(p.Mountpoint, "/proc") || strings.HasPrefix(p.Mountpoint, "/sys") || strings.HasPrefix(p.Mountpoint, "/dev") {
continue
}
if seen[p.Mountpoint] {
continue
}
seen[p.Mountpoint] = true
u, err := disk.Usage(p.Mountpoint)
if err != nil || u.Total == 0 {
continue
}
fstype := p.Fstype
if fstype == "" {
fstype = u.Fstype
}
items = append(items, DiskItem{
Mountpoint: p.Mountpoint,
Device: p.Device,
FSType: fstype,
DriveType: getDriveType(p.Device),
TotalBytes: u.Total,
UsedBytes: u.Used,
FreeBytes: u.Free,
UsagePercent: u.UsedPercent,
})
}
if len(items) == 0 {
d, err2 := disk.Usage("/")
if err2 == nil {
items = append(items, DiskItem{
Mountpoint: "/",
FSType: d.Fstype,
DriveType: getDriveType(d.Path),
TotalBytes: d.Total,
UsedBytes: d.Used,
FreeBytes: d.Free,
UsagePercent: d.UsedPercent,
})
}
}
return items
} }
// CollectDiscoveryData gathers static host information. // CollectDiscoveryData gathers static host information.
@@ -49,45 +253,83 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
} }
} }
vm, _ := mem.VirtualMemory() vm := collectRAMDetails()
d, _ := disk.Usage("/") disks := collectDiskItems()
cpuDet := collectCPUDetails()
cpuInfo, _ := cpu.Info() pubIP := getPublicIP()
cpuModel := "Unknown"
if len(cpuInfo) > 0 { diskTotalGB := 0.0
cpuModel = cpuInfo[0].Model for _, d := range disks {
if d.Mountpoint == "/" {
diskTotalGB = float64(d.TotalBytes) / (1024 * 1024 * 1024)
break
}
}
if diskTotalGB == 0 && len(disks) > 0 {
diskTotalGB = float64(disks[0].TotalBytes) / (1024 * 1024 * 1024)
} }
return DiscoveryData{ return DiscoveryData{
Hostname: h.Hostname, Hostname: h.Hostname,
IPs: ips, IPs: ips,
OS: fmt.Sprintf("%s %s", h.OS, h.Platform), PublicIP: pubIP,
Kernel: h.KernelVersion, OS: fmt.Sprintf("%s %s", h.OS, h.Platform),
CPUModel: cpuModel, Kernel: h.KernelVersion,
RAMTotalGB: float64(vm.Total) / (1024 * 1024 * 1024), CPUModel: cpuDet.Model,
DiskTotalGB: float64(d.Total) / (1024 * 1024 * 1024), CPUDetails: cpuDet,
Location: cfg.Location, RAMTotalGB: float64(vm.TotalBytes) / (1024 * 1024 * 1024),
RAMDetails: vm,
DiskTotalGB: diskTotalGB,
Disks: disks,
Location: cfg.Location,
Capabilities: map[string]interface{}{
"telemetry": cfg.Capabilities.Telemetry,
"configure_ldap": cfg.Capabilities.ConfigureLDAP,
"ldap_tunnel": cfg.Capabilities.LdapTunnel,
"secrets": cfg.Capabilities.Secrets,
"iam": cfg.Capabilities.IAM,
"reboot": cfg.Capabilities.Reboot,
"shutdown": true,
"service_control": cfg.Capabilities.ServiceControl,
"arbitrary_bash": cfg.Capabilities.ArbitraryBash,
},
} }
} }
// CollectTelemetryData gathers real-time performance metrics including ZFS and GPU. // CollectTelemetryData gathers real-time performance metrics including ZFS and GPU.
func CollectTelemetryData(exec Executor) TelemetryData { func CollectTelemetryData(exec Executor) TelemetryData {
cpuPerc, _ := cpu.Percent(time.Second, false) cpuPerc, _ := cpu.Percent(time.Second, false)
vm, _ := mem.VirtualMemory() vm := collectRAMDetails()
d, _ := disk.Usage("/") disks := collectDiskItems()
cpuDet := collectCPUDetails()
cpuVal := 0.0 cpuVal := 0.0
if len(cpuPerc) > 0 { if len(cpuPerc) > 0 {
cpuVal = cpuPerc[0] cpuVal = cpuPerc[0]
} }
diskVal := 0.0
for _, d := range disks {
if d.Mountpoint == "/" {
diskVal = d.UsagePercent
break
}
}
if diskVal == 0 && len(disks) > 0 {
diskVal = disks[0].UsagePercent
}
return TelemetryData{ return TelemetryData{
CPUUsagePercent: cpuVal, CPUUsagePercent: cpuVal,
CPUDetails: cpuDet,
RAMUsagePercent: vm.UsedPercent, RAMUsagePercent: vm.UsedPercent,
DiskUsagePercent: d.UsedPercent, RAMDetails: vm,
ZFSHealth: collectZFSHealth(exec), DiskUsagePercent: diskVal,
GPUUsage: collectGPUUsage(exec), Disks: disks,
Timestamp: time.Now().Format(time.RFC3339), ZFSHealth: collectZFSHealth(exec),
GPUUsage: collectGPUUsage(exec),
Timestamp: time.Now().Format(time.RFC3339),
} }
} }
@@ -154,10 +396,13 @@ func StartTelemetryLoop(c MessageWriter, cm *ConfigManager, exec Executor, stopC
Type: "telemetry", Type: "telemetry",
Payload: map[string]interface{}{ Payload: map[string]interface{}{
"cpu_usage_percent": telemetry.CPUUsagePercent, "cpu_usage_percent": telemetry.CPUUsagePercent,
"cpu_details": telemetry.CPUDetails,
"ram_usage_percent": telemetry.RAMUsagePercent, "ram_usage_percent": telemetry.RAMUsagePercent,
"ram_details": telemetry.RAMDetails,
"disk_usage_percent": telemetry.DiskUsagePercent, "disk_usage_percent": telemetry.DiskUsagePercent,
"disks": telemetry.Disks,
"zfs_health": telemetry.ZFSHealth, "zfs_health": telemetry.ZFSHealth,
"gpu_usage_percent": telemetry.GPUUsage, "gpu_usage_percent": telemetry.GPUUsage,
"timestamp": telemetry.Timestamp, "timestamp": telemetry.Timestamp,
}, },
}) })
BIN
View File
Binary file not shown.
Binary file not shown.
+297 -13
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"crypto/ed25519" "crypto/ed25519"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
@@ -23,14 +24,55 @@ type WSMessage struct {
Payload map[string]interface{} `json:"payload"` Payload map[string]interface{} `json:"payload"`
} }
// Application close codes the SSO uses to say "your enrollment is the problem"
// (PROTOCOL.md §1.1). All three mean retrying quickly is pointless.
const (
closeUnauthorized = 4001 // token was never issued, or is unknown
closeSuperseded = 4002 // another connection took over this enrollment
closeRevoked = 4003 // enrollment revoked or deleted by an admin
closeTokenRotated = 4004 // token rotated; agent.yml holds the old one
)
// How long to wait before retrying after the server rejects our credential.
// Short enough that a re-enrollment is picked up without a restart, long enough
// that a decommissioned agent is not a permanent load on the SSO.
const authRetryInterval = 5 * time.Minute
type MessageWriter interface { type MessageWriter interface {
WriteMessage(messageType int, data []byte) error WriteMessage(messageType int, data []byte) error
} }
// canonicalize produces the exact bytes the server signed (PROTOCOL.md §5):
// keys sorted alphabetically, no whitespace, `signature` omitted.
//
// encoding/json sorts map keys for us, but by default it also escapes <, > and
// & as <, > and & -- which Node's JSON.stringify on the server
// does not. Any payload containing those characters therefore hashed
// differently on each side and the signature failed. For arbitrary_bash that is
// most real scripts: `>` redirection and `&&` are everywhere. SetEscapeHTML
// (false) is what makes the two encoders agree.
//
// Encoder.Encode also appends a trailing newline, which must be trimmed or it
// is signed-over data the server never produced.
func canonicalize(payload map[string]interface{}) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(payload); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
func verifySignature(cfg *Config, msg WSMessage) bool { func verifySignature(cfg *Config, msg WSMessage) bool {
// Fail CLOSED. This used to return true when no public key was configured,
// which meant an agent installed without a `public_key` would execute
// reboot / configure_ldap / arbitrary_bash from anything that could reach
// its socket, with no verification at all -- the exact commands the
// signature exists to protect. An agent that cannot verify must not act.
if cfg.PublicKey == "" { if cfg.PublicKey == "" {
log.Println("No public key configured; skipping signature verification") log.Println("Refusing high-risk command: no public_key configured in agent.yml")
return true return false
} }
sigB64, ok := msg.Payload["signature"].(string) sigB64, ok := msg.Payload["signature"].(string)
@@ -52,7 +94,11 @@ func verifySignature(cfg *Config, msg WSMessage) bool {
payloadCopy[k] = v payloadCopy[k] = v
} }
} }
canonicalPayload, _ := json.Marshal(payloadCopy) canonicalPayload, err := canonicalize(payloadCopy)
if err != nil {
log.Printf("Could not canonicalize payload for verification: %v", err)
return false
}
pubKeyBytes, err := base64.StdEncoding.DecodeString(cfg.PublicKey) pubKeyBytes, err := base64.StdEncoding.DecodeString(cfg.PublicKey)
if err != nil || len(pubKeyBytes) != ed25519.PublicKeySize { if err != nil || len(pubKeyBytes) != ed25519.PublicKeySize {
@@ -75,12 +121,36 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
log.Fatalf("Invalid ServerURL: %v", err) log.Fatalf("Invalid ServerURL: %v", err)
} }
u.Path = "/api/agent/ws" u.Path = "/api/agent/ws"
u.RawQuery = "token=" + cfg.AuthToken // Our own token once enrolled, else the join key. The hostname lets the
// server name a self-enrolling host something meaningful instead of a
// generated placeholder.
q := url.Values{}
q.Set("token", cfg.Credential())
if hn, err := os.Hostname(); err == nil && hn != "" {
q.Set("hostname", hn)
}
u.RawQuery = q.Encode()
log.Printf("Connecting to %s", u.String()) if cfg.Credential() == "" {
log.Printf("No auth_token or join_key in %s -- nothing to authenticate with. Retrying in %s.", cm.configPath, authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) // Never log u.String(): RawQuery carries the auth token, and agent logs
// are routinely shipped around and pasted into issues.
log.Printf("Connecting to %s%s", u.Host, u.Path)
c, resp, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil { if err != nil {
// The server now rejects tokens it did not issue. Retrying a bad
// credential every 5s just floods the SSO and its audit log
// forever, so back off hard and say plainly what is wrong.
if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the SSO Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
log.Printf("Dial error: %v. Retrying in 5 seconds...", err) log.Printf("Dial error: %v. Retrying in 5 seconds...", err)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
continue continue
@@ -90,8 +160,26 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
stopCh := make(chan struct{}) stopCh := make(chan struct{})
// All outbound writes go through the safe writer: gorilla allows only one
// concurrent writer, and telemetry, heartbeat, the LDAP tunnel and command
// responses all write to the same socket.
sw := &safeWriter{c: c}
// Local LDAP byte-pump tunnel (DESIGN.md §4). The agent never parses LDAP;
// it forwards raw bytes to the SSO and writes the responses back.
tunnel := newLdapTunnel(func(msg WSMessage) error {
return sendTunnelMessage(sw, msg)
})
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
socketPath := cfg.LdapSocket
if socketPath == "" {
socketPath = "/run/theta/ldap.sock"
}
go tunnel.start(socketPath, stopCh)
}
// Start telemetry and discovery with stopCh lifecycle control // Start telemetry and discovery with stopCh lifecycle control
StartTelemetryLoop(c, cm, exec, stopCh) StartTelemetryLoop(sw, cm, exec, stopCh)
// Heartbeat loop // Heartbeat loop
go func() { go func() {
@@ -104,18 +192,31 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
case <-ticker.C: case <-ticker.C:
hb := WSMessage{Type: "heartbeat", Payload: map[string]interface{}{"timestamp": time.Now().Format(time.RFC3339)}} hb := WSMessage{Type: "heartbeat", Payload: map[string]interface{}{"timestamp": time.Now().Format(time.RFC3339)}}
payload, _ := json.Marshal(hb) payload, _ := json.Marshal(hb)
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil { if err := sw.WriteMessage(websocket.TextMessage, payload); err != nil {
return return
} }
} }
} }
}() }()
// Set when the server closes us for an enrollment problem rather than a
// transient fault, so the reconnect below can back off instead of
// spinning on a credential that will not start working by itself.
authRejected := false
// Read loop // Read loop
for { for {
_, message, err := c.ReadMessage() _, message, err := c.ReadMessage()
if err != nil { if err != nil {
log.Println("WebSocket read error:", err) // The SSO accepts the upgrade and only then closes with an
// application code, so an auth failure surfaces here rather
// than at Dial.
if websocket.IsCloseError(err, closeUnauthorized, closeRevoked, closeTokenRotated) {
authRejected = true
log.Printf("Server closed the connection: %v. This agent's token is not valid for that SSO — re-enroll it and update agent.yml.", err)
} else {
log.Println("WebSocket read error:", err)
}
break // break read loop, reconnect break // break read loop, reconnect
} }
@@ -125,21 +226,33 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
continue continue
} }
handleCommand(cm, msg, c, exec) handleCommand(cm, msg, sw, exec, tunnel)
} }
// Cleanup on disconnect // Cleanup on disconnect
close(stopCh) close(stopCh)
c.Close() c.Close()
if authRejected {
log.Printf("Reconnecting in %s.", authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
log.Println("WebSocket disconnected. Reconnecting in 5 seconds...") log.Println("WebSocket disconnected. Reconnecting in 5 seconds...")
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
} }
} }
func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Executor) { func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Executor, tunnel *ldapTunnel) {
cfg := cm.Get() cfg := cm.Get()
log.Printf("Received command: %s", msg.Type) // Don't log the server's fire-and-forget heartbeat ack — it arrives every
// 60s and is not a command to act on; logging it is pure per-minute noise.
// The LDAP tunnel is high-frequency (every chunk of a bind/search), so it is
// not logged either.
if msg.Type != "heartbeat_ack" && msg.Type != "ldap_tunnel" {
log.Printf("Received command: %s", msg.Type)
}
sendResponse := func(status string, message string) { sendResponse := func(status string, message string) {
resp, _ := json.Marshal(map[string]string{"status": status, "message": message}) resp, _ := json.Marshal(map[string]string{"status": status, "message": message})
@@ -147,6 +260,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
} }
switch msg.Type { switch msg.Type {
case "ldap_tunnel":
// SSO→agent direction of the LDAP byte pump: write the response bytes to
// the matching local socket.
if tunnel != nil {
tunnel.handleMessage(msg.Payload)
}
case "reload_config": case "reload_config":
if err := cm.Reload(); err != nil { if err := cm.Reload(); err != nil {
log.Printf("Reload failed: %v", err) log.Printf("Reload failed: %v", err)
@@ -170,6 +289,9 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
linesCount := 100 linesCount := 100
if l, ok := msg.Payload["lines"].(float64); ok && l > 0 { if l, ok := msg.Payload["lines"].(float64); ok && l > 0 {
linesCount = int(l) linesCount = int(l)
if linesCount > 2000 {
linesCount = 2000
}
} }
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount) log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
@@ -213,6 +335,24 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
sendResponse("ok", "update applied successfully; restarting agent...") sendResponse("ok", "update applied successfully; restarting agent...")
os.Exit(0) os.Exit(0)
case "config": case "config":
// A config frame carrying credentials means the server accepted our
// join key and enrolled this host. Persist what it issued -- our own
// per-agent token and the public key to pin -- so the next connection
// authenticates as this agent rather than re-enrolling, and so signed
// commands can be verified. This is what lets an install ship with only
// a join key and still end up fully configured.
if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled {
token, _ := msg.Payload["auth_token"].(string)
pubKey, _ := msg.Payload["public_key"].(string)
if err := cm.PersistEnrollment(token, pubKey); err != nil {
log.Printf("Enrolled, but could not persist credentials: %v", err)
log.Printf("This agent will re-enroll on every reconnect until %s is writable.", cm.configPath)
} else {
log.Printf("Enrolled with the SSO. Credentials written to %s; the join key is no longer needed.", cm.configPath)
}
sendResponse("ok", "enrollment stored")
return
}
log.Printf("Received config payload: %v", msg.Payload) log.Printf("Received config payload: %v", msg.Payload)
sendResponse("ok", "Configuration received") sendResponse("ok", "Configuration received")
case "reboot": case "reboot":
@@ -232,6 +372,52 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return return
} }
sendResponse("ok", "system rebooting") sendResponse("ok", "system rebooting")
case "shutdown":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
if !cfg.Capabilities.Reboot {
log.Println("Shutdown rejected: capability disabled in agent.yml")
sendResponse("error", "shutdown capability disabled")
return
}
log.Printf("Executing shutdown...")
sendResponse("ok", "system shutting down")
if _, err := exec.Execute("shutdown", "-h", "now"); err != nil {
exec.Execute("poweroff")
}
return
case "systemd_action":
serviceName, _ := msg.Payload["service"].(string)
action, _ := msg.Payload["action"].(string)
if serviceName == "" {
sendResponse("error", "service name required")
return
}
if action == "" {
action = "status"
}
if action != "status" && !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
log.Printf("Executing systemctl %s %s...", action, serviceName)
out, err := exec.Execute("systemctl", action, serviceName)
errMsg := ""
if err != nil {
errMsg = err.Error()
}
respMap := map[string]interface{}{
"status": "ok",
"service": serviceName,
"action": action,
"output": string(out),
"error": errMsg,
}
respPayload, _ := json.Marshal(respMap)
c.WriteMessage(websocket.TextMessage, respPayload)
return
case "service_restart": case "service_restart":
serviceName, ok := msg.Payload["service"].(string) serviceName, ok := msg.Payload["service"].(string)
if !ok || !cfg.Capabilities.CanManageService(serviceName) { if !ok || !cfg.Capabilities.CanManageService(serviceName) {
@@ -265,19 +451,110 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
} }
log.Println("Pushing updated SSSD configuration...") log.Println("Pushing updated SSSD configuration...")
_ = os.MkdirAll("/etc/sssd", 0755)
if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil { if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
log.Printf("Failed to write SSSD config: %v", err) log.Printf("Failed to write SSSD config: %v", err)
sendResponse("error", "failed to write config") sendResponse("error", "failed to write config")
return return
} }
// Ensure /etc/nsswitch.conf enables sss for passwd, group, shadow, sudoers
if nssBytes, err := os.ReadFile("/etc/nsswitch.conf"); err == nil {
nssContent := string(nssBytes)
updatedNss := false
lines := strings.Split(nssContent, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if (strings.HasPrefix(trimmed, "passwd:") || strings.HasPrefix(trimmed, "group:") || strings.HasPrefix(trimmed, "shadow:") || strings.HasPrefix(trimmed, "sudoers:")) && !strings.Contains(trimmed, "sss") {
lines[i] = line + " sss"
updatedNss = true
}
}
if updatedNss {
_ = os.WriteFile("/etc/nsswitch.conf", []byte(strings.Join(lines, "\n")), 0644)
}
}
log.Println("Restarting SSSD service...") log.Println("Restarting SSSD service...")
if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil { if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil {
log.Printf("SSSD restart failed: %v", err) log.Printf("SSSD restart failed (%v), attempting auto-install of missing packages...", err)
if _, err2 := exec.Execute("sh", "-c", "DEBIAN_FRONTEND=noninteractive apt-get update -y -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || dnf install -y sssd sssd-ldap sssd-tools || yum install -y sssd sssd-ldap sssd-tools"); err2 == nil {
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
if _, err3 := exec.Execute("systemctl", "restart", "sssd"); err3 == nil {
// Configure SSH AuthorizedKeysCommand
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
_ = os.WriteFile(sshConfPath, []byte(sshConfContent), 0644)
_, _ = exec.Execute("systemctl", "reload", "sshd")
sendResponse("ok", "LDAP configuration updated")
return
}
}
sendResponse("error", "failed to restart sssd") sendResponse("error", "failed to restart sssd")
return return
} }
// Ensure /etc/ssh/sshd_config.d/theta-sssd.conf is created for SSH AuthorizedKeysCommand
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
if err := os.WriteFile(sshConfPath, []byte(sshConfContent), 0644); err == nil {
_, _ = exec.Execute("systemctl", "reload", "sshd")
}
if sshdBytes, err2 := os.ReadFile("/etc/ssh/sshd_config"); err2 == nil {
sshdStr := string(sshdBytes)
if !strings.Contains(sshdStr, "sss_ssh_authorizedkeys") {
sshdStr += "\nAuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
_ = os.WriteFile("/etc/ssh/sshd_config", []byte(sshdStr), 0644)
_, _ = exec.Execute("systemctl", "reload", "sshd")
}
}
// Ensure PAM mkhomedir is enabled
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
sendResponse("ok", "LDAP configuration updated") sendResponse("ok", "LDAP configuration updated")
case "render_secrets":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
if !cfg.Capabilities.Secrets {
log.Println("Secrets render rejected: capability disabled in agent.yml")
sendResponse("error", "secrets capability disabled")
return
}
log.Println("Rendering secret templates...")
if err := renderSecrets(cfg, exec); err != nil {
log.Printf("Secrets render failed: %v", err)
sendResponse("error", fmt.Sprintf("secrets render failed: %v", err))
return
}
sendResponse("ok", "secrets rendered")
case "iam_apply":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
if !cfg.Capabilities.IAM {
log.Println("IAM apply rejected: capability disabled in agent.yml")
sendResponse("error", "iam capability disabled")
return
}
payload, err := parseIAMPayload(msg.Payload)
if err != nil {
log.Printf("IAM apply: bad payload: %v", err)
sendResponse("error", "invalid IAM payload")
return
}
log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID)
if err := applyIAM(payload, exec); err != nil {
log.Printf("IAM apply failed: %v", err)
sendResponse("error", fmt.Sprintf("iam apply failed: %v", err))
return
}
sendResponse("ok", "iam applied")
case "arbitrary_bash": case "arbitrary_bash":
if !verifySignature(cfg, msg) { if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed") sendResponse("error", "signature verification failed")
@@ -311,6 +588,13 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
respPayload, _ := json.Marshal(resp) respPayload, _ := json.Marshal(resp)
c.WriteMessage(websocket.TextMessage, respPayload) c.WriteMessage(websocket.TextMessage, respPayload)
return return
// heartbeat_ack is the server's acknowledgement of the agent's own periodic
// heartbeat (the agent sends `heartbeat`, the server answers `heartbeat_ack`).
// There is nothing to do with it -- it is not a command to run, and answering
// an ack with an error response would inject spurious errors into the
// command-response channel every minute. Silently ignore.
case "heartbeat_ack":
return
default: default:
log.Printf("Unknown command type: %s", msg.Type) log.Printf("Unknown command type: %s", msg.Type)
sendResponse("error", "unknown command type") sendResponse("error", "unknown command type")
+141 -10
View File
@@ -1,11 +1,42 @@
package main package main
import ( import (
"crypto/ed25519"
"encoding/base64"
"encoding/json" "encoding/json"
"os" "os"
"testing" "testing"
) )
// A fixed key pair for the tests, standing in for the SSO's persisted signing
// key. High-risk commands must now be genuinely signed: the agent fails closed
// when no public_key is configured, so these tests sign the way the server
// does instead of relying on verification being skipped.
var testPubKey, testPrivKey, _ = ed25519.GenerateKey(nil)
func testPubKeyB64() string {
return base64.StdEncoding.EncodeToString(testPubKey)
}
// sign mirrors the server's canonicalization (sorted keys, no whitespace, no
// HTML escaping, `signature` omitted) and adds the signature to the payload.
func sign(t *testing.T, payload map[string]interface{}) map[string]interface{} {
t.Helper()
if payload == nil {
payload = map[string]interface{}{}
}
canonical, err := canonicalize(payload)
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
signed := make(map[string]interface{}, len(payload)+1)
for k, v := range payload {
signed[k] = v
}
signed["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(testPrivKey, canonical))
return signed
}
type MockConn struct { type MockConn struct {
Messages [][]byte Messages [][]byte
} }
@@ -44,13 +75,19 @@ func (m *MockExecutor) ReadFile(path string) ([]byte, error) {
func TestHandleCommand(t *testing.T) { func TestHandleCommand(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
cfg *Config cfg *Config
msg WSMessage msg WSMessage
expectedStatus string expectedStatus string
expectedCmd []string expectedCmd []string
expectedFile string expectedFile string
expectedFileCont string expectedFileCont string
// sign the payload with the test key before dispatch, the way the SSO
// signs high-risk commands
signed bool
// heartbeat_ack (and any fire-and-forget ack) must be silently ignored —
// no response message, no command, no log noise.
expectedNoResponse bool
}{ }{
{ {
name: "config command success", name: "config command success",
@@ -66,11 +103,13 @@ func TestHandleCommand(t *testing.T) {
{ {
name: "reboot command allowed", name: "reboot command allowed",
cfg: &Config{ cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{Reboot: true}, Capabilities: Capabilities{Reboot: true},
}, },
msg: WSMessage{ msg: WSMessage{
Type: "reboot", Type: "reboot",
}, },
signed: true,
expectedStatus: "ok", expectedStatus: "ok",
expectedCmd: []string{"reboot"}, expectedCmd: []string{"reboot"},
}, },
@@ -120,6 +159,7 @@ func TestHandleCommand(t *testing.T) {
{ {
name: "configure_ldap allowed", name: "configure_ldap allowed",
cfg: &Config{ cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{ConfigureLDAP: true}, Capabilities: Capabilities{ConfigureLDAP: true},
}, },
msg: WSMessage{ msg: WSMessage{
@@ -128,10 +168,11 @@ func TestHandleCommand(t *testing.T) {
"config": "domain = theta42.local\nserver = sso.local", "config": "domain = theta42.local\nserver = sso.local",
}, },
}, },
expectedStatus: "ok", signed: true,
expectedFile: "/etc/sssd/sssd.conf", expectedStatus: "ok",
expectedFile: "/etc/sssd/sssd.conf",
expectedFileCont: "domain = theta42.local\nserver = sso.local", expectedFileCont: "domain = theta42.local\nserver = sso.local",
expectedCmd: []string{"systemctl", "restart", "sssd"}, expectedCmd: []string{"systemctl", "restart", "sssd"},
}, },
{ {
name: "configure_ldap denied", name: "configure_ldap denied",
@@ -150,6 +191,7 @@ func TestHandleCommand(t *testing.T) {
{ {
name: "arbitrary_bash allowed", name: "arbitrary_bash allowed",
cfg: &Config{ cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{ArbitraryBash: true}, Capabilities: Capabilities{ArbitraryBash: true},
}, },
msg: WSMessage{ msg: WSMessage{
@@ -158,6 +200,7 @@ func TestHandleCommand(t *testing.T) {
"script": "uptime", "script": "uptime",
}, },
}, },
signed: true,
expectedStatus: "ok", expectedStatus: "ok",
expectedCmd: []string{"bash", "-c", "uptime"}, expectedCmd: []string{"bash", "-c", "uptime"},
}, },
@@ -175,6 +218,16 @@ func TestHandleCommand(t *testing.T) {
expectedStatus: "error", expectedStatus: "error",
expectedCmd: nil, expectedCmd: nil,
}, },
{
name: "heartbeat_ack is silently ignored",
cfg: &Config{
Capabilities: Capabilities{},
},
msg: WSMessage{
Type: "heartbeat_ack",
},
expectedNoResponse: true,
},
{ {
name: "unknown command", name: "unknown command",
cfg: &Config{ cfg: &Config{
@@ -192,7 +245,21 @@ func TestHandleCommand(t *testing.T) {
mockConn := &MockConn{} mockConn := &MockConn{}
mockExec := &MockExecutor{} mockExec := &MockExecutor{}
cm := &ConfigManager{current: tc.cfg} cm := &ConfigManager{current: tc.cfg}
handleCommand(cm, tc.msg, mockConn, mockExec) msg := tc.msg
if tc.signed {
msg.Payload = sign(t, msg.Payload)
}
handleCommand(cm, msg, mockConn, mockExec, nil)
if tc.expectedNoResponse {
if len(mockConn.Messages) != 0 {
t.Fatalf("expected no response message, got %d: %v", len(mockConn.Messages), mockConn.Messages)
}
if len(mockExec.ExecutedCommands) > 0 {
t.Errorf("expected no commands to be executed, but got %v", mockExec.ExecutedCommands)
}
return
}
if len(mockConn.Messages) != 1 { if len(mockConn.Messages) != 1 {
t.Fatalf("expected 1 response message, got %d", len(mockConn.Messages)) t.Fatalf("expected 1 response message, got %d", len(mockConn.Messages))
@@ -236,3 +303,67 @@ func TestHandleCommand(t *testing.T) {
}) })
} }
} }
// The agent must not execute a high-risk command it cannot verify. This used to
// return true when no public_key was configured, so an agent installed without
// one executed reboot / configure_ldap / arbitrary_bash unverified.
func TestVerifySignatureFailsClosedWithoutPublicKey(t *testing.T) {
cfg := &Config{} // no PublicKey
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
if verifySignature(cfg, msg) {
t.Fatal("verifySignature accepted a command with no public_key configured")
}
}
func TestVerifySignatureRejectsWrongKey(t *testing.T) {
otherPub, _, _ := ed25519.GenerateKey(nil)
cfg := &Config{PublicKey: base64.StdEncoding.EncodeToString(otherPub)}
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
if verifySignature(cfg, msg) {
t.Fatal("verifySignature accepted a signature from a different key")
}
}
func TestVerifySignatureRejectsTamperedPayload(t *testing.T) {
cfg := &Config{PublicKey: testPubKeyB64()}
payload := sign(t, map[string]interface{}{"script": "uptime"})
payload["script"] = "rm -rf /" // swap the script, keep the signature
if verifySignature(cfg, WSMessage{Type: "arbitrary_bash", Payload: payload}) {
t.Fatal("verifySignature accepted a payload modified after signing")
}
}
// Regression: encoding/json escapes <, > and & by default, but the server's
// JSON.stringify does not. Any script using redirection or && therefore
// canonicalized differently on each side and failed verification -- which is
// most real scripts.
func TestVerifySignatureAcceptsShellMetacharacters(t *testing.T) {
cfg := &Config{PublicKey: testPubKeyB64()}
for _, script := range []string{
"echo hi > /tmp//out.log",
"systemctl is-active nginx && systemctl reload nginx",
"grep -c . < /etc/passwd",
"a=1 && b=2 && echo \"$a<$b\" > /dev/null",
} {
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": script})}
if !verifySignature(cfg, msg) {
t.Errorf("verifySignature rejected a correctly signed script: %q", script)
}
}
}
// The canonical form must be byte-identical to the server's: sorted keys, no
// whitespace, no HTML escaping, no trailing newline, signature omitted.
func TestCanonicalizeMatchesServerForm(t *testing.T) {
got, err := canonicalize(map[string]interface{}{
"script": "echo a > b && c",
"comment": "x&y",
})
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
want := `{"comment":"x&y","script":"echo a > b && c"}`
if string(got) != want {
t.Errorf("canonical form mismatch:\n got: %s\nwant: %s", got, want)
}
}