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:
2026-08-09 17:10:07 -07:00
parent 7a6eb84d36
commit 4a619f7adc
38 changed files with 1115 additions and 171 deletions
+28 -4
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"regexp"
"runtime"
"strings"
"time"
)
@@ -31,6 +32,12 @@ func handleCLI(args []string) bool {
case "--reinitialize", "reinitialize", "--reinit", "reinit":
runReinitialize(args[1:])
return true
case "install-service":
handleServiceCommand(args[1:])
return true
case "remove-service", "uninstall-service":
handleServiceCommand(append([]string{"remove"}, args[1:]...))
return true
case "--version", "version", "-v":
fmt.Println("Theta Agent " + AgentVersion)
return true
@@ -50,6 +57,8 @@ func printUsage() {
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 install-service (Windows) register the agent as a service")
fmt.Println(" theta-agent remove-service (Windows) unregister the agent service")
fmt.Println(" theta-agent version Show version info")
fmt.Println()
fmt.Println("Reinitialize Flags:")
@@ -58,7 +67,7 @@ func printUsage() {
}
func runSelfUpdate(args []string) {
configPath := "/etc/theta42/agent.yml"
configPath := defaultConfigPath()
cm, err := NewConfigManager(configPath)
if err != nil {
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
@@ -69,7 +78,16 @@ func runSelfUpdate(args []string) {
log.Fatalf("[!] Update failed: server_url is empty in %s", configPath)
}
downloadURL := fmt.Sprintf("%s/resources/theta-agent/theta-agent-linux-amd64", serverURL)
arch := "amd64"
if runtime.GOARCH == "arm64" {
arch = "arm64"
}
ext := ""
if runtime.GOOS == "windows" {
ext = ".exe"
}
artifact := fmt.Sprintf("theta-agent-%s-%s%s", runtime.GOOS, arch, ext)
downloadURL := fmt.Sprintf("%s/resources/theta-agent/%s", serverURL, artifact)
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
client := &http.Client{Timeout: 30 * time.Second}
@@ -106,7 +124,7 @@ func runSelfUpdate(args []string) {
}
func runReinitialize(args []string) {
configPath := "/etc/theta42/agent.yml"
configPath := defaultConfigPath()
joinKey := ""
for i := 0; i < len(args); i++ {
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
@@ -146,6 +164,12 @@ func runReinitialize(args []string) {
func restartAffectedServices(exec Executor) {
log.Printf("[+] Restarting theta-agent service...")
if runtime.GOOS == "windows" {
// sc.exe has no one-shot restart.
_, _ = exec.Execute("sc", "stop", "theta-agent")
_, _ = exec.Execute("sc", "start", "theta-agent")
return
}
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
@@ -235,7 +259,7 @@ func runGetSecrets(args []string) {
}
func fetchAgentSecrets() (map[string]string, error) {
configPath := "/etc/theta42/agent.yml"
configPath := defaultConfigPath()
cm, err := NewConfigManager(configPath)
if err != nil {
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)