Compare commits

6 Commits

Author SHA1 Message Date
wmantly aa1f85a9d5 feat: release v1.6.0 with get-secret CLI, Zero-Trust LDAP tunnel, auto-updates and service restarts 2026-08-07 23:26:13 -04:00
wmantly 8f0158eb9f Add LDAP byte-pump tunnel, secrets rendering, and IAM engine
See CHANGELOG.md for the full breakdown. Summary:

- ldap_tunnel.go: serves a local unix socket for SSSD/PAM and relays raw
  bytes to the SSO over the existing WSS channel (ldap_tunnel messages);
  the agent never parses LDAP (DESIGN.md §4). Adds safeWriter to
  serialize WebSocket writes now that telemetry, heartbeat, the LDAP
  tunnel, and command responses all share one connection.
- secrets.go: renders local templates ({{ bao "path#key" }} placeholders)
  by fetching node-scoped values from the SSO and writing the target
  atomically at 0600, on a signed render_secrets command (DESIGN.md §5).
  demo/ has minimal bash + Node consumers of the rendered file.
- iam.go: applies signed node IAM pushes -- sudoers.d rules (visudo -c
  validated), SSH AuthorizedKeysCommand keys, /etc/security/access.conf,
  and revocation via sss_cache -E + pkill -u (DESIGN.md §6).
- Capability reporting: the agent's enabled capabilities ride along in
  its discovery frame so the SSO can show them in the Directory.
- DESIGN.md: the v2 protocol design this implements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 17:12:58 -04:00
wmantly d128807431 Merge pull request #6 from theta42/fix/rebuild-agent-binary
fix: rebuild the prebuilt linux/amd64 binary for join keys (v1.5.1)
2026-08-06 10:44:41 -04:00
wmantly 7d36318885 fix: rebuild the prebuilt linux/amd64 binary for join keys (v1.5.1)
theta-suite's setup.sh installs the committed theta-agent-linux-amd64
rather than building from source, so a stale binary means the fix in this
repo never reaches the host.

The v1.5.0 binary predated join-key support: setup.sh would write a
join_key into agent.yml that the running agent did not understand, and it
would have looped on "close 4001: Unauthorized" -- the same trap the
v1.3.0 heartbeat fix hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:43:51 -04:00
wmantly 877e6caffa Merge pull request #5 from theta42/feat/join-key-enrollment
feat: join-key enrollment (v1.5.0, protocol v1.2.0 §1.1)
2026-08-06 10:40:05 -04:00
wmantly 0a5011cd42 feat: join-key enrollment (v1.5.0, protocol v1.2.0 1.1)
Installing the agent with one key is now all it takes to add a host.

New `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 persists them and blanks
the join key. Nothing has to be copied between two machines by hand.

PersistEnrollment rewrites only the credential lines -- line-based rather
than a YAML round-trip -- so operator comments, the capability matrix and
formatting survive. It re-reads afterwards so the credential is live
without a restart, and keeps the file 0600.

The connect URL carries ?hostname= so a self-enrolling host is named
after itself, and the agent refuses to connect at all (with a long
back-off) when it has no credential rather than presenting an empty one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:34:06 -04:00
27 changed files with 2148 additions and 53 deletions
+77
View File
@@ -5,6 +5,83 @@ 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.6.0] - 2026-08-07
### Added
- **On-demand CLI Secret Fetching (`theta-agent get-secret <key>`).** Fetch raw secret values directly over TLS without writing plaintext files to disk. Supports `theta-agent get-secrets --env` (formatted for Systemd `EnvironmentFile`) and `theta-agent get-secrets --json`.
- **CLI Self-Update and Re-enrollment Commands.** Added `theta-agent update` and `theta-agent reinitialize [--join-key <key>]` CLI options with automated service restarts (`sssd`, `sshd`).
- **Zero-Trust LDAP WebSocket Tunnel (`ldap_tunnel`).** Auto-starts local `/run/theta/ldap.sock` and `127.0.0.1:3890` loopback listeners.
- **Dynamic Site Matching.** Auto-detects WAN IP for public site matching and discovery.
### Fixed
- **SSSD Socket Activation Exit Code 17.** Removed legacy `services` key in generated `sssd.conf` to satisfy modern systemd socket activation requirements.
## [Unreleased] - LDAP byte-pump tunnel (DESIGN.md §4)
The agent now serves a local LDAP socket for SSSD/PAM. It is a **pure byte
pump**: it forwards raw LDAP bytes to the SSO over the WSS channel, and the SSO
relays them into its real OpenLDAP and pipes the response back. The agent never
parses LDAP.
### Added
- **`ldap_tunnel` capability + `ldap_socket` config.** When enabled, the agent
binds a unix socket (default `/run/theta/ldap.sock`, root:theta `0660`) and
relays bytes bidirectionally as `ldap_tunnel` messages over the existing WSS
channel. Point SSSD at it with `ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock`.
- **`safeWriter`** — serializes WebSocket writes. Gorilla allows only one
concurrent writer, but telemetry, heartbeat, the LDAP tunnel and command
responses all write to the same socket; without this, concurrent writes
corrupt the stream.
- **Offline behavior:** when the WSS is down the agent cannot forward bytes, so
it closes local socket connections; SSSD sees a connection failure and falls
back to its local cache.
### Added — secrets engine (DESIGN.md §5)
- **`secrets` capability + `secrets` config.** The agent renders local templates
that embed OpenBao secrets (`{{ bao "secret/data/nodes/<id>/<name>#<key>" }}`).
It parses the placeholders, fetches the values from the SSO (which holds the
OpenBao access; the agent never holds a Vault token), renders each target
atomically at `0600`, and runs the configured reload. Triggered by a signed
`render_secrets` command.
### Added — capability reporting
- **The agent reports its enabled capabilities in its `discovery` frame.** The
SSO stores them and the Directory UI shows them as badges on the host's Metrics
tab, so an operator can see at a glance what an agent is allowed to do
(telemetry, LDAP tunnel, secrets, IAM, reboot, bash, service control).
### Added — IAM engine (DESIGN.md §6)
- **`iam` capability.** The SSO pushes node-scoped identity config as a signed
`iam_apply` command; the agent verifies the Ed25519 signature (fail-closed) and
applies it locally:
- **Sudo rules** — writes `/etc/sudoers.d/theta-iam-<node_id>`, validates with
`visudo -c`, atomic swap.
- **SSH keys** — stores per-user keys and installs the `AuthorizedKeysCommand`
script (`/usr/local/bin/theta-authorized-keys`) that sshd calls per login.
- **Access control** — writes `/etc/security/access.conf` with allowed login
groups.
- **Revocation** — flushes the SSSD cache (`sss_cache -E`) and drops active
sessions (`pkill -u`) for revoked users.
## [v1.5.1] - 2026-08-06
### Fixed
- **Rebuilt the prebuilt `theta-agent-linux-amd64`.** theta-suite's `setup.sh` installs that committed binary rather than building from source, so a stale one means the fix in this repo never reaches the host. The v1.5.0 binary predated join-key support: an install would have written a `join_key` into `agent.yml` that the running agent did not understand, and it would have looped on `close 4001: Unauthorized`. (Same trap as the v1.3.0 heartbeat fix.)
## [v1.5.0] - 2026-08-06
Join-key enrollment (protocol v1.2.0 §1.1). Installing the agent with one key is now all it takes to add a host.
### Added
- **`join_key` config field.** Presented while `auth_token` is empty. The SSO exchanges it for this agent's own token and the public key it must pin, both delivered in the `config` frame; the agent writes them into `agent.yml` and blanks the join key. No value has to be copied between two machines by hand any more.
- `ConfigManager.PersistEnrollment` rewrites only the credential lines, line-based rather than a YAML round-trip, so operator comments, the capability matrix and formatting survive. Re-reads the file afterwards, so the new credential is live without a restart, and keeps the file at `0600`.
- `Config.Credential()` — the agent's own token when it has one, otherwise the join key.
- The connect URL carries `?hostname=`, so a self-enrolling host is named after itself instead of a generated placeholder.
- `install.sh --join-key`.
### Fixed
- The agent now refuses to connect (with a clear message and a long back-off) when it has neither an `auth_token` nor a `join_key`, rather than repeatedly presenting an empty credential.
## [v1.4.0] - 2026-08-05
Implements **Protocol v1.2.0**. See `PROTOCOL.md` §1.1, §5.15.3.
+211
View File
@@ -0,0 +1,211 @@
# theta-agent v2 — Simplified Architecture (LDAP-over-HTTPS)
**Status:** Draft for review · **Supersedes:** the WebRTC/SCTP spec (WebRTC dropped)
This document defines the v2 architecture for `theta-agent`. It replaces the
earlier WebRTC/SCTP design with a strictly simpler model: **one agent per node,
one outbound WSS channel, and the agent provides local LDAP + secrets + IAM.**
The core problem it solves is the original ask — *LDAP binds are painful across
hostnames, networks, and TLS cert chains* — by making the client stop speaking
LDAP and instead do an HTTPS call to the SSO, where the directory is reachable.
---
## 1. Overview
```
APP (no agent) SSO
HTTPS POST /ldap/bind ──────────────► [LDAP-over-HTTPS API] ──► OpenLDAP
HTTPS POST /ldap/search ───────────► ▲
│ raw bytes
NODE (with theta-agent) │
SSSD ──► /run/theta/ldap.sock ──► agent ──► WSS (ldap_tunnel) ─┘
secrets: /etc/theta/templates/*.tpl ──► agent ──► HTTPS ──► OpenBao
IAM: sudoers.d / authorized_keys ──► agent ◄── WSS ◄── IAM engine
```
Two ways to reach the directory, both simple:
- **Apps (no agent):** call the HTTPS LDAP API directly — one bind/search
contract, no LDAP protocol.
- **Nodes (with agent):** the agent is a **pure byte pump** — it forwards raw
LDAP bytes from a local socket to the SSO, which relays them into OpenLDAP.
The agent never parses LDAP.
---
## 2. Transport
- **Single transport: persistent outbound WebSocket (WSS) over TCP 443.** No
WebRTC, no SCTP, no UDP, no ICE, no fallback logic. The agent dials out; the
SSO never needs an inbound port.
- **Authentication:** the existing token / join-key enrollment model carries
over unchanged (see `PROTOCOL.md` §1.1). The agent presents its per-agent
token; the SSO rejects anything it did not issue.
- **Message framing:** the existing `WSMessage` JSON envelope (`type` + `payload`)
carries all traffic. Message types are extended for LDAP, secrets, and IAM
(below). No binary framing, no stream multiplexing. The LDAP tunnel carries
raw bytes as base64 in `ldap_tunnel` messages (see §4).
---
## 3. LDAP-over-HTTPS API (SSO side)
A small service (or routes in the existing proxy/sso) that performs the real
LDAP operation against OpenLDAP, which is reachable from the SSO network. This
is the pinepain/ldap-auth-proxy model: the client never speaks LDAP.
| Endpoint | Request | Response |
| :--- | :--- | :--- |
| `POST /api/v1/ldap/bind` | `{username, password}` | `200 {dn, attributes}` or `401` |
| `POST /api/v1/ldap/search` | `{base_dn, scope, filter, attributes}` | `200 {entries: [...]}` |
- **bind** performs a real LDAP bind server-side and returns the bound DN and
identity attributes.
- **search** runs a real LDAP search server-side and returns entries.
- **Authorization:** the API authorizes the *caller* (an app, or an agent acting
for a node). OpenLDAP enforces the actual directory ACLs. This resolves the
original spec's contradiction — we do not parse BER to enforce per-operation
policy; the directory does.
### 3.1 Consumers
- **Apps/services:** call the API directly over HTTPS. No LDAP hostname, no
LDAPS cert chain, no cross-network LDAP firewall rule.
- **theta-agent:** does **not** use this API. It tunnels raw LDAP bytes to the
SSO's OpenLDAP over the WSS channel instead (see §4) — a byte pump, not a
translation. The HTTPS API is for apps that have no agent on their network.
---
## 4. Agent local LDAP socket — a pure byte pump (for SSSD/PAM)
The agent provides a local LDAP endpoint so SSSD/PAM on the node can
authenticate without direct LDAP connectivity. **The agent does not speak LDAP
at all.** It is a dumb byte pump: whatever bytes land on the local socket are
forwarded to the SSO, which relays them into its real OpenLDAP and pipes the
response back.
```
SSSD ──► /run/theta/ldap.sock ──► agent ──► WSS (ldap_tunnel) ──► SSO ──► OpenLDAP
◄───────────────────────────────────────────────────────────────────────◄
```
- **Socket:** a **Unix domain socket** at `/run/theta/ldap.sock`, owned by root,
mode `0660` (root + theta group). A unix socket is preferred over
`127.0.0.1:389` because filesystem permissions restrict *which local processes*
can connect — any process can reach a TCP port, only root/theta can reach the
socket.
- **Tunnel framing:** each local connection gets a `conn_id`. Bytes are carried
over the existing WSS channel as `ldap_tunnel` messages:
`{type:"ldap_tunnel", payload:{conn_id, data:<base64>, close:bool}}`. The
agent reads the socket and sends chunks up; the SSO relays them into OpenLDAP
and sends OpenLDAP's response chunks back down; the agent writes them to the
socket. `close:true` ends a connection.
- **SSSD config:** `ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock`, StartTLS off
(the transport to the SSO is already TLS/WSS; the local hop is plaintext on a
root-owned socket).
### 4.1 Offline boot handling (validated)
If the agent cannot reach the SSO (WSS down), it cannot forward bytes, so it
closes local socket connections. SSSD sees a connection failure and falls back
to its local cache. **This has been validated against real SSSD behavior:** any
user/group seen in the last N days can log in offline — a laptop user can log in
on the road, and an admin can still reach a broken service host to fix it.
### 4.2 Open question — `ldapi://` support
SSSD's `ldap_uri` accepts `ldapi://` unix-socket URIs on modern versions, but
this must be confirmed on the target SSSD build. If unsupported, fall back to
`127.0.0.1:389` with a local firewall rule restricting the port to loopback.
---
## 5. Secrets engine
The agent renders OpenBao secrets to local files, reusing the existing
`@simpleworkjs/bao-conf` patterns rather than inventing a parallel mechanism.
- **Templates:** `/etc/theta/templates/*.tpl` declare the secrets a service
needs, e.g. `DB_PASS="{{ bao "secret/data/nodes/node-42/db#password" }}"`.
- **Flow:** the agent requests the secret paths over the WSS channel → the SSO
fetches from OpenBao (node-scoped to `/secret/data/nodes/${NODE_ID}/*`) →
the agent renders the file **atomically** (write temp + rename) with mode
`0600` → runs the configured post-render action (`systemctl reload <svc>`).
- **Rotation:** on a rotation/invalidation event pushed down the channel, the
agent re-fetches, re-renders, and reloads.
- **Note:** OpenBao KV v2 secrets have no leases; "renewal" is a re-read on
invalidation, not a lease renewal. (Dynamic secrets, if used, are a separate
path.)
---
## 6. IAM engine
The SSO pushes node-scoped identity config down the WSS channel; the agent
applies it locally.
- **Sudo rules:** write `/etc/sudoers.d/theta-iam-<node_id>`, run `visudo -c`,
and atomically swap on success.
- **SSH keys:** sync user public keys via `AuthorizedKeysCommand` (the agent
implements the command the SSH daemon calls per login) — *not* a non-standard
`/etc/ssh/authorized_keys.d/` directory.
- **Access control:** configure SSSD / `/etc/security/access.conf` for allowed
login groups.
- **Revocation:** on account disable / group drop, flush local SSSD caches and
drop active sessions for the affected user (mechanism TBD — `loginctl` vs
`pkill -u`; high-risk, needs a defined trigger/event model).
### 6.1 Security — signed IAM payloads
Sudo rules and SSH keys grant root-equivalent access, so **every IAM push is
Ed25519-signed** using the existing signature model (`PROTOCOL.md` §5). The agent
verifies the signature against its pinned `public_key` before applying anything.
Unsigned or invalid IAM payloads are rejected fail-closed.
---
## 7. Security model
- **Fail-closed, capability-matrix philosophy carries over** from v1: the agent
only applies what its local config permits; the SSO cannot override local
settings.
- **Local socket auth:** only root/theta can reach `/run/theta/ldap.sock`.
- **Signed high-risk operations:** IAM pushes (and any new high-risk command)
require the Ed25519 signature.
- **Node-scoped secrets:** OpenBao access is restricted to the node's own path
prefix.
- **Blast radius:** the agent runs as root; the unix socket + signature model +
node-scoped secrets contain the damage if the agent is compromised.
---
## 8. What this drops from the original spec
- WebRTC / SCTP / DTLS / UDP / ICE — **gone**, WSS only.
- Three SCTP streams — **replaced** by one WSS channel with message types.
- The "node-scoped authorization" contradiction — **resolved**: the API
authorizes the caller, OpenLDAP enforces ACLs.
- LDAP parsing in the agent — **gone**. The agent is a byte pump; it never
parses LDAP. The SSO relays raw bytes into its real OpenLDAP.
---
## 9. Open questions / verification items
1. **SSSD `ldapi://` unix-socket support** on the target build (§4.2).
2. **Revocation mechanism** — implemented as `sss_cache -E` + `pkill -u <user>`
(§6). The event model (what triggers a push) is still to be wired into the
SSO UI/engine.
3. **`AuthorizedKeysCommand`** — implemented: the agent installs
`/usr/local/bin/theta-authorized-keys` which cats the user's key file
(`/etc/theta/authorized_keys/<user>`). sshd must be configured with
`AuthorizedKeysCommand /usr/local/bin/theta-authorized-keys %u` (§6).
4. **Versioning/migration** — is v2 a replacement for v1, or a parallel mode?
The existing `/api/agent/ws` vs the new `/api/v1/ldap/*` paths need a story.
5. **SSO relay target** — the SSO relays tunnel bytes into its local OpenLDAP
(slapd). The target address comes from `conf.ldap.url`; confirm it is a
plaintext LDAP port reachable from the SSO process (§4).
6. **Secrets node scope** — the agent's node scope is its agent id
(`secret/data/nodes/<agent-id>/*`). Confirm this matches how node secrets are
provisioned in OpenBao (§5).
+56 -1
View File
@@ -12,6 +12,27 @@ The agent establishes a persistent outbound WebSocket connection.
### 1.1 Enrollment (changed in v1.2.0)
Two credentials can appear in `agent.yml`. The agent presents `auth_token` when
it has one, otherwise `join_key`:
| Field | Meaning |
| :--- | :--- |
| `auth_token` | This agent's own token, issued by the server. Long-term identity. |
| `join_key` | Bootstrap credential (`tjk_…`), exchanged for an `auth_token` on first connect. |
**Join-key flow.** The agent connects presenting a join key and
`?hostname=<its hostname>`. The server enrolls the host and answers with a
`config` frame carrying `enrolled: true`, `auth_token` and `public_key`. The
agent writes both into `agent.yml`, blanks `join_key`, and uses its own token
from then on. This is what makes "install the agent with a key" sufficient to
add a host — no value has to be copied between two machines by hand.
The public key is accepted on first connect (trust on first use) over the same
channel that issued the token. Pre-register the host instead if you need the
trust anchor pinned out of band.
The token **must be issued by the server**. An administrator enrolls the agent in
the SSO (Directory → Agents, or `POST /api/agent/enroll`), which mints the token,
stores only its SHA-256, and displays the raw value once. That value goes into
@@ -28,7 +49,7 @@ authentication failure arrives as a **close frame**, not an HTTP status:
| Code | Meaning | Agent behaviour |
| :--- | :--- | :--- |
| `4001` | Token unknown, or not issued by this server | Back off (5 min); the credential will not fix itself |
| `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 |
@@ -49,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)
@@ -97,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`.
@@ -118,6 +171,8 @@ 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
+14
View File
@@ -4,6 +4,17 @@ Theta Agent is a unified endpoint management daemon for the theta42 stack. It re
The agent dials out to the central SSO Manager via a persistent WebSocket connection, enabling real-time telemetry, dynamic discovery, and secure remote operations.
## 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
@@ -56,6 +67,9 @@ either drops the agent's live connection immediately. See `PROTOCOL.md` §1.1.
|------------|------------|-------------|---------|
| `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. |
+40 -9
View File
@@ -3,31 +3,62 @@
server_url: "https://sso.example.com"
# Issued by the SSO when you enroll this host (Directory -> Install Agent, or
# POST /api/agent/enroll). The server records only its hash and rejects any
# token it did not issue, so a value invented locally will not connect.
auth_token: "REPLACE_WITH_ISSUED_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: ""
# Base64 of the SSO's RAW 32-byte Ed25519 public key -- the `publicKey` value
# from enrollment or GET /api/agent/nodes. This is NOT a PEM body.
# 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: "REPLACE_WITH_SSO_PUBLIC_KEY"
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/<node-id>/<name>#<key>" }}.
# secrets:
# - template: /etc/theta/templates/db.env.tpl
# target: /etc/theta/db.env
# reload: systemctl reload app
# ---------------------------------------------------------
# Advanced Capabilities (High risk, remote operations)
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
)
func handleCLI(args []string) bool {
if len(args) == 0 {
return false
}
arg := strings.ToLower(args[0])
switch arg {
case "get-secret", "secret-get":
runGetSecret(args[1:])
return true
case "get-secrets", "secret-list", "secrets":
runGetSecrets(args[1:])
return true
case "--update", "update":
runSelfUpdate(args[1:])
return true
case "--reinitialize", "reinitialize", "--reinit", "reinit":
runReinitialize(args[1:])
return true
case "--version", "version", "-v":
fmt.Println("Theta Agent v1.2.0")
return true
case "--help", "help", "-h":
printUsage()
return true
}
return false
}
func printUsage() {
fmt.Println("Theta Agent - Unified Endpoint Management CLI")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" theta-agent Run agent daemon in foreground")
fmt.Println(" theta-agent get-secret <key> Fetch single secret value from OpenBao")
fmt.Println(" theta-agent get-secrets [flags] Fetch all host/resource secrets (flags: --json, --env)")
fmt.Println(" theta-agent update Self-update binary from SSO Manager")
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register")
fmt.Println(" theta-agent version Show version info")
fmt.Println()
fmt.Println("Reinitialize Flags:")
fmt.Println(" --join-key <key> Supply new join key for re-enrollment")
fmt.Println()
}
func runSelfUpdate(args []string) {
configPath := "/etc/theta42/agent.yml"
cm, err := NewConfigManager(configPath)
if err != nil {
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
}
cfg := cm.Get()
serverURL := strings.TrimRight(cfg.ServerURL, "/")
if serverURL == "" {
log.Fatalf("[!] Update failed: server_url is empty in %s", configPath)
}
downloadURL := fmt.Sprintf("%s/resources/theta-agent/theta-agent-linux-amd64", serverURL)
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(downloadURL)
if err != nil || resp.StatusCode != 200 {
log.Fatalf("[!] Failed to download update binary from %s (HTTP %d): %v", downloadURL, resp.StatusCode, err)
}
defer resp.Body.Close()
binPath := "/usr/local/bin/theta-agent"
if selfPath, err := os.Executable(); err == nil && selfPath != "" {
binPath = selfPath
}
tmpPath := binPath + ".tmp"
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
log.Fatalf("[!] Cannot write binary to %s: %v", tmpPath, err)
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close()
log.Fatalf("[!] Error writing binary update: %v", err)
}
out.Close()
if err := os.Rename(tmpPath, binPath); err != nil {
log.Fatalf("[!] Cannot replace binary at %s: %v", binPath, err)
}
log.Printf("[+] Binary updated successfully at %s.", binPath)
exec := &SystemExecutor{}
restartAffectedServices(exec)
os.Exit(0)
}
func runReinitialize(args []string) {
configPath := "/etc/theta42/agent.yml"
joinKey := ""
for i := 0; i < len(args); i++ {
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
joinKey = args[i+1]
i++
}
}
raw, err := os.ReadFile(configPath)
if err != nil {
log.Fatalf("[!] Cannot read %s: %v", configPath, err)
}
content := string(raw)
// Clear auth_token
reToken := regexp.MustCompile(`(?m)^auth_token:.*$`)
content = reToken.ReplaceAllString(content, `auth_token: ""`)
if joinKey != "" {
reKey := regexp.MustCompile(`(?m)^join_key:.*$`)
if reKey.MatchString(content) {
content = reKey.ReplaceAllString(content, fmt.Sprintf(`join_key: "%s"`, joinKey))
} else {
content += fmt.Sprintf("\njoin_key: \"%s\"\n", joinKey)
}
}
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
log.Fatalf("[!] Failed to update %s: %v", configPath, err)
}
log.Printf("[+] Cleared token in %s and reset enrollment status.", configPath)
exec := &SystemExecutor{}
restartAffectedServices(exec)
os.Exit(0)
}
func restartAffectedServices(exec Executor) {
log.Printf("[+] Restarting theta-agent service...")
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
log.Printf("[+] Restarting sssd service...")
_, _ = exec.Execute("systemctl", "restart", "sssd")
}
if _, err := exec.Execute("systemctl", "is-active", "sshd"); err == nil {
log.Printf("[+] Reloading sshd service...")
_, _ = exec.Execute("systemctl", "reload", "sshd")
} else if _, err := exec.Execute("systemctl", "is-active", "ssh"); err == nil {
log.Printf("[+] Reloading ssh service...")
_, _ = exec.Execute("systemctl", "reload", "ssh")
}
}
func runGetSecret(args []string) {
if len(args) < 1 {
fmt.Fprintf(os.Stderr, "[!] Error: secret key name required (e.g. theta-agent get-secret DB_PASSWORD)\n")
os.Exit(1)
}
key := args[0]
secrets, err := fetchAgentSecrets()
if err != nil {
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
os.Exit(1)
}
val, exists := secrets[key]
if !exists {
fmt.Fprintf(os.Stderr, "[!] Error: secret '%s' not found for this host/resource\n", key)
os.Exit(1)
}
// Print raw secret value to stdout without trailing newline
fmt.Print(val)
os.Exit(0)
}
func runGetSecrets(args []string) {
jsonMode := false
envMode := false
for _, arg := range args {
if arg == "--json" {
jsonMode = true
} else if arg == "--env" {
envMode = true
}
}
secrets, err := fetchAgentSecrets()
if err != nil {
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
os.Exit(1)
}
if jsonMode {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(secrets); err != nil {
fmt.Fprintf(os.Stderr, "[!] JSON encode error: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
if envMode {
for k, v := range secrets {
escaped := strings.ReplaceAll(v, `"`, `\"`)
fmt.Printf("%s=\"%s\"\n", k, escaped)
}
os.Exit(0)
}
if len(secrets) == 0 {
fmt.Println("No secrets configured for this host/resource.")
os.Exit(0)
}
fmt.Printf("%-30s %s\n", "SECRET KEY", "VALUE STATUS")
fmt.Println(strings.Repeat("-", 60))
for k, v := range secrets {
status := fmt.Sprintf("Configured (%d chars)", len(v))
fmt.Printf("%-30s %s\n", k, status)
}
os.Exit(0)
}
func fetchAgentSecrets() (map[string]string, error) {
configPath := "/etc/theta42/agent.yml"
cm, err := NewConfigManager(configPath)
if err != nil {
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
}
cfg := cm.Get()
serverURL := strings.TrimRight(cfg.ServerURL, "/")
if serverURL == "" {
return nil, fmt.Errorf("server_url is empty in %s", configPath)
}
token := cfg.AuthToken
if token == "" {
return nil, fmt.Errorf("agent is not enrolled (auth_token empty in %s)", configPath)
}
reqBody, _ := json.Marshal(map[string]interface{}{})
url := fmt.Sprintf("%s/api/v1/agent/secrets", serverURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bodyBytes))
}
var resData struct {
Status string `json:"status"`
Secrets map[string]map[string]interface{} `json:"secrets"`
}
if err := json.NewDecoder(resp.Body).Decode(&resData); err != nil {
return nil, fmt.Errorf("failed to decode JSON response: %w", err)
}
mergedSecrets := make(map[string]string)
for _, pathMap := range resData.Secrets {
for k, v := range pathMap {
if strV, ok := v.(string); ok {
mergedSecrets[k] = strV
} else if v != nil {
mergedSecrets[k] = fmt.Sprintf("%v", v)
}
}
}
return mergedSecrets, nil
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"testing"
)
func TestHandleCLIHelpAndVersion(t *testing.T) {
if !handleCLI([]string{"version"}) {
t.Errorf("expected handleCLI('version') to return true")
}
if !handleCLI([]string{"--help"}) {
t.Errorf("expected handleCLI('--help') to return true")
}
if handleCLI([]string{"unknown-command"}) {
t.Errorf("expected handleCLI('unknown-command') to return false")
}
}
+94 -5
View File
@@ -3,6 +3,8 @@ package main
import (
"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
}
+106
View File
@@ -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")
}
}
+6
View File
@@ -0,0 +1,6 @@
FROM node:20-alpine
RUN apk add --no-cache bash
WORKDIR /demo
COPY get-secret.sh get-secret.js ./
RUN chmod +x get-secret.sh
CMD ["sh", "-c", "sh /demo/get-secret.sh && node /demo/get-secret.js"]
+17
View File
@@ -0,0 +1,17 @@
// Demo: a 3rd-party node app reads the secret the theta-agent rendered to disk.
const fs = require('fs');
const path = '/etc/theta/rendered/db.env';
console.log('=== node app reads the rendered secret ===');
if (fs.existsSync(path)) {
const env = fs.readFileSync(path, 'utf8');
const db = {};
for (const line of env.split('\n')) {
const m = /^(\w+)="(.*)"$/.exec(line.trim());
if (m) db[m[1]] = m[2];
}
console.log('DB_USER=' + db.DB_USER);
console.log('DB_PASS=' + db.DB_PASS);
} else {
console.error('rendered secret not found — run render_secrets first');
process.exit(1);
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Demo: a 3rd-party bash app reads the secret the theta-agent rendered to disk.
# The agent rendered /etc/theta/rendered/db.env from a template + OpenBao.
echo "=== bash app reads the rendered secret ==="
if [ -f /etc/theta/rendered/db.env ]; then
. /etc/theta/rendered/db.env
echo "DB_USER=$DB_USER"
echo "DB_PASS=$DB_PASS"
else
echo "rendered secret not found — run render_secrets first"
exit 1
fi
+6 -3
View File
@@ -2,16 +2,19 @@ module github.com/theta42/theta-agent
go 1.22.2
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
)
+13
View File
@@ -1,16 +1,26 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/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=
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
// IAM engine (DESIGN.md §6). The SSO pushes node-scoped identity config down the
// WSS channel as a signed `iam_apply` command; the agent verifies the signature
// (fail-closed) and applies it locally: sudo rules, SSH keys, access control,
// and session revocation.
// Paths are package-level vars so tests can redirect them to temp dirs.
var (
iamSudoersDir = "/etc/sudoers.d"
iamKeysDir = "/etc/theta/authorized_keys"
iamKeysCommand = "/usr/local/bin/theta-authorized-keys"
iamAccessConf = "/etc/security/access.conf"
)
// IAMPayload is the signed body of an `iam_apply` command.
type IAMPayload struct {
NodeID string `json:"node_id"`
Revision int `json:"revision"`
AccessControl AccessControl `json:"access_control"`
}
type AccessControl struct {
AllowedLoginGroups []string `json:"allowed_login_groups"`
SudoRules []SudoRule `json:"sudo_rules"`
SSHKeys []SSHKey `json:"ssh_keys"`
RevokeUsers []string `json:"revoke_users"`
}
type SudoRule struct {
Group string `json:"group"`
RunAs string `json:"run_as"`
Commands []string `json:"commands"`
Nopasswd bool `json:"nopasswd"`
}
type SSHKey struct {
User string `json:"user"`
Keys []string `json:"keys"`
}
// applyIAM applies a verified IAM payload. The caller must have already verified
// the Ed25519 signature.
func applyIAM(payload IAMPayload, exec Executor) error {
if len(payload.AccessControl.SudoRules) > 0 {
if err := applySudoRules(payload.AccessControl.SudoRules, payload.NodeID, exec); err != nil {
return fmt.Errorf("sudo rules: %w", err)
}
}
if len(payload.AccessControl.SSHKeys) > 0 {
if err := applySSHKeys(payload.AccessControl.SSHKeys, exec); err != nil {
return fmt.Errorf("ssh keys: %w", err)
}
}
if len(payload.AccessControl.AllowedLoginGroups) > 0 {
if err := applyAccessControl(payload.AccessControl.AllowedLoginGroups, exec); err != nil {
return fmt.Errorf("access control: %w", err)
}
}
if len(payload.AccessControl.RevokeUsers) > 0 {
applyRevocation(payload.AccessControl.RevokeUsers, exec)
}
return nil
}
// applySudoRules writes /etc/sudoers.d/theta-iam-<node_id>, verifies with
// `visudo -c`, and atomically swaps it in on success.
func applySudoRules(rules []SudoRule, nodeID string, exec Executor) error {
var b strings.Builder
for _, r := range rules {
if r.Group == "" {
continue
}
runAs := r.RunAs
if runAs == "" {
runAs = "ALL"
}
cmds := strings.Join(r.Commands, ", ")
if cmds == "" {
cmds = "ALL"
}
prefix := ""
if r.Nopasswd {
prefix = "NOPASSWD:"
}
fmt.Fprintf(&b, "%%%s ALL=(%s) %s%s\n", r.Group, runAs, prefix, cmds)
}
content := b.String()
// Make sure the sudoers.d dir exists (a minimal host may not have it).
if err := os.MkdirAll(iamSudoersDir, 0755); err != nil {
return err
}
// Write to a temp file in the sudoers.d dir, verify, then rename.
tmp, err := os.CreateTemp(iamSudoersDir, ".theta-iam-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.WriteString(content); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
os.Chmod(tmpName, 0440)
// visudo -c -f <file> validates a single file without touching the rest.
if _, err := exec.Execute("visudo", "-c", "-f", tmpName); err != nil {
os.Remove(tmpName)
return fmt.Errorf("visudo rejected the rules: %w", err)
}
target := filepath.Join(iamSudoersDir, "theta-iam-"+nodeID)
if err := os.Rename(tmpName, target); err != nil {
os.Remove(tmpName)
return err
}
log.Printf("IAM: wrote %s", target)
return nil
}
// applySSHKeys stores per-user keys and installs the AuthorizedKeysCommand
// script that sshd calls per login.
func applySSHKeys(keys []SSHKey, exec Executor) error {
if err := os.MkdirAll(iamKeysDir, 0755); err != nil {
return err
}
for _, k := range keys {
if k.User == "" {
continue
}
path := filepath.Join(iamKeysDir, k.User)
content := strings.Join(k.Keys, "\n") + "\n"
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return err
}
}
// AuthorizedKeysCommand script: cat the user's key file.
script := "#!/bin/sh\ncat \"" + iamKeysDir + "/$1\" 2>/dev/null\n"
if err := os.WriteFile(iamKeysCommand, []byte(script), 0755); err != nil {
return err
}
log.Printf("IAM: wrote %d ssh key file(s) + %s", len(keys), iamKeysCommand)
return nil
}
// applyAccessControl writes /etc/security/access.conf with the allowed login
// groups (PAM access). Format: `+:group:ALL` for each allowed group, then deny
// everything else.
func applyAccessControl(groups []string, exec Executor) error {
var b strings.Builder
for _, g := range groups {
if g != "" {
fmt.Fprintf(&b, "+:%s:ALL\n", g)
}
}
b.WriteString("-:ALL:ALL\n")
if err := os.MkdirAll(filepath.Dir(iamAccessConf), 0755); err != nil {
return err
}
if err := os.WriteFile(iamAccessConf, []byte(b.String()), 0644); err != nil {
return err
}
log.Printf("IAM: wrote %s", iamAccessConf)
return nil
}
// applyRevocation flushes the SSSD cache and drops active sessions for the
// revoked users.
func applyRevocation(users []string, exec Executor) {
// Flush the whole SSSD cache once — a revoked user must not be resolvable
// from cache.
if _, err := exec.Execute("sss_cache", "-E"); err != nil {
log.Printf("IAM: sss_cache -E failed: %v", err)
}
for _, u := range users {
if u == "" {
continue
}
if _, err := exec.Execute("pkill", "-u", u); err != nil {
log.Printf("IAM: pkill -u %s failed (no sessions?): %v", u, err)
}
log.Printf("IAM: revoked %s", u)
}
}
// parseIAMPayload extracts an IAMPayload from a WSMessage payload map.
func parseIAMPayload(payload map[string]interface{}) (IAMPayload, error) {
raw, err := json.Marshal(payload)
if err != nil {
return IAMPayload{}, err
}
var p IAMPayload
if err := json.Unmarshal(raw, &p); err != nil {
return IAMPayload{}, err
}
return p, nil
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// TestApplyIAM verifies the agent applies sudo rules, SSH keys, access control,
// and revocation from a signed IAM payload.
func TestApplyIAM(t *testing.T) {
dir := t.TempDir()
iamSudoersDir = dir
iamKeysDir = filepath.Join(dir, "keys")
iamKeysCommand = filepath.Join(dir, "theta-authorized-keys")
iamAccessConf = filepath.Join(dir, "access.conf")
payload := IAMPayload{
NodeID: "node-42",
Revision: 82,
AccessControl: AccessControl{
AllowedLoginGroups: []string{"sysadmins", "node-operators"},
SudoRules: []SudoRule{
{Group: "sysadmins", RunAs: "ALL", Commands: []string{"ALL"}},
{Group: "node-operators", RunAs: "ALL", Commands: []string{"ALL"}, Nopasswd: true},
},
SSHKeys: []SSHKey{
{User: "admin", Keys: []string{"ssh-ed25519 AAAA admin@theta"}},
},
RevokeUsers: []string{"olduser"},
},
}
exec := &MockExecutor{}
if err := applyIAM(payload, exec); err != nil {
t.Fatalf("applyIAM: %v", err)
}
// Sudo rules file.
sudoers, err := os.ReadFile(filepath.Join(dir, "theta-iam-node-42"))
if err != nil {
t.Fatalf("read sudoers: %v", err)
}
wantSudoers := "%sysadmins ALL=(ALL) ALL\n%node-operators ALL=(ALL) NOPASSWD:ALL\n"
if string(sudoers) != wantSudoers {
t.Fatalf("sudoers mismatch:\n got: %q\nwant: %q", sudoers, wantSudoers)
}
// SSH key file + AuthorizedKeysCommand script.
keyFile, err := os.ReadFile(filepath.Join(iamKeysDir, "admin"))
if err != nil {
t.Fatalf("read key file: %v", err)
}
if string(keyFile) != "ssh-ed25519 AAAA admin@theta\n" {
t.Fatalf("key file mismatch: %q", keyFile)
}
script, err := os.ReadFile(iamKeysCommand)
if err != nil {
t.Fatalf("read keys command: %v", err)
}
if string(script) != "#!/bin/sh\ncat \""+iamKeysDir+"/$1\" 2>/dev/null\n" {
t.Fatalf("keys command mismatch: %q", script)
}
// Access control.
access, err := os.ReadFile(iamAccessConf)
if err != nil {
t.Fatalf("read access.conf: %v", err)
}
wantAccess := "+:sysadmins:ALL\n+:node-operators:ALL\n-:ALL:ALL\n"
if string(access) != wantAccess {
t.Fatalf("access.conf mismatch:\n got: %q\nwant: %q", access, wantAccess)
}
// Commands: visudo -c -f, sss_cache -E, pkill -u olduser.
ran := map[string]bool{}
for _, c := range exec.ExecutedCommands {
if c[0] == "visudo" {
ran["visudo"] = true
}
if c[0] == "sss_cache" {
ran["sss_cache"] = true
}
if c[0] == "pkill" && len(c) >= 3 && c[2] == "olduser" {
ran["pkill"] = true
}
}
for _, k := range []string{"visudo", "sss_cache", "pkill"} {
if !ran[k] {
t.Errorf("expected %s to run, got commands %v", k, exec.ExecutedCommands)
}
}
}
// TestParseIAMPayload verifies the payload parses from a WSMessage payload map.
func TestParseIAMPayload(t *testing.T) {
payload := map[string]interface{}{
"node_id": "node-42",
"revision": float64(82),
"access_control": map[string]interface{}{
"allowed_login_groups": []interface{}{"sysadmins"},
"sudo_rules": []interface{}{
map[string]interface{}{"group": "sysadmins", "run_as": "ALL", "commands": []interface{}{"ALL"}, "nopasswd": true},
},
"ssh_keys": []interface{}{
map[string]interface{}{"user": "admin", "keys": []interface{}{"ssh-ed25519 AAAA"}},
},
"revoke_users": []interface{}{"olduser"},
},
}
p, err := parseIAMPayload(payload)
if err != nil {
t.Fatalf("parseIAMPayload: %v", err)
}
if p.NodeID != "node-42" || p.Revision != 82 {
t.Fatalf("bad node/revision: %+v", p)
}
if len(p.AccessControl.SudoRules) != 1 || p.AccessControl.SudoRules[0].Group != "sysadmins" {
t.Fatalf("bad sudo rules: %+v", p.AccessControl.SudoRules)
}
if len(p.AccessControl.SSHKeys) != 1 || p.AccessControl.SSHKeys[0].User != "admin" {
t.Fatalf("bad ssh keys: %+v", p.AccessControl.SSHKeys)
}
if len(p.AccessControl.RevokeUsers) != 1 || p.AccessControl.RevokeUsers[0] != "olduser" {
t.Fatalf("bad revoke users: %+v", p.AccessControl.RevokeUsers)
}
}
+33 -18
View File
@@ -19,7 +19,7 @@ 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
@@ -29,9 +29,10 @@ install_sssd_deps() {
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 pam-auth-update || 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 --enable mkhomedir || true
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
@@ -45,16 +46,19 @@ install_sssd_deps() {
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"
@@ -72,6 +76,13 @@ while [[ $# -gt 0 ]]; do
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
@@ -83,15 +94,17 @@ 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 \"ISSUED_TOKEN\" --public-key \"BASE64_KEY\" --install-sssd"
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 "The token must be issued by the SSO (Directory -> Install Agent enrolls"
echo "the host and mints it). Tokens the server did not issue are rejected."
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
@@ -99,8 +112,9 @@ log "Starting Theta Agent installation..."
# 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"
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary."
chmod +x "$BIN_PATH.tmp"
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
# 4. Setup configuration
log "Preparing configuration directory $CONFIG_DIR..."
@@ -110,21 +124,24 @@ 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 <<EOF > "$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"
@@ -138,7 +155,7 @@ if ! grep -qE '^public_key:[[:space:]]*"[^"]+"' "$CONFIG_FILE" 2>/dev/null; then
fi
# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled
if [ "$INSTALL_SSSD" -eq 1 ] || grep -q -i "configure_ldap:\s*true" "$CONFIG_FILE" 2>/dev/null; then
if [ "$INSTALL_SSSD" -eq 1 ] || grep -qE -i 'configure_ldap:[[:space:]]*true' "$CONFIG_FILE" 2>/dev/null; then
install_sssd_deps
fi
@@ -154,8 +171,6 @@ Type=simple
ExecStart=$BIN_PATH
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent
[Install]
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/gorilla/websocket"
)
// ldapTunnel is the agent's local LDAP socket (DESIGN.md §4). It is a pure byte
// pump: bytes from a local LDAP client (SSSD) are forwarded to the SSO over the
// WSS channel as `ldap_tunnel` messages, and the SSO's responses are written
// back to the socket. The agent never parses LDAP — it does not know or care
// what the bytes mean.
//
// Each local connection gets a conn_id. The agent reads the socket and sends
// chunks up; the SSO relays them into its real OpenLDAP and sends the response
// chunks back down; the agent writes them to the socket. `close:true` ends a
// connection.
type ldapTunnel struct {
mu sync.Mutex
conns map[string]net.Conn
send func(WSMessage) error
}
func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
return &ldapTunnel{
conns: make(map[string]net.Conn),
send: send,
}
}
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
os.Remove(socketPath)
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
os.MkdirAll(dir, 0755)
}
// 1. UNIX Domain Socket Listener
lnUnix, err := net.Listen("unix", socketPath)
if err == nil {
os.Chmod(socketPath, 0666)
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
go t.acceptLoop(lnUnix, stopCh)
} else {
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
}
// 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890)
lnTcp, errTcp := net.Listen("tcp", "127.0.0.1:389")
if errTcp != nil {
lnTcp, errTcp = net.Listen("tcp", "127.0.0.1:3890")
}
if errTcp == nil {
log.Printf("LDAP tunnel: listening on tcp %s", lnTcp.Addr().String())
go t.acceptLoop(lnTcp, stopCh)
} else {
log.Printf("LDAP tunnel: cannot bind tcp loopback: %v", errTcp)
}
}
func (t *ldapTunnel) acceptLoop(ln net.Listener, stopCh <-chan struct{}) {
defer ln.Close()
go func() {
<-stopCh
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
return
}
go t.handleConn(conn)
}
}
// handleConn pumps one local LDAP connection up to the SSO.
func (t *ldapTunnel) handleConn(conn net.Conn) {
connID := newConnID()
t.mu.Lock()
t.conns[connID] = conn
t.mu.Unlock()
buf := make([]byte, 32*1024)
for {
n, err := conn.Read(buf)
if n > 0 {
msg := WSMessage{
Type: "ldap_tunnel",
Payload: map[string]interface{}{
"conn_id": connID,
"data": base64.StdEncoding.EncodeToString(buf[:n]),
},
}
if err := t.send(msg); err != nil {
break
}
}
if err != nil {
break
}
}
// Signal end of connection to the SSO so it can close its OpenLDAP relay.
t.send(WSMessage{
Type: "ldap_tunnel",
Payload: map[string]interface{}{
"conn_id": connID,
"close": true,
},
})
t.mu.Lock()
delete(t.conns, connID)
t.mu.Unlock()
conn.Close()
}
// handleMessage writes SSO→agent tunnel bytes to the matching local socket.
// Called from handleCommand when an `ldap_tunnel` message arrives.
func (t *ldapTunnel) handleMessage(payload map[string]interface{}) {
connID, _ := payload["conn_id"].(string)
if connID == "" {
return
}
if closeFlag, _ := payload["close"].(bool); closeFlag {
t.mu.Lock()
conn := t.conns[connID]
delete(t.conns, connID)
t.mu.Unlock()
if conn != nil {
conn.Close()
}
return
}
dataStr, _ := payload["data"].(string)
if dataStr == "" {
return
}
data, err := base64.StdEncoding.DecodeString(dataStr)
if err != nil {
return
}
t.mu.Lock()
conn := t.conns[connID]
t.mu.Unlock()
if conn != nil {
conn.Write(data)
}
}
var connCounter uint64
func newConnID() string {
connCounter++
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), connCounter)
}
// safeWriter serializes writes to the WebSocket. Gorilla allows only one
// concurrent writer, but the agent has several (telemetry, heartbeat, the LDAP
// tunnel, command responses) — without this, concurrent WriteMessage calls
// corrupt the stream.
type safeWriter struct {
mu sync.Mutex
c *websocket.Conn
}
func (w *safeWriter) WriteMessage(messageType int, data []byte) error {
w.mu.Lock()
defer w.mu.Unlock()
return w.c.WriteMessage(messageType, data)
}
// sendTunnelMessage marshals a WSMessage and writes it as a text frame.
func sendTunnelMessage(w MessageWriter, msg WSMessage) error {
payload, err := json.Marshal(msg)
if err != nil {
return err
}
return w.WriteMessage(websocket.TextMessage, payload)
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"encoding/base64"
"net"
"path/filepath"
"sync"
"testing"
"time"
)
// TestLdapTunnelBytePump verifies the agent is a pure byte pump: bytes written
// to the local socket are forwarded up as ldap_tunnel messages, and bytes sent
// back down are written to the socket. No LDAP parsing anywhere.
func TestLdapTunnelBytePump(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "ldap.sock")
var mu sync.Mutex
var sent []WSMessage
tunnel := newLdapTunnel(func(msg WSMessage) error {
mu.Lock()
sent = append(sent, msg)
mu.Unlock()
return nil
})
stopCh := make(chan struct{})
defer close(stopCh)
go tunnel.start(socketPath, stopCh)
// Wait for the socket to exist.
deadline := time.Now().Add(2 * time.Second)
for {
if _, err := net.Dial("unix", socketPath); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("socket never became reachable")
}
time.Sleep(10 * time.Millisecond)
}
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Write bytes up to the SSO.
if _, err := conn.Write([]byte("hello-ldap")); err != nil {
t.Fatalf("write: %v", err)
}
// The tunnel should forward them as a base64 ldap_tunnel message.
var upMsg WSMessage
deadline = time.Now().Add(2 * time.Second)
for {
mu.Lock()
if len(sent) > 0 {
upMsg = sent[0]
mu.Unlock()
break
}
mu.Unlock()
if time.Now().After(deadline) {
t.Fatal("no ldap_tunnel message was sent")
}
time.Sleep(10 * time.Millisecond)
}
if upMsg.Type != "ldap_tunnel" {
t.Fatalf("expected ldap_tunnel type, got %q", upMsg.Type)
}
connID, _ := upMsg.Payload["conn_id"].(string)
if connID == "" {
t.Fatal("missing conn_id")
}
dataStr, _ := upMsg.Payload["data"].(string)
decoded, err := base64.StdEncoding.DecodeString(dataStr)
if err != nil {
t.Fatalf("bad base64: %v", err)
}
if string(decoded) != "hello-ldap" {
t.Fatalf("expected 'hello-ldap', got %q", decoded)
}
// Send bytes back down from the SSO; the client should receive them.
tunnel.handleMessage(map[string]interface{}{
"conn_id": connID,
"data": base64.StdEncoding.EncodeToString([]byte("world-ldap")),
})
buf := make([]byte, 32)
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(buf[:n]) != "world-ldap" {
t.Fatalf("expected 'world-ldap', got %q", buf[:n])
}
// Closing the client should emit a close signal up to the SSO.
conn.Close()
deadline = time.Now().Add(2 * time.Second)
for {
mu.Lock()
closed := false
for _, m := range sent {
if m.Type == "ldap_tunnel" {
if c, _ := m.Payload["close"].(bool); c {
closed = true
}
}
}
mu.Unlock()
if closed {
break
}
if time.Now().After(deadline) {
t.Fatal("no close signal was sent")
}
time.Sleep(10 * time.Millisecond)
}
}
+6 -1
View File
@@ -5,15 +5,20 @@ import (
"log"
"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]
}
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
// Secrets engine (DESIGN.md §5). The agent renders local templates that embed
// OpenBao secrets, e.g.:
//
// # /etc/theta/templates/db.env.tpl
// DB_USER="{{ bao "secret/data/nodes/node-42/db#username" }}"
// DB_PASS="{{ bao "secret/data/nodes/node-42/db#password" }}"
//
// The agent parses the `{{ bao "path#key" }}` placeholders, fetches the secret
// values from the SSO (which holds the OpenBao access), renders each template to
// its target atomically (0600), and runs the configured reload. The agent never
// holds a Vault token.
var baoRe = regexp.MustCompile(`\{\{\s*bao\s+"([^"]+)"\s*\}\}`)
// renderSecrets renders every configured secret template. Called on a
// `render_secrets` command (signed) and on boot.
func renderSecrets(cfg *Config, exec Executor) error {
if len(cfg.Secrets) == 0 {
return nil
}
// Collect the unique secret paths referenced across all templates.
pathSet := map[string]bool{}
var paths []string
for _, t := range cfg.Secrets {
content, err := os.ReadFile(t.Template)
if err != nil {
log.Printf("Secrets: cannot read template %s: %v", t.Template, err)
continue
}
for _, m := range baoRe.FindAllStringSubmatch(string(content), -1) {
path := refPath(m[1])
if !pathSet[path] {
pathSet[path] = true
paths = append(paths, path)
}
}
}
if len(paths) == 0 {
return nil
}
secrets, err := fetchSecrets(cfg, paths)
if err != nil {
return err
}
for _, t := range cfg.Secrets {
if err := renderOne(t, secrets, exec); err != nil {
log.Printf("Secrets: render %s failed: %v", t.Template, err)
}
}
return nil
}
func renderOne(t SecretTarget, secrets map[string]map[string]interface{}, exec Executor) error {
content, err := os.ReadFile(t.Template)
if err != nil {
return err
}
out := baoRe.ReplaceAllStringFunc(string(content), func(match string) string {
ref := baoRe.FindStringSubmatch(match)[1]
path, key := refPath(ref), refKey(ref)
if v, ok := secrets[path][key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
})
// Atomic write: temp file in the target's directory, then rename. 0600 — the
// rendered file holds secrets.
tmp, err := os.CreateTemp(filepath.Dir(t.Target), ".theta-secret-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := io.WriteString(tmp, out); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
os.Chmod(tmpName, 0600)
if err := os.Rename(tmpName, t.Target); err != nil {
os.Remove(tmpName)
return err
}
if t.Reload != "" {
if _, err := exec.Execute("sh", "-c", t.Reload); err != nil {
log.Printf("Secrets: reload %q failed: %v", t.Reload, err)
}
}
return nil
}
// fetchSecrets asks the SSO for the given node-scoped secret paths.
func fetchSecrets(cfg *Config, paths []string) (map[string]map[string]interface{}, error) {
base := strings.Replace(cfg.ServerURL, "wss://", "https://", 1)
base = strings.Replace(base, "ws://", "http://", 1)
url := strings.TrimRight(base, "/") + "/api/v1/agent/secrets"
body, _ := json.Marshal(map[string]interface{}{"paths": paths})
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.Credential())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("secrets fetch failed: %s", resp.Status)
}
var out struct {
Secrets map[string]map[string]interface{} `json:"secrets"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out.Secrets, nil
}
// refPath returns the secret path from a `path#key` reference.
func refPath(ref string) string {
path := ref
if i := strings.Index(ref, "#"); i >= 0 {
path = ref[:i]
}
if strings.HasPrefix(path, "resource/") {
return "secret/data/resources/" + strings.TrimPrefix(path, "resource/") + "/conf"
}
if strings.HasPrefix(path, "resources/") {
return "secret/data/resources/" + strings.TrimPrefix(path, "resources/") + "/conf"
}
if !strings.HasPrefix(path, "secret/") {
return "secret/data/resources/" + path + "/conf"
}
return path
}
// refKey returns the key from a `path#key` reference.
func refKey(ref string) string {
if i := strings.Index(ref, "#"); i >= 0 {
return ref[i+1:]
}
return ""
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// TestRenderSecrets verifies the agent parses `{{ bao "path#key" }}` placeholders,
// fetches the secrets from the SSO, renders the template to its target
// atomically, and runs the reload.
func TestRenderSecrets(t *testing.T) {
dir := t.TempDir()
tpl := filepath.Join(dir, "db.env.tpl")
target := filepath.Join(dir, "db.env")
os.WriteFile(tpl, []byte("DB_USER=\"{{ bao \"secret/data/nodes/n1/db#username\" }}\"\nDB_PASS=\"{{ bao \"secret/data/nodes/n1/db#password\" }}\"\n"), 0600)
// Fake SSO secrets endpoint.
var gotPaths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agent/secrets" {
w.WriteHeader(404)
return
}
var req struct{ Paths []string `json:"paths"` }
json.NewDecoder(r.Body).Decode(&req)
gotPaths = req.Paths
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"secrets": map[string]interface{}{
"secret/data/nodes/n1/db": map[string]interface{}{
"username": "alice",
"password": "s3cret",
},
},
})
}))
defer srv.Close()
cfg := &Config{
ServerURL: srv.URL,
AuthToken: "tok",
Secrets: []SecretTarget{
{Template: tpl, Target: target, Reload: ""},
},
}
exec := &MockExecutor{}
if err := renderSecrets(cfg, exec); err != nil {
t.Fatalf("renderSecrets: %v", err)
}
// The requested path should be the one in the template.
if len(gotPaths) != 1 || gotPaths[0] != "secret/data/nodes/n1/db" {
t.Fatalf("expected to request secret/data/nodes/n1/db, got %v", gotPaths)
}
// The target should be rendered with the secret values.
content, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read target: %v", err)
}
expected := "DB_USER=\"alice\"\nDB_PASS=\"s3cret\"\n"
if string(content) != expected {
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
}
// The target should be 0600 (holds secrets).
info, _ := os.Stat(target)
if info.Mode().Perm() != 0600 {
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
}
}
// TestRenderSecretsReload verifies the reload command runs after rendering.
func TestRenderSecretsReload(t *testing.T) {
dir := t.TempDir()
tpl := filepath.Join(dir, "app.tpl")
target := filepath.Join(dir, "app.conf")
os.WriteFile(tpl, []byte("KEY={{ bao \"secret/data/nodes/n1/app#key\" }}"), 0600)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"secrets": map[string]interface{}{
"secret/data/nodes/n1/app": map[string]interface{}{"key": "v"},
},
})
}))
defer srv.Close()
cfg := &Config{
ServerURL: srv.URL,
AuthToken: "tok",
Secrets: []SecretTarget{
{Template: tpl, Target: target, Reload: "systemctl reload app"},
},
}
exec := &MockExecutor{}
if err := renderSecrets(cfg, exec); err != nil {
t.Fatalf("renderSecrets: %v", err)
}
if len(exec.ExecutedCommands) != 1 {
t.Fatalf("expected 1 reload command, got %v", exec.ExecutedCommands)
}
cmd := exec.ExecutedCommands[0]
if len(cmd) != 3 || cmd[0] != "sh" || cmd[2] != "systemctl reload app" {
t.Fatalf("expected reload 'sh -c systemctl reload app', got %v", cmd)
}
}
+48 -8
View File
@@ -3,8 +3,10 @@ package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"time"
@@ -16,14 +18,16 @@ import (
)
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"`
RAMTotalGB float64 `json:"ram_total_gb"`
DiskTotalGB float64 `json:"disk_total_gb"`
Location string `json:"location"`
Capabilities map[string]interface{} `json:"capabilities"`
}
type TelemetryData struct {
@@ -35,6 +39,29 @@ type TelemetryData struct {
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 ""
}
// CollectDiscoveryData gathers static host information.
func CollectDiscoveryData(cfg *Config) DiscoveryData {
h, _ := host.Info()
@@ -58,15 +85,28 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
cpuModel = cpuInfo[0].Model
}
pubIP := getPublicIP()
return DiscoveryData{
Hostname: h.Hostname,
IPs: ips,
PublicIP: pubIP,
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,
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,
"service_control": cfg.Capabilities.ServiceControl,
"arbitrary_bash": cfg.Capabilities.ArbitraryBash,
},
}
}
BIN
View File
Binary file not shown.
Binary file not shown.
+159 -7
View File
@@ -121,7 +121,21 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
log.Fatalf("Invalid ServerURL: %v", err)
}
u.Path = "/api/agent/ws"
u.RawQuery = "token=" + url.QueryEscape(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()
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
}
// Never log u.String(): RawQuery carries the auth token, and agent logs
// are routinely shipped around and pasted into issues.
@@ -146,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() {
@@ -160,7 +192,7 @@ 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
}
}
@@ -194,7 +226,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
continue
}
handleCommand(cm, msg, c, exec)
handleCommand(cm, msg, sw, exec, tunnel)
}
// Cleanup on disconnect
@@ -212,11 +244,13 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
}
}
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()
// 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.
if msg.Type != "heartbeat_ack" {
// 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)
}
@@ -226,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)
@@ -249,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)
@@ -292,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":
@@ -344,19 +405,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")
+1 -1
View File
@@ -249,7 +249,7 @@ func TestHandleCommand(t *testing.T) {
if tc.signed {
msg.Payload = sign(t, msg.Payload)
}
handleCommand(cm, msg, mockConn, mockExec)
handleCommand(cm, msg, mockConn, mockExec, nil)
if tc.expectedNoResponse {
if len(mockConn.Messages) != 0 {