feat: release v1.6.0 with get-secret CLI, Zero-Trust LDAP tunnel, auto-updates and service restarts

This commit is contained in:
2026-08-07 23:26:13 -04:00
parent 8f0158eb9f
commit 9d3c5d2ac9
13 changed files with 474 additions and 34 deletions
+11
View File
@@ -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
+1 -1
View File
@@ -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.
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
)
func handleCLI(args []string) bool {
if len(args) == 0 {
return false
}
arg := strings.ToLower(args[0])
switch arg {
case "get-secret", "secret-get":
runGetSecret(args[1:])
return true
case "get-secrets", "secret-list", "secrets":
runGetSecrets(args[1:])
return true
case "--update", "update":
runSelfUpdate(args[1:])
return true
case "--reinitialize", "reinitialize", "--reinit", "reinit":
runReinitialize(args[1:])
return true
case "--version", "version", "-v":
fmt.Println("Theta Agent v1.2.0")
return true
case "--help", "help", "-h":
printUsage()
return true
}
return false
}
func printUsage() {
fmt.Println("Theta Agent - Unified Endpoint Management CLI")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" theta-agent Run agent daemon in foreground")
fmt.Println(" theta-agent get-secret <key> Fetch single secret value from OpenBao")
fmt.Println(" theta-agent get-secrets [flags] Fetch all host/resource secrets (flags: --json, --env)")
fmt.Println(" theta-agent update Self-update binary from SSO Manager")
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register")
fmt.Println(" theta-agent version Show version info")
fmt.Println()
fmt.Println("Reinitialize Flags:")
fmt.Println(" --join-key <key> Supply new join key for re-enrollment")
fmt.Println()
}
func runSelfUpdate(args []string) {
configPath := "/etc/theta42/agent.yml"
cm, err := NewConfigManager(configPath)
if err != nil {
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
}
cfg := cm.Get()
serverURL := strings.TrimRight(cfg.ServerURL, "/")
if serverURL == "" {
log.Fatalf("[!] Update failed: server_url is empty in %s", configPath)
}
downloadURL := fmt.Sprintf("%s/resources/theta-agent/theta-agent-linux-amd64", serverURL)
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(downloadURL)
if err != nil || resp.StatusCode != 200 {
log.Fatalf("[!] Failed to download update binary from %s (HTTP %d): %v", downloadURL, resp.StatusCode, err)
}
defer resp.Body.Close()
binPath := "/usr/local/bin/theta-agent"
if selfPath, err := os.Executable(); err == nil && selfPath != "" {
binPath = selfPath
}
tmpPath := binPath + ".tmp"
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
log.Fatalf("[!] Cannot write binary to %s: %v", tmpPath, err)
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close()
log.Fatalf("[!] Error writing binary update: %v", err)
}
out.Close()
if err := os.Rename(tmpPath, binPath); err != nil {
log.Fatalf("[!] Cannot replace binary at %s: %v", binPath, err)
}
log.Printf("[+] Binary updated successfully at %s.", binPath)
exec := &SystemExecutor{}
restartAffectedServices(exec)
os.Exit(0)
}
func runReinitialize(args []string) {
configPath := "/etc/theta42/agent.yml"
joinKey := ""
for i := 0; i < len(args); i++ {
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
joinKey = args[i+1]
i++
}
}
raw, err := os.ReadFile(configPath)
if err != nil {
log.Fatalf("[!] Cannot read %s: %v", configPath, err)
}
content := string(raw)
// Clear auth_token
reToken := regexp.MustCompile(`(?m)^auth_token:.*$`)
content = reToken.ReplaceAllString(content, `auth_token: ""`)
if joinKey != "" {
reKey := regexp.MustCompile(`(?m)^join_key:.*$`)
if reKey.MatchString(content) {
content = reKey.ReplaceAllString(content, fmt.Sprintf(`join_key: "%s"`, joinKey))
} else {
content += fmt.Sprintf("\njoin_key: \"%s\"\n", joinKey)
}
}
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
log.Fatalf("[!] Failed to update %s: %v", configPath, err)
}
log.Printf("[+] Cleared token in %s and reset enrollment status.", configPath)
exec := &SystemExecutor{}
restartAffectedServices(exec)
os.Exit(0)
}
func restartAffectedServices(exec Executor) {
log.Printf("[+] Restarting theta-agent service...")
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
log.Printf("[+] Restarting sssd service...")
_, _ = exec.Execute("systemctl", "restart", "sssd")
}
if _, err := exec.Execute("systemctl", "is-active", "sshd"); err == nil {
log.Printf("[+] Reloading sshd service...")
_, _ = exec.Execute("systemctl", "reload", "sshd")
} else if _, err := exec.Execute("systemctl", "is-active", "ssh"); err == nil {
log.Printf("[+] Reloading ssh service...")
_, _ = exec.Execute("systemctl", "reload", "ssh")
}
}
func runGetSecret(args []string) {
if len(args) < 1 {
fmt.Fprintf(os.Stderr, "[!] Error: secret key name required (e.g. theta-agent get-secret DB_PASSWORD)\n")
os.Exit(1)
}
key := args[0]
secrets, err := fetchAgentSecrets()
if err != nil {
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
os.Exit(1)
}
val, exists := secrets[key]
if !exists {
fmt.Fprintf(os.Stderr, "[!] Error: secret '%s' not found for this host/resource\n", key)
os.Exit(1)
}
// Print raw secret value to stdout without trailing newline
fmt.Print(val)
os.Exit(0)
}
func runGetSecrets(args []string) {
jsonMode := false
envMode := false
for _, arg := range args {
if arg == "--json" {
jsonMode = true
} else if arg == "--env" {
envMode = true
}
}
secrets, err := fetchAgentSecrets()
if err != nil {
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
os.Exit(1)
}
if jsonMode {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(secrets); err != nil {
fmt.Fprintf(os.Stderr, "[!] JSON encode error: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
if envMode {
for k, v := range secrets {
escaped := strings.ReplaceAll(v, `"`, `\"`)
fmt.Printf("%s=\"%s\"\n", k, escaped)
}
os.Exit(0)
}
if len(secrets) == 0 {
fmt.Println("No secrets configured for this host/resource.")
os.Exit(0)
}
fmt.Printf("%-30s %s\n", "SECRET KEY", "VALUE STATUS")
fmt.Println(strings.Repeat("-", 60))
for k, v := range secrets {
status := fmt.Sprintf("Configured (%d chars)", len(v))
fmt.Printf("%-30s %s\n", k, status)
}
os.Exit(0)
}
func fetchAgentSecrets() (map[string]string, error) {
configPath := "/etc/theta42/agent.yml"
cm, err := NewConfigManager(configPath)
if err != nil {
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
}
cfg := cm.Get()
serverURL := strings.TrimRight(cfg.ServerURL, "/")
if serverURL == "" {
return nil, fmt.Errorf("server_url is empty in %s", configPath)
}
token := cfg.AuthToken
if token == "" {
return nil, fmt.Errorf("agent is not enrolled (auth_token empty in %s)", configPath)
}
reqBody, _ := json.Marshal(map[string]interface{}{})
url := fmt.Sprintf("%s/api/v1/agent/secrets", serverURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bodyBytes))
}
var resData struct {
Status string `json:"status"`
Secrets map[string]map[string]interface{} `json:"secrets"`
}
if err := json.NewDecoder(resp.Body).Decode(&resData); err != nil {
return nil, fmt.Errorf("failed to decode JSON response: %w", err)
}
mergedSecrets := make(map[string]string)
for _, pathMap := range resData.Secrets {
for k, v := range pathMap {
if strV, ok := v.(string); ok {
mergedSecrets[k] = strV
} else if v != nil {
mergedSecrets[k] = fmt.Sprintf("%v", v)
}
}
}
return mergedSecrets, nil
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"testing"
)
func TestHandleCLIHelpAndVersion(t *testing.T) {
if !handleCLI([]string{"version"}) {
t.Errorf("expected handleCLI('version') to return true")
}
if !handleCLI([]string{"--help"}) {
t.Errorf("expected handleCLI('--help') to return true")
}
if handleCLI([]string{"unknown-command"}) {
t.Errorf("expected handleCLI('unknown-command') to return false")
}
}
+4
View File
@@ -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
View File
@@ -19,7 +19,7 @@ log() { echo -e "${GREEN}[+]${NC} $1"; }
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
# 1. Root check
if [ "$EUID" -ne 0 ]; then
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
error "This script must be run as root."
fi
@@ -29,9 +29,10 @@ install_sssd_deps() {
log "Installing SSSD and PAM integration dependencies..."
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo pam-auth-update || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
if command -v pam-auth-update >/dev/null 2>&1; then
pam-auth-update --enable mkhomedir || true
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
fi
elif command -v dnf >/dev/null 2>&1; then
dnf install -y sssd sssd-ldap sssd-tools || true
@@ -45,6 +46,8 @@ install_sssd_deps() {
else
log "SSSD is already installed."
fi
mkdir -p /etc/sssd
chmod 755 /etc/sssd
}
# 2. Argument Parsing
@@ -55,7 +58,7 @@ PUBLIC_KEY=""
B64_CONFIG=""
INSTALL_SSSD=0
while [[ $# -gt 0 ]]; do
while [ $# -gt 0 ]; do
case $1 in
--url)
URL="$2"
@@ -91,8 +94,8 @@ while [[ $# -gt 0 ]]; do
esac
done
# Validation
if [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
# Validation: require credentials ONLY if config file does not already exist
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
echo "Usage examples:"
echo " sh install.sh \"BASE64_CONFIG\""
@@ -109,8 +112,9 @@ log "Starting Theta Agent installation..."
# 3. Install binary
log "Downloading binary from $BINARY_URL..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
chmod +x "$BIN_PATH"
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary."
chmod +x "$BIN_PATH.tmp"
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
# 4. Setup configuration
log "Preparing configuration directory $CONFIG_DIR..."
@@ -120,9 +124,8 @@ chmod 755 "$CONFIG_DIR"
if [ -n "$B64_CONFIG" ]; then
log "Decoding and writing configuration from base64..."
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
else
elif [ ! -f "$CONFIG_FILE" ]; then
log "Generating minimal configuration from arguments..."
# Create a minimal yaml with the provided URL and Token
cat <<EOF > "$CONFIG_FILE"
server_url: "$URL"
auth_token: "$TOKEN"
@@ -131,11 +134,14 @@ public_key: "$PUBLIC_KEY"
location: "unknown"
capabilities:
telemetry: true
configure_ldap: false
configure_ldap: true
ldap_tunnel: true
reboot: false
service_control: []
arbitrary_bash: false
EOF
else
log "Preserving existing configuration at $CONFIG_FILE"
fi
chmod 600 "$CONFIG_FILE"
@@ -149,7 +155,7 @@ if ! grep -qE '^public_key:[[:space:]]*"[^"]+"' "$CONFIG_FILE" 2>/dev/null; then
fi
# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled
if [ "$INSTALL_SSSD" -eq 1 ] || grep -q -i "configure_ldap:\s*true" "$CONFIG_FILE" 2>/dev/null; then
if [ "$INSTALL_SSSD" -eq 1 ] || grep -qE -i 'configure_ldap:[[:space:]]*true' "$CONFIG_FILE" 2>/dev/null; then
install_sssd_deps
fi
@@ -165,8 +171,6 @@ Type=simple
ExecStart=$BIN_PATH
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent
[Install]
+25 -14
View File
@@ -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 {
+6 -1
View File
@@ -5,15 +5,20 @@ import (
"log"
"os"
"os/signal"
"strings"
"syscall"
)
func main() {
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
return
}
log.Println("Starting Theta Agent...")
// Attempt to load configuration
configPath := "/etc/theta42/agent.yml"
if len(os.Args) > 1 {
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
configPath = os.Args[1]
}
+12 -2
View File
@@ -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.
+29
View File
@@ -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
View File
Binary file not shown.
Binary file not shown.
+56 -2
View File
@@ -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) {