Compare commits

2 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
26 changed files with 1848 additions and 36 deletions
+58
View File
@@ -5,6 +5,64 @@ 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
+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).
+20
View File
@@ -70,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)
@@ -153,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. |
+25 -2
View File
@@ -26,16 +26,39 @@ 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")
}
}
+18 -1
View File
@@ -16,6 +16,17 @@ 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 {
@@ -28,7 +39,9 @@ type Config struct {
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
@@ -146,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
}
+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)
}
}
+18 -14
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,6 +46,8 @@ install_sssd_deps() {
else
log "SSSD is already installed."
fi
mkdir -p /etc/sssd
chmod 755 /etc/sssd
}
# 2. Argument Parsing
@@ -55,7 +58,7 @@ PUBLIC_KEY=""
B64_CONFIG=""
INSTALL_SSSD=0
while [[ $# -gt 0 ]]; do
while [ $# -gt 0 ]; do
case $1 in
--url)
URL="$2"
@@ -91,8 +94,8 @@ while [[ $# -gt 0 ]]; do
esac
done
# Validation
if [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
# 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\""
@@ -109,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..."
@@ -120,9 +124,8 @@ 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"
@@ -131,11 +134,14 @@ 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"
@@ -149,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
@@ -165,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.
+126 -6
View File
@@ -160,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() {
@@ -174,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
}
}
@@ -208,7 +226,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
continue
}
handleCommand(cm, msg, c, exec)
handleCommand(cm, msg, sw, exec, tunnel)
}
// Cleanup on disconnect
@@ -226,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)
}
@@ -240,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)
@@ -263,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)
@@ -376,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 {