diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff6173..5520809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `).** 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 ]` 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 diff --git a/README.md b/README.md index a2a9247..4c53ae5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..cd930d0 --- /dev/null +++ b/cli.go @@ -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 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 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 +} diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..b2eb2a6 --- /dev/null +++ b/cli_test.go @@ -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") + } +} diff --git a/config.go b/config.go index 69194c8..7fd8c60 100644 --- a/config.go +++ b/config.go @@ -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 } diff --git a/install.sh b/install.sh index 8c7625f..5f3e57c 100644 --- a/install.sh +++ b/install.sh @@ -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 < "$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] diff --git a/ldap_tunnel.go b/ldap_tunnel.go index fe74e2e..b82d823 100644 --- a/ldap_tunnel.go +++ b/ldap_tunnel.go @@ -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 { diff --git a/main.go b/main.go index 1f623f1..236ef3d 100644 --- a/main.go +++ b/main.go @@ -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] } diff --git a/secrets.go b/secrets.go index aa0d804..ccd2ce9 100644 --- a/secrets.go +++ b/secrets.go @@ -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. diff --git a/telemetry.go b/telemetry.go index a4ffc37..72d2911 100644 --- a/telemetry.go +++ b/telemetry.go @@ -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, diff --git a/theta-agent b/theta-agent index 966958b..92c74b7 100755 Binary files a/theta-agent and b/theta-agent differ diff --git a/theta-agent-linux-amd64 b/theta-agent-linux-amd64 index 3b05a79..92c74b7 100755 Binary files a/theta-agent-linux-amd64 and b/theta-agent-linux-amd64 differ diff --git a/websocket.go b/websocket.go index 5ccf700..811c769 100644 --- a/websocket.go +++ b/websocket.go @@ -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) {