Compare commits

..

1 Commits

Author SHA1 Message Date
wmantly 7d1cfd08a5 fix: ship the rebuilt binary with heartbeat_ack fix + test (v1.3.1)
The prebuilt theta-agent-linux-amd64 was built before the v1.3.0 heartbeat_ack
fix, so the installed agent still logged 'Unknown command type: heartbeat_ack'.
Rebuild it with the fix; add a test asserting heartbeat_ack is silently ignored
(no response, no command, no log).
2026-08-05 02:26:46 -04:00
13 changed files with 32 additions and 695 deletions
-41
View File
@@ -5,47 +5,6 @@ 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.5.1] - 2026-08-06
### Fixed
- **Rebuilt the prebuilt `theta-agent-linux-amd64`.** theta-suite's `setup.sh` installs that committed binary rather than building from source, so a stale one means the fix in this repo never reaches the host. The v1.5.0 binary predated join-key support: an install would have written a `join_key` into `agent.yml` that the running agent did not understand, and it would have looped on `close 4001: Unauthorized`. (Same trap as the v1.3.0 heartbeat fix.)
## [v1.5.0] - 2026-08-06
Join-key enrollment (protocol v1.2.0 §1.1). Installing the agent with one key is now all it takes to add a host.
### Added
- **`join_key` config field.** Presented while `auth_token` is empty. The SSO exchanges it for this agent's own token and the public key it must pin, both delivered in the `config` frame; the agent writes them into `agent.yml` and blanks the join key. No value has to be copied between two machines by hand any more.
- `ConfigManager.PersistEnrollment` rewrites only the credential lines, line-based rather than a YAML round-trip, so operator comments, the capability matrix and formatting survive. Re-reads the file afterwards, so the new credential is live without a restart, and keeps the file at `0600`.
- `Config.Credential()` — the agent's own token when it has one, otherwise the join key.
- The connect URL carries `?hostname=`, so a self-enrolling host is named after itself instead of a generated placeholder.
- `install.sh --join-key`.
### Fixed
- The agent now refuses to connect (with a clear message and a long back-off) when it has neither an `auth_token` nor a `join_key`, rather than repeatedly presenting an empty credential.
## [v1.4.0] - 2026-08-05
Implements **Protocol v1.2.0**. See `PROTOCOL.md` §1.1, §5.15.3.
### Security
- **Fail-closed signature verification.** `verifySignature` returned `true` when no `public_key` was configured, logging "skipping signature verification". Combined with an installer that never wrote a `public_key`, that meant a default install would execute `reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash` and `update_binary` **unverified** from anything that could reach its socket. An agent that cannot verify a high-risk command now refuses it.
- **The token must be issued by the server.** The SSO now rejects tokens it did not mint (close code `4001`). Agents carrying a token generated by the old browser-side installer will not connect until re-enrolled.
### Fixed
- **Canonicalization mismatch broke signatures for most real scripts.** Go's `encoding/json` escapes `<`, `>` and `&` by default; the server's `JSON.stringify` does not. Any payload containing them — an `arbitrary_bash` script using `>` redirection or `&&`, which is most of them — hashed differently on each side and failed verification. `canonicalize()` now uses `json.Encoder` with `SetEscapeHTML(false)` and trims the encoder's trailing newline.
- **Auth failures no longer hot-loop.** A rejected credential was retried every 5 seconds forever, flooding the SSO and its audit log. Close codes `4001`/`4003`/`4004` now back off for 5 minutes and log what to do about it.
- **The auth token no longer appears in logs.** The connect line logged the full URL, including `?token=...`. It now logs only host + path, and the token is URL-escaped.
### Added
- Close-code handling for the SSO's enrollment signals: `4001` unauthorized, `4002` superseded, `4003` revoked, `4004` token rotated.
- `install.sh --public-key <base64>`, written into the generated `agent.yml`. The installer warns loudly when no public key is configured, since such an agent can report telemetry but will refuse every high-risk command.
- Tests: fail-closed with no key, wrong key, payload tampered after signing, shell metacharacters (`>`, `&&`, `<`) round-tripping, and canonical-form equality with the server. `interop_check_test.go` verifies a signature produced by the live SSO against the agent's own verifier (skipped unless `INTEROP_FIXTURE` is set).
### Changed
- Existing tests no longer rely on verification being skipped; high-risk cases now sign with a real test key.
- `agent.yml.example`, `README.md`, `INSTALL.md`: enrollment is a prerequisite, and `public_key` is the base64 of the **raw 32-byte** Ed25519 key — not a PEM body. The previous documented example (`MCowBQYDK2VwAyEA...`) decodes to 44 bytes and would have been rejected.
## [v1.3.0] - 2026-08-04
### Fixed
+1 -14
View File
@@ -2,18 +2,6 @@
Theta Agent is designed for rapid deployment across the fleet. The recommended method is via the "One-Liner" install, which marries the agent to a specific SSO Manager instance.
## Prerequisite: enroll the host
The agent's token is issued by the SSO, not chosen by you. In the SSO open
**Directory → Install Agent**, name the host, bind it to a host resource, and
press **Enroll & issue token**. You get:
- the **agent token** — shown once; only its hash is stored
- the **SSO public key** — pinned by the agent to verify high-risk commands
The modal builds the install command below with both already filled in. A token
the SSO did not issue is rejected at connect time with close code `4001`.
## Quick Start (The One-Liner)
The SSO Manager provides a pre-generated installation command. Copy and paste it into your terminal as root:
@@ -27,8 +15,7 @@ curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- "
### Option B: Minimal Setup
Use this for rapid deployment with basic telemetry:
```bash
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- \
--url "https://sso.example.com" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- --url "https://sso.example.com" --token "your-host-token"
```
### What this does:
+4 -106
View File
@@ -1,4 +1,4 @@
# Theta Agent Protocol Specification (v1.2.0)
# Theta Agent Protocol Specification (v1.1.0)
This document defines the communication protocol between the `theta-agent` (Client) and the `sso-manager` (Server).
@@ -7,55 +7,8 @@ This document defines the communication protocol between the `theta-agent` (Clie
The agent establishes a persistent outbound WebSocket connection.
- **Endpoint**: `wss://<manager-url>/api/agent/ws`
- **Authentication**: The agent must provide its enrollment token as a query parameter:
- `wss://<manager-url>/api/agent/ws?token=<AGENT_TOKEN>`
### 1.1 Enrollment (changed in v1.2.0)
Two credentials can appear in `agent.yml`. The agent presents `auth_token` when
it has one, otherwise `join_key`:
| Field | Meaning |
| :--- | :--- |
| `auth_token` | This agent's own token, issued by the server. Long-term identity. |
| `join_key` | Bootstrap credential (`tjk_…`), exchanged for an `auth_token` on first connect. |
**Join-key flow.** The agent connects presenting a join key and
`?hostname=<its hostname>`. The server enrolls the host and answers with a
`config` frame carrying `enrolled: true`, `auth_token` and `public_key`. The
agent writes both into `agent.yml`, blanks `join_key`, and uses its own token
from then on. This is what makes "install the agent with a key" sufficient to
add a host — no value has to be copied between two machines by hand.
The public key is accepted on first connect (trust on first use) over the same
channel that issued the token. Pre-register the host instead if you need the
trust anchor pinned out of band.
The token **must be issued by the server**. An administrator enrolls the agent in
the SSO (Directory → Agents, or `POST /api/agent/enroll`), which mints the token,
stores only its SHA-256, and displays the raw value once. That value goes into
`auth_token` in `agent.yml`.
Up to v1.1.0 the token was generated in the browser and never recorded
server-side, so the server accepted *any* string: anyone who could reach
`/api/agent/ws` could register as a node, publish discovery/telemetry, and
receive commands addressed to a token they guessed. Tokens the server did not
issue are now rejected.
The server accepts the WebSocket upgrade before authenticating, so an
authentication failure arrives as a **close frame**, not an HTTP status:
| Code | Meaning | Agent behaviour |
| :--- | :--- | :--- |
| `4001` | Credential unknown — neither an issued token nor a valid join key | Back off (5 min); the credential will not fix itself |
| `4002` | Superseded — another connection authenticated as this agent | Normal reconnect |
| `4003` | Enrollment revoked or deleted by an administrator | Back off (5 min) |
| `4004` | Token rotated — `agent.yml` holds the superseded value | Back off (5 min); re-copy the token |
Revocation and rotation both drop any live socket immediately, so they take
effect without waiting for the agent to reconnect.
- **Authentication**: The agent must provide a unique host token as a query parameter:
- `wss://<manager-url>/api/agent/ws?token=<HOST_TOKEN>`
## 2. Message Format
@@ -118,20 +71,6 @@ 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`.
@@ -158,50 +97,9 @@ These commands **require** an Ed25519 signature in the payload. The agent verifi
To send a high-risk command:
1. Create the payload (e.g., `{"script": "uptime"}`).
2. Canonicalize the JSON (see 5.1).
2. Canonicalize the JSON (sort keys alphabetically, remove whitespace).
3. Sign the canonical bytes using the private Ed25519 key.
4. Add the base64 signature to the payload: `{"script": "uptime", "signature": "..."}`.
5. Send as a `WSMessage`.
The agent performs the reverse process to verify authenticity before execution.
### 5.1 Canonical form
Both sides must produce **byte-identical** input to sign/verify:
- keys sorted alphabetically
- no insignificant whitespace
- the `signature` key omitted
- **no HTML escaping** — `<`, `>` and `&` are emitted literally
- no trailing newline
The escaping rule is load-bearing. Go's `encoding/json` escapes those three
characters by default while JavaScript's `JSON.stringify` does not, so a payload
containing any of them hashed differently on each side and verification failed.
For `arbitrary_bash` that is most real scripts (`>` redirection, `&&`). The Go
client uses `json.Encoder` with `SetEscapeHTML(false)`.
Example — payload `{"script": "echo a > b && c", "comment": "x&y"}` canonicalizes to:
```
{"comment":"x&y","script":"echo a > b && c"}
```
### 5.2 The server signing key (changed in v1.2.0)
The server's Ed25519 key pair is **persistent**, stored in OpenBao at
`secret/agent/signing-key`. `public_key` in `agent.yml` is the base64-encoded raw
32-byte public key, available from the enrollment response or
`GET /api/agent/nodes`.
Previously the pair was generated in memory at process start, so it changed on
every restart and no agent could meaningfully pin it. If the server cannot load
or persist a key it now **refuses to send high-risk commands** rather than
signing with a key no agent has seen.
### 5.3 Agent-side verification is fail-closed (changed in v1.2.0)
An agent with no `public_key` configured **rejects** every high-risk command.
Until v1.1.0 it logged "skipping signature verification" and executed them,
which meant an agent installed without a key would run `reboot`,
`configure_ldap` and `arbitrary_bash` from anything that reached its socket.
+2 -26
View File
@@ -39,17 +39,6 @@ The agent will **only** execute commands that are explicitly enabled in its loca
### Cryptographic Hardening
All high-risk commands require an Ed25519 signature. The agent verifies the signature against the `public_key` provided in the local config. If the signature is missing or invalid, the command is rejected regardless of the capability matrix.
Verification is **fail-closed**: an agent with no `public_key` configured rejects
every high-risk command. (Before protocol v1.2.0 it logged "skipping signature
verification" and executed them, so an agent installed without a key would run
`reboot`, `configure_ldap` and `arbitrary_bash` unverified.)
### Enrollment
The agent's token must be **issued by the SSO**. The server stores only its
SHA-256 and rejects anything else at the WebSocket handshake, so a token cannot
be minted client-side, and an enrollment can be revoked or rotated centrally —
either drops the agent's live connection immediately. See `PROTOCOL.md` §1.1.
### Capability Matrix
| Capability | Risk Level | Description | Impact |
@@ -67,7 +56,7 @@ Configuration is stored in YAML format at `/etc/theta42/agent.yml`.
### Example `agent.yml`
```yaml
server_url: "wss://sso.theta42.local"
auth_token: "issued-by-the-sso-at-enrollment"
auth_token: "your-unique-host-token"
public_key: "base64-encoded-ed25519-public-key"
location: "dc-01-rack-12"
capabilities:
@@ -82,12 +71,7 @@ capabilities:
1. **Build**: Compile for your target architecture (see CI/CD artifacts).
2. **Deploy**: Place the binary in `/usr/local/bin/theta-agent`.
3. **Enroll**: In the SSO, open **Directory → Install Agent**, name the host, bind
it to a host resource, and press **Enroll & issue token**. The SSO mints the
token (shown once) and gives you its public key. Tokens the server did not
issue are rejected.
4. **Configure**: Create `/etc/theta42/agent.yml` with the issued `auth_token`,
the SSO's `public_key`, and your capabilities.
3. **Configure**: Create `/etc/theta42/agent.yml` with the required token and capabilities.
4. **Service**: Set up as a systemd unit (example: `/etc/systemd/system/theta-agent.service`).
For the fastest deployment, use the installation script:
@@ -95,14 +79,6 @@ For the fastest deployment, use the installation script:
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- "BASE64_ENCODED_CONFIG"
```
The **Install Agent** modal generates that command for you after enrollment,
with the token and public key already embedded. The equivalent flag form is:
```bash
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- \
--url "https://sso.example.com" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
```
## Development & Testing
The agent uses a decoupled execution engine for safety and testability.
+1 -22
View File
@@ -2,28 +2,7 @@
# Default location: /etc/theta42/agent.yml
server_url: "https://sso.example.com"
# This agent's own token. Leave EMPTY when installing with a join key -- the
# agent fills it in itself once the SSO enrolls it. The server records only a
# hash and rejects any token it did not issue, so a locally invented value will
# never connect.
auth_token: ""
# The one credential you need to add a host. Used only while auth_token is
# empty: the SSO exchanges it for this agent's own token + public key on first
# connect, and the agent then blanks this line. Get one from the SSO
# (Directory -> Install Agent, or POST /api/agent/join-keys).
join_key: ""
# Base64 of the SSO's RAW 32-byte Ed25519 public key (NOT a PEM body). Filled in
# automatically when enrolling with a join key; set it by hand only if you
# pre-registered this host.
#
# Required for any high-risk command. Without it the agent still reports
# telemetry, but REFUSES reboot / service_restart / configure_ldap /
# arbitrary_bash / update_binary, because it has no way to verify them.
public_key: ""
auth_token: "REPLACE_WITH_AGENT_TOKEN"
location: "default" # Location identifier (e.g., site, datacenter) for naming
capabilities:
+4 -76
View File
@@ -3,8 +3,6 @@ package main
import (
"fmt"
"os"
"regexp"
"strings"
"sync"
"gopkg.in/yaml.v3"
@@ -19,31 +17,17 @@ type Capabilities struct {
}
type Config struct {
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"`
ServerURL string `yaml:"server_url"`
AuthToken string `yaml:"auth_token"`
Location string `yaml:"location"`
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
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
}
@@ -77,62 +61,6 @@ 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 {
-106
View File
@@ -3,7 +3,6 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -97,108 +96,3 @@ 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")
}
}
+3 -36
View File
@@ -50,8 +50,6 @@ install_sssd_deps() {
# 2. Argument Parsing
URL=""
TOKEN=""
JOIN_KEY=""
PUBLIC_KEY=""
B64_CONFIG=""
INSTALL_SSSD=0
@@ -65,21 +63,6 @@ while [[ $# -gt 0 ]]; do
TOKEN="$2"
shift 2
;;
# Base64 of the SSO's raw Ed25519 public key. The agent verifies high-risk
# commands (reboot, configure_ldap, arbitrary_bash, update_binary) against
# it and REFUSES them when it is absent, so an install without this key can
# stream telemetry but cannot be acted on.
--public-key)
PUBLIC_KEY="$2"
shift 2
;;
# The one credential an operator hands out. The server exchanges it for a
# per-agent token on first connect, which the agent writes back into
# agent.yml -- so this is all you need to add a host.
--join-key)
JOIN_KEY="$2"
shift 2
;;
--install-sssd|--ldap)
INSTALL_SSSD=1
shift
@@ -92,16 +75,11 @@ while [[ $# -gt 0 ]]; do
done
# Validation
if [ -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."
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."
echo "Usage examples:"
echo " sh install.sh \"BASE64_CONFIG\""
echo " sh install.sh --url \"https://sso.local\" --join-key \"tjk_...\" --install-sssd"
echo " sh install.sh --url \"https://sso.local\" --token \"ISSUED_TOKEN\" --public-key \"BASE64_KEY\""
echo ""
echo "--join-key is the normal path: the host enrolls itself on first connect"
echo "and the SSO issues it its own token + public key, which the agent writes"
echo "back into agent.yml. Get a key from Directory -> Install Agent."
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\" --install-sssd"
exit 1
fi
@@ -126,8 +104,6 @@ else
cat <<EOF > "$CONFIG_FILE"
server_url: "$URL"
auth_token: "$TOKEN"
join_key: "$JOIN_KEY"
public_key: "$PUBLIC_KEY"
location: "unknown"
capabilities:
telemetry: true
@@ -139,15 +115,6 @@ EOF
fi
chmod 600 "$CONFIG_FILE"
# An agent with no public_key cannot verify signed commands and will refuse
# every one of them. That is the safe default, but it is silent at run time, so
# say it plainly here where the operator is watching.
if ! grep -qE '^public_key:[[:space:]]*"[^"]+"' "$CONFIG_FILE" 2>/dev/null; then
log "WARNING: no public_key configured — this agent will report telemetry but"
log " REFUSE reboot / configure_ldap / arbitrary_bash / update_binary."
log " Re-run with --public-key \"<base64 key>\" (shown at enrollment)."
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
install_sssd_deps
-36
View File
@@ -1,36 +0,0 @@
package main
import (
"encoding/json"
"os"
"testing"
)
// Cross-implementation check: a payload signed by the Node server (utils/
// agent_manager.js) must verify with the agent's own verifySignature. Skips
// unless the fixture is present, so it never breaks a normal `go test`.
func TestInteropWithServerSignature(t *testing.T) {
raw, err := os.ReadFile(os.Getenv("INTEROP_FIXTURE"))
if err != nil {
t.Skip("no INTEROP_FIXTURE provided")
}
var fx struct {
Pub string `json:"pub"`
Payload map[string]interface{} `json:"payload"`
Sig string `json:"sig"`
}
if err := json.Unmarshal(raw, &fx); err != nil {
t.Fatalf("bad fixture: %v", err)
}
payload := map[string]interface{}{}
for k, v := range fx.Payload {
payload[k] = v
}
payload["signature"] = fx.Sig
cfg := &Config{PublicKey: fx.Pub}
if !verifySignature(cfg, WSMessage{Type: "arbitrary_bash", Payload: payload}) {
t.Fatal("agent REJECTED a signature produced by the SSO server")
}
t.Log("agent accepted the server-produced signature")
}
BIN
View File
Binary file not shown.
Binary file not shown.
+7 -114
View File
@@ -1,7 +1,6 @@
package main
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
@@ -24,55 +23,14 @@ type WSMessage struct {
Payload map[string]interface{} `json:"payload"`
}
// Application close codes the SSO uses to say "your enrollment is the problem"
// (PROTOCOL.md §1.1). All three mean retrying quickly is pointless.
const (
closeUnauthorized = 4001 // token was never issued, or is unknown
closeSuperseded = 4002 // another connection took over this enrollment
closeRevoked = 4003 // enrollment revoked or deleted by an admin
closeTokenRotated = 4004 // token rotated; agent.yml holds the old one
)
// How long to wait before retrying after the server rejects our credential.
// Short enough that a re-enrollment is picked up without a restart, long enough
// that a decommissioned agent is not a permanent load on the SSO.
const authRetryInterval = 5 * time.Minute
type MessageWriter interface {
WriteMessage(messageType int, data []byte) error
}
// canonicalize produces the exact bytes the server signed (PROTOCOL.md §5):
// keys sorted alphabetically, no whitespace, `signature` omitted.
//
// encoding/json sorts map keys for us, but by default it also escapes <, > and
// & as <, > and & -- which Node's JSON.stringify on the server
// does not. Any payload containing those characters therefore hashed
// differently on each side and the signature failed. For arbitrary_bash that is
// most real scripts: `>` redirection and `&&` are everywhere. SetEscapeHTML
// (false) is what makes the two encoders agree.
//
// Encoder.Encode also appends a trailing newline, which must be trimmed or it
// is signed-over data the server never produced.
func canonicalize(payload map[string]interface{}) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(payload); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
func verifySignature(cfg *Config, msg WSMessage) bool {
// Fail CLOSED. This used to return true when no public key was configured,
// which meant an agent installed without a `public_key` would execute
// reboot / configure_ldap / arbitrary_bash from anything that could reach
// its socket, with no verification at all -- the exact commands the
// signature exists to protect. An agent that cannot verify must not act.
if cfg.PublicKey == "" {
log.Println("Refusing high-risk command: no public_key configured in agent.yml")
return false
log.Println("No public key configured; skipping signature verification")
return true
}
sigB64, ok := msg.Payload["signature"].(string)
@@ -94,11 +52,7 @@ func verifySignature(cfg *Config, msg WSMessage) bool {
payloadCopy[k] = v
}
}
canonicalPayload, err := canonicalize(payloadCopy)
if err != nil {
log.Printf("Could not canonicalize payload for verification: %v", err)
return false
}
canonicalPayload, _ := json.Marshal(payloadCopy)
pubKeyBytes, err := base64.StdEncoding.DecodeString(cfg.PublicKey)
if err != nil || len(pubKeyBytes) != ed25519.PublicKeySize {
@@ -121,36 +75,12 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
log.Fatalf("Invalid ServerURL: %v", err)
}
u.Path = "/api/agent/ws"
// 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()
u.RawQuery = "token=" + cfg.AuthToken
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
}
log.Printf("Connecting to %s", u.String())
// Never log u.String(): RawQuery carries the auth token, and agent logs
// are routinely shipped around and pasted into issues.
log.Printf("Connecting to %s%s", u.Host, u.Path)
c, resp, err := websocket.DefaultDialer.Dial(u.String(), nil)
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
// The server now rejects tokens it did not issue. Retrying a bad
// credential every 5s just floods the SSO and its audit log
// forever, so back off hard and say plainly what is wrong.
if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the SSO Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
log.Printf("Dial error: %v. Retrying in 5 seconds...", err)
time.Sleep(5 * time.Second)
continue
@@ -181,24 +111,11 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
}
}()
// Set when the server closes us for an enrollment problem rather than a
// transient fault, so the reconnect below can back off instead of
// spinning on a credential that will not start working by itself.
authRejected := false
// Read loop
for {
_, message, err := c.ReadMessage()
if err != nil {
// The SSO accepts the upgrade and only then closes with an
// application code, so an auth failure surfaces here rather
// than at Dial.
if websocket.IsCloseError(err, closeUnauthorized, closeRevoked, closeTokenRotated) {
authRejected = true
log.Printf("Server closed the connection: %v. This agent's token is not valid for that SSO — re-enroll it and update agent.yml.", err)
} else {
log.Println("WebSocket read error:", err)
}
log.Println("WebSocket read error:", err)
break // break read loop, reconnect
}
@@ -215,12 +132,6 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
close(stopCh)
c.Close()
if authRejected {
log.Printf("Reconnecting in %s.", authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
log.Println("WebSocket disconnected. Reconnecting in 5 seconds...")
time.Sleep(5 * time.Second)
}
@@ -306,24 +217,6 @@ 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":
+10 -118
View File
@@ -1,42 +1,11 @@
package main
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"testing"
)
// A fixed key pair for the tests, standing in for the SSO's persisted signing
// key. High-risk commands must now be genuinely signed: the agent fails closed
// when no public_key is configured, so these tests sign the way the server
// does instead of relying on verification being skipped.
var testPubKey, testPrivKey, _ = ed25519.GenerateKey(nil)
func testPubKeyB64() string {
return base64.StdEncoding.EncodeToString(testPubKey)
}
// sign mirrors the server's canonicalization (sorted keys, no whitespace, no
// HTML escaping, `signature` omitted) and adds the signature to the payload.
func sign(t *testing.T, payload map[string]interface{}) map[string]interface{} {
t.Helper()
if payload == nil {
payload = map[string]interface{}{}
}
canonical, err := canonicalize(payload)
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
signed := make(map[string]interface{}, len(payload)+1)
for k, v := range payload {
signed[k] = v
}
signed["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(testPrivKey, canonical))
return signed
}
type MockConn struct {
Messages [][]byte
}
@@ -75,16 +44,13 @@ func (m *MockExecutor) ReadFile(path string) ([]byte, error) {
func TestHandleCommand(t *testing.T) {
tests := []struct {
name string
cfg *Config
msg WSMessage
expectedStatus string
expectedCmd []string
expectedFile string
name string
cfg *Config
msg WSMessage
expectedStatus string
expectedCmd []string
expectedFile string
expectedFileCont string
// sign the payload with the test key before dispatch, the way the SSO
// signs high-risk commands
signed bool
// heartbeat_ack (and any fire-and-forget ack) must be silently ignored —
// no response message, no command, no log noise.
expectedNoResponse bool
@@ -103,13 +69,11 @@ func TestHandleCommand(t *testing.T) {
{
name: "reboot command allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{Reboot: true},
},
msg: WSMessage{
Type: "reboot",
},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"reboot"},
},
@@ -159,7 +123,6 @@ func TestHandleCommand(t *testing.T) {
{
name: "configure_ldap allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{ConfigureLDAP: true},
},
msg: WSMessage{
@@ -168,11 +131,10 @@ func TestHandleCommand(t *testing.T) {
"config": "domain = theta42.local\nserver = sso.local",
},
},
signed: true,
expectedStatus: "ok",
expectedFile: "/etc/sssd/sssd.conf",
expectedStatus: "ok",
expectedFile: "/etc/sssd/sssd.conf",
expectedFileCont: "domain = theta42.local\nserver = sso.local",
expectedCmd: []string{"systemctl", "restart", "sssd"},
expectedCmd: []string{"systemctl", "restart", "sssd"},
},
{
name: "configure_ldap denied",
@@ -191,7 +153,6 @@ func TestHandleCommand(t *testing.T) {
{
name: "arbitrary_bash allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{ArbitraryBash: true},
},
msg: WSMessage{
@@ -200,7 +161,6 @@ func TestHandleCommand(t *testing.T) {
"script": "uptime",
},
},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"bash", "-c", "uptime"},
},
@@ -245,11 +205,7 @@ func TestHandleCommand(t *testing.T) {
mockConn := &MockConn{}
mockExec := &MockExecutor{}
cm := &ConfigManager{current: tc.cfg}
msg := tc.msg
if tc.signed {
msg.Payload = sign(t, msg.Payload)
}
handleCommand(cm, msg, mockConn, mockExec)
handleCommand(cm, tc.msg, mockConn, mockExec)
if tc.expectedNoResponse {
if len(mockConn.Messages) != 0 {
@@ -303,67 +259,3 @@ func TestHandleCommand(t *testing.T) {
})
}
}
// The agent must not execute a high-risk command it cannot verify. This used to
// return true when no public_key was configured, so an agent installed without
// one executed reboot / configure_ldap / arbitrary_bash unverified.
func TestVerifySignatureFailsClosedWithoutPublicKey(t *testing.T) {
cfg := &Config{} // no PublicKey
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
if verifySignature(cfg, msg) {
t.Fatal("verifySignature accepted a command with no public_key configured")
}
}
func TestVerifySignatureRejectsWrongKey(t *testing.T) {
otherPub, _, _ := ed25519.GenerateKey(nil)
cfg := &Config{PublicKey: base64.StdEncoding.EncodeToString(otherPub)}
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
if verifySignature(cfg, msg) {
t.Fatal("verifySignature accepted a signature from a different key")
}
}
func TestVerifySignatureRejectsTamperedPayload(t *testing.T) {
cfg := &Config{PublicKey: testPubKeyB64()}
payload := sign(t, map[string]interface{}{"script": "uptime"})
payload["script"] = "rm -rf /" // swap the script, keep the signature
if verifySignature(cfg, WSMessage{Type: "arbitrary_bash", Payload: payload}) {
t.Fatal("verifySignature accepted a payload modified after signing")
}
}
// Regression: encoding/json escapes <, > and & by default, but the server's
// JSON.stringify does not. Any script using redirection or && therefore
// canonicalized differently on each side and failed verification -- which is
// most real scripts.
func TestVerifySignatureAcceptsShellMetacharacters(t *testing.T) {
cfg := &Config{PublicKey: testPubKeyB64()}
for _, script := range []string{
"echo hi > /tmp//out.log",
"systemctl is-active nginx && systemctl reload nginx",
"grep -c . < /etc/passwd",
"a=1 && b=2 && echo \"$a<$b\" > /dev/null",
} {
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": script})}
if !verifySignature(cfg, msg) {
t.Errorf("verifySignature rejected a correctly signed script: %q", script)
}
}
}
// The canonical form must be byte-identical to the server's: sorted keys, no
// whitespace, no HTML escaping, no trailing newline, signature omitted.
func TestCanonicalizeMatchesServerForm(t *testing.T) {
got, err := canonicalize(map[string]interface{}{
"script": "echo a > b && c",
"comment": "x&y",
})
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
want := `{"comment":"x&y","script":"echo a > b && c"}`
if string(got) != want {
t.Errorf("canonical form mismatch:\n got: %s\nwant: %s", got, want)
}
}