diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81c5fb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Binaries and build output +theta-agent +theta-agent-* +dist/ +*.exe diff --git a/CHANGELOG.md b/CHANGELOG.md index 52eb4fe..0c80da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 `).** 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 ]` 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//#" }}`). + 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-`, 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.1–5.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 `, 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 ### Added diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..0880637 --- /dev/null +++ b/DESIGN.md @@ -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:, 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 `). +- **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-`, 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 ` + (§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/`). 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//*`). Confirm this matches how node secrets are + provisioned in OpenBao (§5). diff --git a/INSTALL.md b/INSTALL.md index a583d37..f060672 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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. +## 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) 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 Use this for rapid deployment with basic telemetry: ```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 "" --public-key "" ``` ### What this does: diff --git a/PROTOCOL.md b/PROTOCOL.md index bba2750..a4dfbe9 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -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). @@ -7,8 +7,55 @@ This document defines the communication protocol between the `theta-agent` (Clie The agent establishes a persistent outbound WebSocket connection. - **Endpoint**: `wss:///api/agent/ws` -- **Authentication**: The agent must provide a unique host token as a query parameter: - - `wss:///api/agent/ws?token=` +- **Authentication**: The agent must provide its enrollment token as a query parameter: + - `wss:///api/agent/ws?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=`. 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 @@ -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.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.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 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`. | | `arbitrary_bash` | `{ "script": "...", "signature": "..." }` | Executes raw bash script. | | `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 To send a high-risk command: 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. 4. Add the base64 signature to the payload: `{"script": "uptime", "signature": "..."}`. 5. Send as a `WSMessage`. The agent performs the reverse process to verify authenticity before execution. + +### 5.1 Canonical form + +Both sides must produce **byte-identical** input to sign/verify: + +- keys sorted alphabetically +- no insignificant whitespace +- the `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. diff --git a/README.md b/README.md index 611653d..4c53ae5 100644 --- a/README.md +++ b/README.md @@ -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. +## 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 ### 1. Telemetry & Observability @@ -39,12 +50,26 @@ The agent will **only** execute commands that are explicitly enabled in its loca ### 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. +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 | Risk Level | Description | Impact | |------------|------------|-------------|---------| | `telemetry` | Safe | Read-only metrics. | Pushes system health to SSO Manager. | | `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. | | `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. | @@ -56,7 +81,7 @@ Configuration is stored in YAML format at `/etc/theta42/agent.yml`. ### Example `agent.yml` ```yaml 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" location: "dc-01-rack-12" capabilities: @@ -71,7 +96,12 @@ capabilities: 1. **Build**: Compile for your target architecture (see CI/CD artifacts). 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`). 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" ``` +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 "" --public-key "" +``` + ## Development & Testing The agent uses a decoupled execution engine for safety and testability. diff --git a/agent.yml.example b/agent.yml.example index eb6e529..d8874dc 100644 --- a/agent.yml.example +++ b/agent.yml.example @@ -2,19 +2,63 @@ # Default location: /etc/theta42/agent.yml 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 +# 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: # --------------------------------------------------------- # Basic Capabilities (Safe, read-only or infrastructure management) # --------------------------------------------------------- - + # Push CPU, RAM, GPU, and ZFS metrics to the SSO Manager telemetry: true - + # Allow the SSO Manager to push down SSSD and SSH keys configuration 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//#" }}. +# secrets: +# - template: /etc/theta/templates/db.env.tpl +# target: /etc/theta/db.env +# reload: systemctl reload app # --------------------------------------------------------- # Advanced Capabilities (High risk, remote operations) diff --git a/build_all.sh b/build_all.sh new file mode 100755 index 0000000..9964cb5 --- /dev/null +++ b/build_all.sh @@ -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" diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..befc315 --- /dev/null +++ b/cli.go @@ -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 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 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 +} diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..b2eb2a6 --- /dev/null +++ b/cli_test.go @@ -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") + } +} diff --git a/config.go b/config.go index 332e8ac..7fd8c60 100644 --- a/config.go +++ b/config.go @@ -3,6 +3,8 @@ package main import ( "fmt" "os" + "regexp" + "strings" "sync" "gopkg.in/yaml.v3" @@ -14,20 +16,47 @@ type Capabilities struct { Reboot bool `yaml:"reboot"` ServiceControl []string `yaml:"service_control"` 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 { - ServerURL string `yaml:"server_url"` - AuthToken string `yaml:"auth_token"` + ServerURL string `yaml:"server_url"` + 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"` 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. type ConfigManager struct { - mu sync.RWMutex - current *Config + mu sync.RWMutex + current *Config configPath string } @@ -61,6 +90,62 @@ func (cm *ConfigManager) Reload() error { 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) { file, err := os.Open(path) if err != nil { @@ -74,6 +159,10 @@ func LoadConfig(path string) (*Config, error) { return nil, fmt.Errorf("failed to decode YAML config: %w", err) } + if cfg.Capabilities.ConfigureLDAP { + cfg.Capabilities.LdapTunnel = true + } + return &cfg, nil } diff --git a/config_test.go b/config_test.go index ebe85fd..f6dd76e 100644 --- a/config_test.go +++ b/config_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "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") + } +} diff --git a/demo/Dockerfile b/demo/Dockerfile new file mode 100644 index 0000000..95f6ddd --- /dev/null +++ b/demo/Dockerfile @@ -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"] diff --git a/demo/get-secret.js b/demo/get-secret.js new file mode 100644 index 0000000..794d9c4 --- /dev/null +++ b/demo/get-secret.js @@ -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); +} diff --git a/demo/get-secret.sh b/demo/get-secret.sh new file mode 100644 index 0000000..9658083 --- /dev/null +++ b/demo/get-secret.sh @@ -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 diff --git a/go.mod b/go.mod index 24fcff7..1f2a163 100644 --- a/go.mod +++ b/go.mod @@ -2,16 +2,19 @@ module github.com/theta42/theta-agent 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 ( 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/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/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/sys v0.20.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 17b6aae..7668791 100644 --- a/go.sum +++ b/go.sum @@ -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/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.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/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/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/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/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= 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/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/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 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= +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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/iam.go b/iam.go new file mode 100644 index 0000000..84b18b2 --- /dev/null +++ b/iam.go @@ -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-, 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 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 +} diff --git a/iam_test.go b/iam_test.go new file mode 100644 index 0000000..23da527 --- /dev/null +++ b/iam_test.go @@ -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) + } +} diff --git a/install.sh b/install.sh index e097f53..8b2d750 100644 --- a/install.sh +++ b/install.sh @@ -19,16 +19,46 @@ log() { echo -e "${GREEN}[+]${NC} $1"; } error() { echo -e "${RED}[!]${NC} $1"; exit 1; } # 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." 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 URL="" TOKEN="" +JOIN_KEY="" +PUBLIC_KEY="" B64_CONFIG="" +INSTALL_SSSD=0 -while [[ $# -gt 0 ]]; do +while [ $# -gt 0 ]; do case $1 in --url) URL="$2" @@ -38,6 +68,25 @@ while [[ $# -gt 0 ]]; do TOKEN="$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" shift @@ -45,21 +94,58 @@ while [[ $# -gt 0 ]]; do esac done -# Validation -if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then - error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token." +# Validation: require credentials ONLY if config file does not already exist +if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then + error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token." echo "Usage examples:" 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 fi 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 -log "Downloading binary from $BINARY_URL..." -curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary." -chmod +x "$BIN_PATH" +log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..." +curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL" +chmod +x "$BIN_PATH.tmp" +mv -f "$BIN_PATH.tmp" "$BIN_PATH" # 4. Setup configuration log "Preparing configuration directory $CONFIG_DIR..." @@ -69,23 +155,41 @@ chmod 755 "$CONFIG_DIR" if [ -n "$B64_CONFIG" ]; then log "Decoding and writing configuration from base64..." 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..." - # Create a minimal yaml with the provided URL and Token cat < "$CONFIG_FILE" server_url: "$URL" auth_token: "$TOKEN" +join_key: "$JOIN_KEY" +public_key: "$PUBLIC_KEY" location: "unknown" capabilities: telemetry: true - configure_ldap: false + configure_ldap: true + ldap_tunnel: true reboot: false service_control: [] arbitrary_bash: false EOF +else + log "Preserving existing configuration at $CONFIG_FILE" fi 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 \"\" (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 log "Creating systemd service unit..." cat < "$SERVICE_FILE" @@ -98,8 +202,6 @@ Type=simple ExecStart=$BIN_PATH Restart=always RestartSec=5 -StandardOutput=syslog -StandardError=syslog SyslogIdentifier=theta-agent [Install] diff --git a/interop_check_test.go b/interop_check_test.go new file mode 100644 index 0000000..855b02a --- /dev/null +++ b/interop_check_test.go @@ -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") +} diff --git a/ldap_tunnel.go b/ldap_tunnel.go new file mode 100644 index 0000000..b82d823 --- /dev/null +++ b/ldap_tunnel.go @@ -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) +} diff --git a/ldap_tunnel_test.go b/ldap_tunnel_test.go new file mode 100644 index 0000000..6afca3c --- /dev/null +++ b/ldap_tunnel_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 1f623f1..236ef3d 100644 --- a/main.go +++ b/main.go @@ -5,15 +5,20 @@ import ( "log" "os" "os/signal" + "strings" "syscall" ) func main() { + if len(os.Args) > 1 && handleCLI(os.Args[1:]) { + return + } + log.Println("Starting Theta Agent...") // Attempt to load configuration configPath := "/etc/theta42/agent.yml" - if len(os.Args) > 1 { + if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { configPath = os.Args[1] } diff --git a/secrets.go b/secrets.go new file mode 100644 index 0000000..ccd2ce9 --- /dev/null +++ b/secrets.go @@ -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 "" +} diff --git a/secrets_test.go b/secrets_test.go new file mode 100644 index 0000000..6ae8c30 --- /dev/null +++ b/secrets_test.go @@ -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) + } +} diff --git a/telemetry.go b/telemetry.go index 309f22f..f70be22 100644 --- a/telemetry.go +++ b/telemetry.go @@ -3,8 +3,13 @@ package main import ( "encoding/json" "fmt" + "io" "log" "net" + "net/http" + "os" + "path/filepath" + "runtime" "strings" "time" @@ -15,24 +20,223 @@ import ( "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 { - Hostname string `json:"hostname"` - IPs []string `json:"ip_addresses"` - OS string `json:"os"` - Kernel string `json:"kernel"` - CPUModel string `json:"cpu"` - RAMTotalGB float64 `json:"ram_total_gb"` - DiskTotalGB float64 `json:"disk_total_gb"` - Location string `json:"location"` + Hostname string `json:"hostname"` + IPs []string `json:"ip_addresses"` + PublicIP string `json:"public_ip"` + OS string `json:"os"` + Kernel string `json:"kernel"` + CPUModel string `json:"cpu"` + CPUDetails CPUDetails `json:"cpu_details"` + 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 { - CPUUsagePercent float64 `json:"cpu_usage_percent"` - RAMUsagePercent float64 `json:"ram_usage_percent"` - DiskUsagePercent float64 `json:"disk_usage_percent"` - ZFSHealth string `json:"zfs_health,omitempty"` - GPUUsage float64 `json:"gpu_usage_percent,omitempty"` - Timestamp string `json:"timestamp"` + CPUUsagePercent float64 `json:"cpu_usage_percent"` + CPUDetails CPUDetails `json:"cpu_details"` + RAMUsagePercent float64 `json:"ram_usage_percent"` + RAMDetails RAMDetails `json:"ram_details"` + DiskUsagePercent float64 `json:"disk_usage_percent"` + 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. @@ -49,45 +253,83 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData { } } - vm, _ := mem.VirtualMemory() - d, _ := disk.Usage("/") + vm := collectRAMDetails() + disks := collectDiskItems() + cpuDet := collectCPUDetails() - cpuInfo, _ := cpu.Info() - cpuModel := "Unknown" - if len(cpuInfo) > 0 { - cpuModel = cpuInfo[0].Model + pubIP := getPublicIP() + + diskTotalGB := 0.0 + 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{ - Hostname: h.Hostname, - IPs: ips, - OS: fmt.Sprintf("%s %s", h.OS, h.Platform), - Kernel: h.KernelVersion, - CPUModel: cpuModel, - RAMTotalGB: float64(vm.Total) / (1024 * 1024 * 1024), - DiskTotalGB: float64(d.Total) / (1024 * 1024 * 1024), - Location: cfg.Location, + Hostname: h.Hostname, + IPs: ips, + PublicIP: pubIP, + OS: fmt.Sprintf("%s %s", h.OS, h.Platform), + Kernel: h.KernelVersion, + CPUModel: cpuDet.Model, + CPUDetails: cpuDet, + 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. func CollectTelemetryData(exec Executor) TelemetryData { cpuPerc, _ := cpu.Percent(time.Second, false) - vm, _ := mem.VirtualMemory() - d, _ := disk.Usage("/") + vm := collectRAMDetails() + disks := collectDiskItems() + cpuDet := collectCPUDetails() cpuVal := 0.0 if len(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{ CPUUsagePercent: cpuVal, + CPUDetails: cpuDet, RAMUsagePercent: vm.UsedPercent, - DiskUsagePercent: d.UsedPercent, - ZFSHealth: collectZFSHealth(exec), - GPUUsage: collectGPUUsage(exec), - Timestamp: time.Now().Format(time.RFC3339), + RAMDetails: vm, + DiskUsagePercent: diskVal, + Disks: disks, + 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", Payload: map[string]interface{}{ "cpu_usage_percent": telemetry.CPUUsagePercent, + "cpu_details": telemetry.CPUDetails, "ram_usage_percent": telemetry.RAMUsagePercent, + "ram_details": telemetry.RAMDetails, "disk_usage_percent": telemetry.DiskUsagePercent, + "disks": telemetry.Disks, "zfs_health": telemetry.ZFSHealth, - "gpu_usage_percent": telemetry.GPUUsage, + "gpu_usage_percent": telemetry.GPUUsage, "timestamp": telemetry.Timestamp, }, }) diff --git a/theta-agent b/theta-agent index 38292b2..bc23c08 100755 Binary files a/theta-agent and b/theta-agent differ diff --git a/theta-agent-linux-amd64 b/theta-agent-linux-amd64 index cb9c281..bc23c08 100755 Binary files a/theta-agent-linux-amd64 and b/theta-agent-linux-amd64 differ diff --git a/websocket.go b/websocket.go index 23cb365..707d360 100644 --- a/websocket.go +++ b/websocket.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "crypto/ed25519" "crypto/sha256" "encoding/base64" @@ -23,14 +24,55 @@ type WSMessage struct { 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 { 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 { + // 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 == "" { - log.Println("No public key configured; skipping signature verification") - return true + log.Println("Refusing high-risk command: no public_key configured in agent.yml") + return false } sigB64, ok := msg.Payload["signature"].(string) @@ -52,7 +94,11 @@ func verifySignature(cfg *Config, msg WSMessage) bool { 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) if err != nil || len(pubKeyBytes) != ed25519.PublicKeySize { @@ -75,12 +121,36 @@ func connectWebSocket(cm *ConfigManager, exec Executor) { log.Fatalf("Invalid ServerURL: %v", err) } 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 { + // 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) time.Sleep(5 * time.Second) continue @@ -90,8 +160,26 @@ func connectWebSocket(cm *ConfigManager, exec Executor) { 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 - StartTelemetryLoop(c, cm, exec, stopCh) + StartTelemetryLoop(sw, cm, exec, stopCh) // Heartbeat loop go func() { @@ -104,18 +192,31 @@ func connectWebSocket(cm *ConfigManager, exec Executor) { case <-ticker.C: hb := WSMessage{Type: "heartbeat", Payload: map[string]interface{}{"timestamp": time.Now().Format(time.RFC3339)}} payload, _ := json.Marshal(hb) - if err := c.WriteMessage(websocket.TextMessage, payload); err != nil { + if err := sw.WriteMessage(websocket.TextMessage, payload); err != nil { 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 for { _, message, err := c.ReadMessage() 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 } @@ -125,21 +226,33 @@ func connectWebSocket(cm *ConfigManager, exec Executor) { continue } - handleCommand(cm, msg, c, exec) + handleCommand(cm, msg, sw, exec, tunnel) } // Cleanup on disconnect close(stopCh) c.Close() + if authRejected { + log.Printf("Reconnecting in %s.", authRetryInterval) + time.Sleep(authRetryInterval) + continue + } + log.Println("WebSocket disconnected. Reconnecting in 5 seconds...") 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() - 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) { 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 { + 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": if err := cm.Reload(); err != nil { log.Printf("Reload failed: %v", err) @@ -170,6 +289,9 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu linesCount := 100 if l, ok := msg.Payload["lines"].(float64); ok && l > 0 { linesCount = int(l) + if linesCount > 2000 { + linesCount = 2000 + } } 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...") os.Exit(0) 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) sendResponse("ok", "Configuration received") case "reboot": @@ -232,6 +372,52 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu return } 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": serviceName, ok := msg.Payload["service"].(string) 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...") + _ = os.MkdirAll("/etc/sssd", 0755) if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil { log.Printf("Failed to write SSSD config: %v", err) sendResponse("error", "failed to write config") 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...") 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") 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") + 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": if !verifySignature(cfg, msg) { sendResponse("error", "signature verification failed") @@ -311,6 +588,13 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu respPayload, _ := json.Marshal(resp) c.WriteMessage(websocket.TextMessage, respPayload) 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: log.Printf("Unknown command type: %s", msg.Type) sendResponse("error", "unknown command type") diff --git a/websocket_test.go b/websocket_test.go index d55d597..19a91c2 100644 --- a/websocket_test.go +++ b/websocket_test.go @@ -1,11 +1,42 @@ package main import ( + "crypto/ed25519" + "encoding/base64" "encoding/json" "os" "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 { Messages [][]byte } @@ -44,13 +75,19 @@ func (m *MockExecutor) ReadFile(path string) ([]byte, error) { func TestHandleCommand(t *testing.T) { tests := []struct { - name string - cfg *Config - msg WSMessage - expectedStatus string - expectedCmd []string - expectedFile string + name string + cfg *Config + msg WSMessage + expectedStatus string + expectedCmd []string + expectedFile 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", @@ -66,11 +103,13 @@ func TestHandleCommand(t *testing.T) { { name: "reboot command allowed", cfg: &Config{ + PublicKey: testPubKeyB64(), Capabilities: Capabilities{Reboot: true}, }, msg: WSMessage{ Type: "reboot", }, + signed: true, expectedStatus: "ok", expectedCmd: []string{"reboot"}, }, @@ -120,6 +159,7 @@ func TestHandleCommand(t *testing.T) { { name: "configure_ldap allowed", cfg: &Config{ + PublicKey: testPubKeyB64(), Capabilities: Capabilities{ConfigureLDAP: true}, }, msg: WSMessage{ @@ -128,10 +168,11 @@ func TestHandleCommand(t *testing.T) { "config": "domain = theta42.local\nserver = sso.local", }, }, - expectedStatus: "ok", - expectedFile: "/etc/sssd/sssd.conf", + signed: true, + expectedStatus: "ok", + expectedFile: "/etc/sssd/sssd.conf", expectedFileCont: "domain = theta42.local\nserver = sso.local", - expectedCmd: []string{"systemctl", "restart", "sssd"}, + expectedCmd: []string{"systemctl", "restart", "sssd"}, }, { name: "configure_ldap denied", @@ -150,6 +191,7 @@ func TestHandleCommand(t *testing.T) { { name: "arbitrary_bash allowed", cfg: &Config{ + PublicKey: testPubKeyB64(), Capabilities: Capabilities{ArbitraryBash: true}, }, msg: WSMessage{ @@ -158,6 +200,7 @@ func TestHandleCommand(t *testing.T) { "script": "uptime", }, }, + signed: true, expectedStatus: "ok", expectedCmd: []string{"bash", "-c", "uptime"}, }, @@ -175,6 +218,16 @@ func TestHandleCommand(t *testing.T) { expectedStatus: "error", expectedCmd: nil, }, + { + name: "heartbeat_ack is silently ignored", + cfg: &Config{ + Capabilities: Capabilities{}, + }, + msg: WSMessage{ + Type: "heartbeat_ack", + }, + expectedNoResponse: true, + }, { name: "unknown command", cfg: &Config{ @@ -192,7 +245,21 @@ func TestHandleCommand(t *testing.T) { mockConn := &MockConn{} mockExec := &MockExecutor{} 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 { 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) + } +}