feat: release v1.6.0 with get-secret CLI, Zero-Trust LDAP tunnel, auto-updates and service restarts
This commit is contained in:
@@ -5,6 +5,17 @@ All notable changes to the `theta-agent` daemon will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [v1.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)
|
## [Unreleased] - LDAP byte-pump tunnel (DESIGN.md §4)
|
||||||
|
|
||||||
The agent now serves a local LDAP socket for SSSD/PAM. It is a **pure byte
|
The agent now serves a local LDAP socket for SSSD/PAM. It is a **pure byte
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ The agent dials out to the central SSO Manager via a persistent WebSocket connec
|
|||||||
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.
|
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.
|
- **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 automatically.** Services get their config files (DB passwords, TLS keys, API tokens) rendered from OpenBao to disk, atomically, with a post-render reload. Rotate a secret and it propagates.
|
- **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.
|
- **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.
|
- **Telemetry & remote operations.** Host discovery, live metrics, and signed remote commands (reboot, service control, config, self-update) — the original C2 capabilities.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,10 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("failed to decode YAML config: %w", err)
|
return nil, fmt.Errorf("failed to decode YAML config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.Capabilities.ConfigureLDAP {
|
||||||
|
cfg.Capabilities.LdapTunnel = true
|
||||||
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-14
@@ -19,7 +19,7 @@ log() { echo -e "${GREEN}[+]${NC} $1"; }
|
|||||||
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
|
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
# 1. Root check
|
# 1. Root check
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
|
||||||
error "This script must be run as root."
|
error "This script must be run as root."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -29,9 +29,10 @@ install_sssd_deps() {
|
|||||||
log "Installing SSSD and PAM integration dependencies..."
|
log "Installing SSSD and PAM integration dependencies..."
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
|
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
|
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
|
fi
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
dnf install -y sssd sssd-ldap sssd-tools || true
|
dnf install -y sssd sssd-ldap sssd-tools || true
|
||||||
@@ -45,6 +46,8 @@ install_sssd_deps() {
|
|||||||
else
|
else
|
||||||
log "SSSD is already installed."
|
log "SSSD is already installed."
|
||||||
fi
|
fi
|
||||||
|
mkdir -p /etc/sssd
|
||||||
|
chmod 755 /etc/sssd
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. Argument Parsing
|
# 2. Argument Parsing
|
||||||
@@ -55,7 +58,7 @@ PUBLIC_KEY=""
|
|||||||
B64_CONFIG=""
|
B64_CONFIG=""
|
||||||
INSTALL_SSSD=0
|
INSTALL_SSSD=0
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [ $# -gt 0 ]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
--url)
|
--url)
|
||||||
URL="$2"
|
URL="$2"
|
||||||
@@ -91,8 +94,8 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Validation
|
# Validation: require credentials ONLY if config file does not already exist
|
||||||
if [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
|
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."
|
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
|
||||||
echo "Usage examples:"
|
echo "Usage examples:"
|
||||||
echo " sh install.sh \"BASE64_CONFIG\""
|
echo " sh install.sh \"BASE64_CONFIG\""
|
||||||
@@ -109,8 +112,9 @@ log "Starting Theta Agent installation..."
|
|||||||
|
|
||||||
# 3. Install binary
|
# 3. Install binary
|
||||||
log "Downloading binary from $BINARY_URL..."
|
log "Downloading binary from $BINARY_URL..."
|
||||||
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
|
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary."
|
||||||
chmod +x "$BIN_PATH"
|
chmod +x "$BIN_PATH.tmp"
|
||||||
|
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
|
||||||
|
|
||||||
# 4. Setup configuration
|
# 4. Setup configuration
|
||||||
log "Preparing configuration directory $CONFIG_DIR..."
|
log "Preparing configuration directory $CONFIG_DIR..."
|
||||||
@@ -120,9 +124,8 @@ chmod 755 "$CONFIG_DIR"
|
|||||||
if [ -n "$B64_CONFIG" ]; then
|
if [ -n "$B64_CONFIG" ]; then
|
||||||
log "Decoding and writing configuration from base64..."
|
log "Decoding and writing configuration from base64..."
|
||||||
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
|
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
|
||||||
else
|
elif [ ! -f "$CONFIG_FILE" ]; then
|
||||||
log "Generating minimal configuration from arguments..."
|
log "Generating minimal configuration from arguments..."
|
||||||
# Create a minimal yaml with the provided URL and Token
|
|
||||||
cat <<EOF > "$CONFIG_FILE"
|
cat <<EOF > "$CONFIG_FILE"
|
||||||
server_url: "$URL"
|
server_url: "$URL"
|
||||||
auth_token: "$TOKEN"
|
auth_token: "$TOKEN"
|
||||||
@@ -131,11 +134,14 @@ public_key: "$PUBLIC_KEY"
|
|||||||
location: "unknown"
|
location: "unknown"
|
||||||
capabilities:
|
capabilities:
|
||||||
telemetry: true
|
telemetry: true
|
||||||
configure_ldap: false
|
configure_ldap: true
|
||||||
|
ldap_tunnel: true
|
||||||
reboot: false
|
reboot: false
|
||||||
service_control: []
|
service_control: []
|
||||||
arbitrary_bash: false
|
arbitrary_bash: false
|
||||||
EOF
|
EOF
|
||||||
|
else
|
||||||
|
log "Preserving existing configuration at $CONFIG_FILE"
|
||||||
fi
|
fi
|
||||||
chmod 600 "$CONFIG_FILE"
|
chmod 600 "$CONFIG_FILE"
|
||||||
|
|
||||||
@@ -149,7 +155,7 @@ if ! grep -qE '^public_key:[[:space:]]*"[^"]+"' "$CONFIG_FILE" 2>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled
|
# 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
|
install_sssd_deps
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -165,8 +171,6 @@ Type=simple
|
|||||||
ExecStart=$BIN_PATH
|
ExecStart=$BIN_PATH
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=theta-agent
|
SyslogIdentifier=theta-agent
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
+25
-14
@@ -37,32 +37,43 @@ func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// start binds the unix socket and accepts connections until stopCh closes.
|
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
|
||||||
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
||||||
// Remove a stale socket left over from a previous run, and make sure the
|
|
||||||
// parent directory exists (e.g. /run/theta on a fresh boot).
|
|
||||||
os.Remove(socketPath)
|
os.Remove(socketPath)
|
||||||
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
||||||
os.MkdirAll(dir, 0755)
|
os.MkdirAll(dir, 0755)
|
||||||
}
|
}
|
||||||
|
|
||||||
ln, err := net.Listen("unix", socketPath)
|
// 1. UNIX Domain Socket Listener
|
||||||
if err != nil {
|
lnUnix, err := net.Listen("unix", socketPath)
|
||||||
log.Printf("LDAP tunnel: cannot bind %s: %v", socketPath, err)
|
if err == nil {
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
// root:theta, 0660 — only root and the theta group can connect. A unix
|
|
||||||
// socket is preferred over 127.0.0.1:389 because filesystem permissions
|
|
||||||
// restrict which local processes can reach it.
|
|
||||||
os.Chmod(socketPath, 0660)
|
|
||||||
defer ln.Close()
|
|
||||||
log.Printf("LDAP tunnel: listening on %s", socketPath)
|
|
||||||
|
|
||||||
|
// 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() {
|
go func() {
|
||||||
<-stopCh
|
<-stopCh
|
||||||
ln.Close()
|
ln.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn, err := ln.Accept()
|
conn, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("Starting Theta Agent...")
|
log.Println("Starting Theta Agent...")
|
||||||
|
|
||||||
// Attempt to load configuration
|
// Attempt to load configuration
|
||||||
configPath := "/etc/theta42/agent.yml"
|
configPath := "/etc/theta42/agent.yml"
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
||||||
configPath = os.Args[1]
|
configPath = os.Args[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -144,10 +144,20 @@ func fetchSecrets(cfg *Config, paths []string) (map[string]map[string]interface{
|
|||||||
|
|
||||||
// refPath returns the secret path from a `path#key` reference.
|
// refPath returns the secret path from a `path#key` reference.
|
||||||
func refPath(ref string) string {
|
func refPath(ref string) string {
|
||||||
|
path := ref
|
||||||
if i := strings.Index(ref, "#"); i >= 0 {
|
if i := strings.Index(ref, "#"); i >= 0 {
|
||||||
return ref[:i]
|
path = ref[:i]
|
||||||
}
|
}
|
||||||
return ref
|
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.
|
// refKey returns the key from a `path#key` reference.
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ import (
|
|||||||
type DiscoveryData struct {
|
type DiscoveryData struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
IPs []string `json:"ip_addresses"`
|
IPs []string `json:"ip_addresses"`
|
||||||
|
PublicIP string `json:"public_ip"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Kernel string `json:"kernel"`
|
Kernel string `json:"kernel"`
|
||||||
CPUModel string `json:"cpu"`
|
CPUModel string `json:"cpu"`
|
||||||
@@ -36,6 +39,29 @@ type TelemetryData struct {
|
|||||||
Timestamp string `json:"timestamp"`
|
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.
|
// CollectDiscoveryData gathers static host information.
|
||||||
func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
||||||
h, _ := host.Info()
|
h, _ := host.Info()
|
||||||
@@ -59,9 +85,12 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
|||||||
cpuModel = cpuInfo[0].Model
|
cpuModel = cpuInfo[0].Model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pubIP := getPublicIP()
|
||||||
|
|
||||||
return DiscoveryData{
|
return DiscoveryData{
|
||||||
Hostname: h.Hostname,
|
Hostname: h.Hostname,
|
||||||
IPs: ips,
|
IPs: ips,
|
||||||
|
PublicIP: pubIP,
|
||||||
OS: fmt.Sprintf("%s %s", h.OS, h.Platform),
|
OS: fmt.Sprintf("%s %s", h.OS, h.Platform),
|
||||||
Kernel: h.KernelVersion,
|
Kernel: h.KernelVersion,
|
||||||
CPUModel: cpuModel,
|
CPUModel: cpuModel,
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
+56
-2
@@ -170,7 +170,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
|||||||
tunnel := newLdapTunnel(func(msg WSMessage) error {
|
tunnel := newLdapTunnel(func(msg WSMessage) error {
|
||||||
return sendTunnelMessage(sw, msg)
|
return sendTunnelMessage(sw, msg)
|
||||||
})
|
})
|
||||||
if cfg.Capabilities.LdapTunnel {
|
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
|
||||||
socketPath := cfg.LdapSocket
|
socketPath := cfg.LdapSocket
|
||||||
if socketPath == "" {
|
if socketPath == "" {
|
||||||
socketPath = "/run/theta/ldap.sock"
|
socketPath = "/run/theta/ldap.sock"
|
||||||
@@ -289,6 +289,9 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
linesCount := 100
|
linesCount := 100
|
||||||
if l, ok := msg.Payload["lines"].(float64); ok && l > 0 {
|
if l, ok := msg.Payload["lines"].(float64); ok && l > 0 {
|
||||||
linesCount = int(l)
|
linesCount = int(l)
|
||||||
|
if linesCount > 2000 {
|
||||||
|
linesCount = 2000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
|
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
|
||||||
@@ -402,18 +405,69 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Pushing updated SSSD configuration...")
|
log.Println("Pushing updated SSSD configuration...")
|
||||||
|
_ = os.MkdirAll("/etc/sssd", 0755)
|
||||||
if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
|
if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
|
||||||
log.Printf("Failed to write SSSD config: %v", err)
|
log.Printf("Failed to write SSSD config: %v", err)
|
||||||
sendResponse("error", "failed to write config")
|
sendResponse("error", "failed to write config")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure /etc/nsswitch.conf enables sss for passwd, group, shadow, sudoers
|
||||||
|
if nssBytes, err := os.ReadFile("/etc/nsswitch.conf"); err == nil {
|
||||||
|
nssContent := string(nssBytes)
|
||||||
|
updatedNss := false
|
||||||
|
lines := strings.Split(nssContent, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if (strings.HasPrefix(trimmed, "passwd:") || strings.HasPrefix(trimmed, "group:") || strings.HasPrefix(trimmed, "shadow:") || strings.HasPrefix(trimmed, "sudoers:")) && !strings.Contains(trimmed, "sss") {
|
||||||
|
lines[i] = line + " sss"
|
||||||
|
updatedNss = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updatedNss {
|
||||||
|
_ = os.WriteFile("/etc/nsswitch.conf", []byte(strings.Join(lines, "\n")), 0644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("Restarting SSSD service...")
|
log.Println("Restarting SSSD service...")
|
||||||
if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil {
|
if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil {
|
||||||
log.Printf("SSSD restart failed: %v", err)
|
log.Printf("SSSD restart failed (%v), attempting auto-install of missing packages...", err)
|
||||||
|
if _, err2 := exec.Execute("sh", "-c", "DEBIAN_FRONTEND=noninteractive apt-get update -y -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || dnf install -y sssd sssd-ldap sssd-tools || yum install -y sssd sssd-ldap sssd-tools"); err2 == nil {
|
||||||
|
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
||||||
|
if _, err3 := exec.Execute("systemctl", "restart", "sssd"); err3 == nil {
|
||||||
|
// Configure SSH AuthorizedKeysCommand
|
||||||
|
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
|
||||||
|
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
|
||||||
|
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
||||||
|
_ = os.WriteFile(sshConfPath, []byte(sshConfContent), 0644)
|
||||||
|
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
||||||
|
sendResponse("ok", "LDAP configuration updated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
sendResponse("error", "failed to restart sssd")
|
sendResponse("error", "failed to restart sssd")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure /etc/ssh/sshd_config.d/theta-sssd.conf is created for SSH AuthorizedKeysCommand
|
||||||
|
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
|
||||||
|
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
|
||||||
|
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
||||||
|
if err := os.WriteFile(sshConfPath, []byte(sshConfContent), 0644); err == nil {
|
||||||
|
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
||||||
|
}
|
||||||
|
if sshdBytes, err2 := os.ReadFile("/etc/ssh/sshd_config"); err2 == nil {
|
||||||
|
sshdStr := string(sshdBytes)
|
||||||
|
if !strings.Contains(sshdStr, "sss_ssh_authorizedkeys") {
|
||||||
|
sshdStr += "\nAuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
||||||
|
_ = os.WriteFile("/etc/ssh/sshd_config", []byte(sshdStr), 0644)
|
||||||
|
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure PAM mkhomedir is enabled
|
||||||
|
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
||||||
|
|
||||||
sendResponse("ok", "LDAP configuration updated")
|
sendResponse("ok", "LDAP configuration updated")
|
||||||
case "render_secrets":
|
case "render_secrets":
|
||||||
if !verifySignature(cfg, msg) {
|
if !verifySignature(cfg, msg) {
|
||||||
|
|||||||
Reference in New Issue
Block a user