feat(windows): platform ops, service wrapper, helper, and air-gap paths
First Windows parity milestone (DESIGN-WINDOWS.md §13 build order item 1).
- Add a PlatformOps abstraction so command dispatch is OS-neutral:
- linuxPlatformOps keeps today's systemctl/journalctl/bash behavior (deliberately
untagged so shared dispatch tests run on Windows CI)
- windowsPlatformOps maps reboot/shutdown to shutdown.exe, service control to
sc.exe (stop+start for restart), fetch_logs to Get-WinEvent, arbitrary_bash to
powershell -EncodedCommand (byte-exact under arbitrary quoting), and declines
configure_ldap (Windows logon goes through OpenCredential)
- Run theta-agent as a Windows service (x/sys/windows/svc): SYSTEM auto-start,
SCM stop/shutdown handling; CLI install-service/remove-service via svc/mgr
- Add theta-agent-helper (session-0 companion): lock/display_off/logout via
user32/wtsapi32, and staged self-update (wait for service stop, swap the
locked exe, sc start)
- Self-update becomes platform-aware: Linux renames over the running binary;
Windows stages .new and hands the swap to the helper (running exe is locked)
- Platform paths: agent.yml and tray.sock under %ProgramData%\Theta42 (the
service runs as SYSTEM while the tray runs as the user, so the per-user temp
dir no longer works for tray IPC); LDAP byte-pump falls back to TCP loopback
- config: service_name, desktop_helper, public_ip_detect (air-gap: skips
external public-IP lookups in telemetry + home monitor), wireguard block
- cli: platform-aware config path + self-update artifact name + service restart
- tests: dispatch tests pin linuxPlatformOps; 0600 mode assertions gated to
POSIX so the suite is green on Windows
Rebuilds all tracked dist binaries (v2.1.0).
This commit is contained in:
+28
-117
@@ -12,7 +12,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -174,7 +173,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
||||
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
|
||||
socketPath := cfg.LdapSocket
|
||||
if socketPath == "" {
|
||||
socketPath = "/run/theta/ldap.sock"
|
||||
socketPath = defaultLdapSocketPath()
|
||||
}
|
||||
go tunnel.start(socketPath, stopCh)
|
||||
}
|
||||
@@ -297,7 +296,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
|
||||
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
|
||||
out, err := exec.Execute("journalctl", "-u", serviceName, "-n", fmt.Sprintf("%d", linesCount), "--no-pager")
|
||||
out, err := defaultPlatformOps.FetchLogs(serviceName, linesCount)
|
||||
if err != nil {
|
||||
log.Printf("Log fetch failed: %v", err)
|
||||
sendResponse("error", "failed to fetch logs")
|
||||
@@ -329,13 +328,13 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
|
||||
log.Printf("Updating binary from %s...", urlStr)
|
||||
if err := downloadAndUpdateBinary(urlStr, checksum); err != nil {
|
||||
if err := defaultPlatformOps.ApplyUpdate(urlStr, checksum); err != nil {
|
||||
log.Printf("Update failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("update failed: %v", err))
|
||||
return
|
||||
}
|
||||
sendResponse("ok", "update applied successfully; restarting agent...")
|
||||
os.Exit(0)
|
||||
defaultPlatformOps.SelfRestart()
|
||||
case "config":
|
||||
// A config frame carrying credentials means the server accepted our
|
||||
// join key and enrolled this host.
|
||||
@@ -370,7 +369,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
log.Printf("Executing reboot...")
|
||||
if _, err := exec.Execute("reboot"); err != nil {
|
||||
if _, err := defaultPlatformOps.Reboot(); err != nil {
|
||||
log.Printf("Reboot failed: %v", err)
|
||||
sendResponse("error", "reboot failed")
|
||||
return
|
||||
@@ -388,8 +387,8 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
log.Printf("Executing shutdown...")
|
||||
sendResponse("ok", "system shutting down")
|
||||
if _, err := exec.Execute("shutdown", "-h", "now"); err != nil {
|
||||
exec.Execute("poweroff")
|
||||
if _, err := defaultPlatformOps.Shutdown(); err != nil {
|
||||
log.Printf("Shutdown failed: %v", err)
|
||||
}
|
||||
return
|
||||
case "desktop_control", "lock_session", "logout_user", "display_off", "sleep_host":
|
||||
@@ -399,36 +398,16 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
targetUser, _ := msg.Payload["user"].(string)
|
||||
log.Printf("Executing desktop control action '%s' for user '%s'...", subAction, targetUser)
|
||||
var out []byte
|
||||
var err error
|
||||
|
||||
switch subAction {
|
||||
case "lock_session", "lock":
|
||||
out, err = exec.Execute("loginctl", "lock-sessions")
|
||||
if err != nil {
|
||||
out, err = exec.Execute("sh", "-c", "DISPLAY=:0 xdg-screensaver lock || DISPLAY=:0 xset dpms force off")
|
||||
}
|
||||
case "logout_user", "logout":
|
||||
if targetUser != "" {
|
||||
out, err = exec.Execute("loginctl", "terminate-user", targetUser)
|
||||
if err != nil {
|
||||
out, err = exec.Execute("pkill", "-KILL", "-u", targetUser)
|
||||
}
|
||||
} else {
|
||||
out, err = exec.Execute("loginctl", "terminate-session")
|
||||
if err != nil {
|
||||
out, err = exec.Execute("pkill", "-9", "-f", "session-child")
|
||||
}
|
||||
}
|
||||
case "display_off":
|
||||
out, err = exec.Execute("sh", "-c", "DISPLAY=:0 xset dpms force off || loginctl lock-sessions")
|
||||
case "sleep_host", "sleep":
|
||||
out, err = exec.Execute("systemctl", "suspend")
|
||||
case "lock_session", "lock", "logout_user", "logout", "display_off", "sleep_host", "sleep":
|
||||
default:
|
||||
sendResponse("error", fmt.Sprintf("unknown desktop action '%s'", subAction))
|
||||
return
|
||||
}
|
||||
|
||||
out, err := defaultPlatformOps.DesktopControl(subAction, targetUser)
|
||||
|
||||
errMsg := ""
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
@@ -457,7 +436,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
log.Printf("Executing systemctl %s %s...", action, serviceName)
|
||||
out, err := exec.Execute("systemctl", action, serviceName)
|
||||
out, err := defaultPlatformOps.ServiceControl(serviceName, action)
|
||||
errMsg := ""
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
@@ -480,7 +459,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
log.Printf("Restarting service %s...", serviceName)
|
||||
if _, err := exec.Execute("systemctl", "restart", serviceName); err != nil {
|
||||
if _, err := defaultPlatformOps.ServiceControl(serviceName, "restart"); err != nil {
|
||||
log.Printf("Service restart failed: %v", err)
|
||||
sendResponse("error", "restart failed")
|
||||
return
|
||||
@@ -504,70 +483,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
if err := defaultPlatformOps.ConfigureLDAP(configData); err != nil {
|
||||
log.Printf("LDAP configuration failed: %v", err)
|
||||
sendResponse("error", err.Error())
|
||||
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), 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) {
|
||||
@@ -628,7 +549,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
|
||||
log.Printf("Executing remote script: %s", script)
|
||||
out, err := exec.Execute("bash", "-c", script)
|
||||
out, err := defaultPlatformOps.RunScript(script)
|
||||
if err != nil {
|
||||
log.Printf("Script execution failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("execution failed: %v", err))
|
||||
@@ -655,20 +576,24 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
}
|
||||
|
||||
func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
|
||||
// downloadBinary fetches the new binary, verifies its SHA-256, and returns the
|
||||
// path of a temp file holding it. The platform's ApplyUpdate decides how to
|
||||
// install it (Linux renames over the running exe; Windows stages a `.new` and
|
||||
// swaps via the helper once the service stops).
|
||||
func downloadBinary(downloadURL string, expectedSHA256 string) (string, error) {
|
||||
resp, err := http.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http fetch failed: %w", err)
|
||||
return "", fmt.Errorf("http fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected http status: %s", resp.Status)
|
||||
return "", fmt.Errorf("unexpected http status: %s", resp.Status)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "theta-agent-update-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
@@ -678,32 +603,18 @@ func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
|
||||
|
||||
if _, err := io.Copy(writer, resp.Body); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to save binary: %w", err)
|
||||
return "", fmt.Errorf("failed to save binary: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
actualSHA256 := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
if !strings.EqualFold(actualSHA256, strings.TrimSpace(expectedSHA256)) {
|
||||
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, actualSHA256)
|
||||
return "", fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, actualSHA256)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to set executable permissions: %w", err)
|
||||
return "", fmt.Errorf("failed to set executable permissions: %w", err)
|
||||
}
|
||||
|
||||
selfPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve current binary path: %w", err)
|
||||
}
|
||||
|
||||
resolvedPath, err := filepath.EvalSymlinks(selfPath)
|
||||
if err == nil {
|
||||
selfPath = resolvedPath
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, selfPath); err != nil {
|
||||
return fmt.Errorf("failed to replace binary: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user