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/),
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
- **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.
|
||||
- **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)
|
||||
}
|
||||
|
||||
if cfg.Capabilities.ConfigureLDAP {
|
||||
cfg.Capabilities.LdapTunnel = true
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
|
||||
+18
-14
@@ -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]
|
||||
|
||||
+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{}) {
|
||||
// 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)
|
||||
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
||||
os.MkdirAll(dir, 0755)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
log.Printf("LDAP tunnel: cannot bind %s: %v", socketPath, err)
|
||||
return
|
||||
// 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)
|
||||
}
|
||||
// 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() {
|
||||
<-stopCh
|
||||
ln.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
+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.
|
||||
func refPath(ref string) string {
|
||||
path := ref
|
||||
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.
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
type DiscoveryData struct {
|
||||
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"`
|
||||
@@ -36,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()
|
||||
@@ -59,9 +85,12 @@ 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,
|
||||
|
||||
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 {
|
||||
return sendTunnelMessage(sw, msg)
|
||||
})
|
||||
if cfg.Capabilities.LdapTunnel {
|
||||
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
|
||||
socketPath := cfg.LdapSocket
|
||||
if socketPath == "" {
|
||||
socketPath = "/run/theta/ldap.sock"
|
||||
@@ -289,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)
|
||||
@@ -402,18 +405,69 @@ 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) {
|
||||
|
||||
Reference in New Issue
Block a user