diff --git a/.gitignore b/.gitignore index 3727a71..a03c039 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ theta-agent-* dist/ *.exe agent.yml +!cmd/theta-agent-helper/ +!cmd/theta-agent-helper/main.go diff --git a/build_all.sh b/build_all.sh index a75598a..e549580 100755 --- a/build_all.sh +++ b/build_all.sh @@ -25,6 +25,12 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_D echo " -> windows/arm64..." CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-arm64.exe" +echo " -> windows/amd64 helper..." +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-helper-windows-amd64.exe" ./cmd/theta-agent-helper/ + +echo " -> windows/arm64 helper..." +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-helper-windows-arm64.exe" ./cmd/theta-agent-helper/ + echo " -> darwin/amd64 (macOS Intel)..." CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-amd64" @@ -38,6 +44,12 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR echo " -> linux/arm64 tray..." CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-arm64" ./cmd/theta-agent-tray/ +echo " -> windows/amd64 tray..." +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-windows-amd64.exe" ./cmd/theta-agent-tray/ + +echo " -> windows/arm64 tray..." +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-windows-arm64.exe" ./cmd/theta-agent-tray/ + echo "" echo "Build complete! Artifacts in $DIST_DIR:" ls -lh "$DIST_DIR" diff --git a/cli.go b/cli.go index 3209923..e114254 100644 --- a/cli.go +++ b/cli.go @@ -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) diff --git a/cmd/theta-agent-helper/main.go b/cmd/theta-agent-helper/main.go new file mode 100644 index 0000000..5a1bff4 --- /dev/null +++ b/cmd/theta-agent-helper/main.go @@ -0,0 +1,227 @@ +//go:build windows + +// theta-agent-helper — session-aware companion for the theta-agent Windows +// service (DESIGN-WINDOWS.md §4 and §8). +// +// The agent service runs in session 0 with no interactive desktop. Operations +// that need an interactive session (lock, display off, logout) or that must +// outlive the service process (staged self-update: swap the locked exe and +// restart the service) run here instead. +// +// theta-agent-helper lock +// theta-agent-helper display_off +// theta-agent-helper logout [user] +// theta-agent-helper update +// +// Sleep is handled in-process by the service (SetSuspendState works from +// session 0); it is also exposed here for manual use. + +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" + "unsafe" +) + +const ( + wmSyscommand = 0x0112 + scMonitorpower = 0xF170 + sleepHwnd = ^uintptr(0) // HWND_BROADCAST + monitorPowerOff = 2 + + wtsCurrentServerHandle = 0 + wtsUserName = 5 +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + wtsapi32 = syscall.NewLazyDLL("wtsapi32.dll") + powrprof = syscall.NewLazyDLL("powrprof.dll") + + procLockWorkStation = user32.NewProc("LockWorkStation") + procSendMessageW = user32.NewProc("SendMessageW") + procWTSLogoffSession = wtsapi32.NewProc("WTSLogoffSession") + procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW") + procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW") + procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory") + procSetSuspendState = powrprof.NewProc("SetSuspendState") +) + +type wtsSessionInfo struct { + SessionID uint32 + WinStation *uint16 + ConnectState uint32 +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: theta-agent-helper [args...]") + os.Exit(1) + } + + switch os.Args[1] { + case "lock": + lockSession() + case "display_off": + displayOff() + case "logout": + logoutSession(argOrEmpty(2)) + case "sleep": + sleepHost() + case "update": + if len(os.Args) < 5 { + fmt.Fprintln(os.Stderr, "usage: theta-agent-helper update ") + os.Exit(1) + } + doUpdate(os.Args[2], os.Args[3], os.Args[4]) + default: + fmt.Fprintf(os.Stderr, "unknown action %q\n", os.Args[1]) + os.Exit(1) + } +} + +func argOrEmpty(i int) string { + if len(os.Args) > i { + return os.Args[i] + } + return "" +} + +func lockSession() { + r, _, err := procLockWorkStation.Call() + if r == 0 { + fmt.Fprintf(os.Stderr, "LockWorkStation failed: %v\n", err) + os.Exit(1) + } +} + +func displayOff() { + r, _, err := procSendMessageW.Call(sleepHwnd, wmSyscommand, scMonitorpower, monitorPowerOff) + if r == 0 { + fmt.Fprintf(os.Stderr, "SendMessage(SC_MONITORPOWER) failed: %v\n", err) + os.Exit(1) + } +} + +func sleepHost() { + r, _, err := procSetSuspendState.Call(0, 0, 0) + if r == 0 { + fmt.Fprintf(os.Stderr, "SetSuspendState failed: %v\n", err) + os.Exit(1) + } +} + +// logoutSession logs off the active console session, or the session of the +// given user (matched by WTS session enumeration). +func logoutSession(user string) { + sessionID := activeConsoleSessionID() + if user != "" { + id, err := sessionForUser(user) + if err != nil { + fmt.Fprintf(os.Stderr, "logout: %v\n", err) + os.Exit(1) + } + sessionID = id + } + if sessionID == 0 { + fmt.Fprintln(os.Stderr, "logout: no active session to log off") + os.Exit(1) + } + + r, _, err := procWTSLogoffSession.Call(wtsCurrentServerHandle, uintptr(sessionID), 0) + if r == 0 { + fmt.Fprintf(os.Stderr, "WTSLogoffSession(%d) failed: %v\n", sessionID, err) + os.Exit(1) + } + fmt.Printf("logged off session %d\n", sessionID) +} + +func activeConsoleSessionID() uint32 { + id, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("WTSGetActiveConsoleSessionId").Call() + return uint32(id) +} + +func sessionForUser(user string) (uint32, error) { + var pInfo *wtsSessionInfo + var count uint32 + + r, _, err := procWTSEnumerateSessionsW.Call(wtsCurrentServerHandle, 0, 1, uintptr(unsafe.Pointer(&pInfo)), uintptr(unsafe.Pointer(&count))) + if r == 0 { + return 0, fmt.Errorf("WTSEnumerateSessionsW: %v", err) + } + defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pInfo))) + + want := strings.ToLower(user) + for i := uint32(0); i < count; i++ { + info := (*wtsSessionInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(pInfo)) + uintptr(i)*unsafe.Sizeof(*pInfo))) + name := wtsSessionUsername(info.SessionID) + if name != "" && strings.ToLower(name) == want { + return info.SessionID, nil + } + } + return 0, fmt.Errorf("no active session for user %q", user) +} + +func wtsSessionUsername(sessionID uint32) string { + var pBuf *uint16 + var bytes uint32 + r, _, _ := procWTSQuerySessionInformationW.Call(wtsCurrentServerHandle, uintptr(sessionID), wtsUserName, uintptr(unsafe.Pointer(&pBuf)), uintptr(unsafe.Pointer(&bytes))) + if r == 0 || pBuf == nil { + return "" + } + defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pBuf))) + return syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(pBuf))[:bytes/2]) +} + +// doUpdate swaps the staged new binary over the running one and restarts the +// service. The service that spawned us stops itself (stopAgent) once we are +// launched; we wait for it to actually stop (the exe is locked while running), +// swap, and start it again. +func doUpdate(newExe, currentExe, serviceName string) { + if serviceName == "" { + serviceName = "theta-agent" + } + + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + if !serviceRunning(serviceName) { + break + } + time.Sleep(500 * time.Millisecond) + } + + // The file can stay locked a moment after the SCM reports STOPPED, so retry. + swapped := false + for i := 0; i < 40; i++ { + if err := os.Rename(newExe, currentExe); err == nil { + swapped = true + break + } + time.Sleep(250 * time.Millisecond) + } + if !swapped { + fmt.Fprintf(os.Stderr, "update: could not replace %s (is the service still running?)\n", currentExe) + os.Exit(1) + } + + cmd := exec.Command("sc", "start", serviceName) + if out, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "update: sc start %s: %v: %s\n", serviceName, err, out) + os.Exit(1) + } + fmt.Printf("update: swapped %s and restarted %s\n", currentExe, serviceName) +} + +func serviceRunning(name string) bool { + out, err := exec.Command("sc", "query", name).CombinedOutput() + if err != nil { + return false + } + text := strings.ToUpper(string(out)) + return strings.Contains(text, "RUNNING") || strings.Contains(text, "START_PENDING") +} diff --git a/cmd/theta-agent-tray/main.go b/cmd/theta-agent-tray/main.go index 7cb6661..f5d8c8f 100644 --- a/cmd/theta-agent-tray/main.go +++ b/cmd/theta-agent-tray/main.go @@ -36,12 +36,17 @@ import ( // ── IPC types (duplicated from the main agent package; tray is its own binary) ── // traySocketPaths returns the daemon IPC socket paths for this platform, in -// the same order the daemon tries to bind them. Windows has no /run or /tmp, -// so it uses a Unix socket under the per-user temp dir; Linux keeps the -// original pair. +// the same order the daemon tries to bind them. Windows has no /run or /tmp and +// the daemon runs as a SYSTEM service, so both sides use the shared +// %ProgramData%\Theta42\tray.sock (installer creates the dir with a +// Users-writable ACL); Linux keeps the original pair. func traySocketPaths() []string { if runtime.GOOS == "windows" { - return []string{filepath.Join(os.TempDir(), "theta-tray.sock")} + pd := os.Getenv("ProgramData") + if pd == "" { + pd = `C:\ProgramData` + } + return []string{filepath.Join(pd, "Theta42", "tray.sock")} } return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"} } diff --git a/config.go b/config.go index 7fd8c60..97115b1 100644 --- a/config.go +++ b/config.go @@ -29,6 +29,14 @@ type SecretTarget struct { Reload string `yaml:"reload"` } +// WireGuardConfig holds the mesh client settings (DESIGN-WINDOWS.md §5). +type WireGuardConfig struct { + // TunnelName is the Windows WireGuard tunnel/service name. + TunnelName string `yaml:"tunnel_name"` + // Conf is where the pushed peer config is persisted on disk. + Conf string `yaml:"conf"` +} + type Config struct { ServerURL string `yaml:"server_url"` AuthToken string `yaml:"auth_token"` @@ -36,12 +44,37 @@ type Config struct { // the server exchanges it for a per-agent AuthToken (written back to this // file), so it is a bootstrap value, not a long-term credential. Used only // when AuthToken is empty. - JoinKey string `yaml:"join_key"` - Location string `yaml:"location"` - PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands - LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4) - Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5) - Capabilities Capabilities `yaml:"capabilities"` + JoinKey string `yaml:"join_key"` + Location string `yaml:"location"` + PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands + LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4) + Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5) + Capabilities Capabilities `yaml:"capabilities"` + + // Windows-specific (DESIGN-WINDOWS.md §11). + ServiceName string `yaml:"service_name"` // Windows service name + DesktopHelper string `yaml:"desktop_helper"` // theta-agent-helper.exe path + PublicIPDetect *bool `yaml:"public_ip_detect"` // false disables external lookups (air-gap) + WireGuard WireGuardConfig `yaml:"wireguard"` +} + +// DetectPublicIP reports whether the agent may perform external public-IP +// lookups. Defaults to true; an air-gapped host sets public_ip_detect: false so +// the agent never tries to reach ipify/icanhazip/etc. +func (c *Config) DetectPublicIP() bool { + if c.PublicIPDetect == nil { + return true + } + return *c.PublicIPDetect +} + +// ServiceNameOrDefault returns the Windows service name, defaulting to +// theta-agent when unset. +func (c *Config) ServiceNameOrDefault() string { + if c.ServiceName != "" { + return c.ServiceName + } + return "theta-agent" } // Credential returns the value to present when connecting: our own token once diff --git a/config_test.go b/config_test.go index f6dd76e..0837b7f 100644 --- a/config_test.go +++ b/config_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -155,10 +156,13 @@ capabilities: t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential()) } - // file must stay 0600 -- it now holds a credential - fi, _ := os.Stat(path) - if fi.Mode().Perm() != 0600 { - t.Errorf("expected mode 0600, got %o", fi.Mode().Perm()) + // file must stay 0600 -- it now holds a credential. POSIX-only: Windows has + // no mode bits (0666 is reported regardless) and relies on ACLs instead. + if runtime.GOOS != "windows" { + fi, _ := os.Stat(path) + if fi.Mode().Perm() != 0600 { + t.Errorf("expected mode 0600, got %o", fi.Mode().Perm()) + } } } diff --git a/dist/theta-agent-darwin-amd64 b/dist/theta-agent-darwin-amd64 index 16c6c62..d5dbdbb 100755 Binary files a/dist/theta-agent-darwin-amd64 and b/dist/theta-agent-darwin-amd64 differ diff --git a/dist/theta-agent-darwin-arm64 b/dist/theta-agent-darwin-arm64 index ca11ced..d5dbdbb 100755 Binary files a/dist/theta-agent-darwin-arm64 and b/dist/theta-agent-darwin-arm64 differ diff --git a/dist/theta-agent-linux-amd64 b/dist/theta-agent-linux-amd64 index ec46c76..d5dbdbb 100755 Binary files a/dist/theta-agent-linux-amd64 and b/dist/theta-agent-linux-amd64 differ diff --git a/dist/theta-agent-linux-arm64 b/dist/theta-agent-linux-arm64 index 91ae337..d5dbdbb 100755 Binary files a/dist/theta-agent-linux-arm64 and b/dist/theta-agent-linux-arm64 differ diff --git a/dist/theta-agent-linux-armv7 b/dist/theta-agent-linux-armv7 index b72de73..d5dbdbb 100755 Binary files a/dist/theta-agent-linux-armv7 and b/dist/theta-agent-linux-armv7 differ diff --git a/dist/theta-agent-tray-linux-amd64 b/dist/theta-agent-tray-linux-amd64 index 14e8cb0..28adce0 100755 Binary files a/dist/theta-agent-tray-linux-amd64 and b/dist/theta-agent-tray-linux-amd64 differ diff --git a/dist/theta-agent-tray-linux-arm64 b/dist/theta-agent-tray-linux-arm64 index 4d9eeec..28adce0 100755 Binary files a/dist/theta-agent-tray-linux-arm64 and b/dist/theta-agent-tray-linux-arm64 differ diff --git a/dist/theta-agent-tray-windows-amd64.exe b/dist/theta-agent-tray-windows-amd64.exe index b87a843..28adce0 100755 Binary files a/dist/theta-agent-tray-windows-amd64.exe and b/dist/theta-agent-tray-windows-amd64.exe differ diff --git a/dist/theta-agent-windows-amd64.exe b/dist/theta-agent-windows-amd64.exe index bf17c7e..d5dbdbb 100755 Binary files a/dist/theta-agent-windows-amd64.exe and b/dist/theta-agent-windows-amd64.exe differ diff --git a/dist/theta-agent-windows-arm64.exe b/dist/theta-agent-windows-arm64.exe index 3993c1a..d5dbdbb 100755 Binary files a/dist/theta-agent-windows-arm64.exe and b/dist/theta-agent-windows-arm64.exe differ diff --git a/go.mod b/go.mod index f714a8c..5c3e09f 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,14 @@ module github.com/theta42/theta-agent go 1.22.2 require ( + fyne.io/systray v1.12.2 github.com/gorilla/websocket v1.5.3 github.com/shirou/gopsutil/v3 v3.24.5 + golang.org/x/sys v0.20.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - fyne.io/systray v1.12.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -18,5 +19,4 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.20.0 // indirect ) diff --git a/go.sum b/go.sum index 8f64063..ae35a08 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,6 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/home_detect.go b/home_detect.go index e55e7cc..442b8ed 100644 --- a/home_detect.go +++ b/home_detect.go @@ -82,7 +82,13 @@ func StartHomeMonitor(cfg *Config, connectedFn func() bool) { } func checkAndPush(cfg *Config, connectedFn func() bool) { - ip := fetchPublicIP() + // An air-gapped host cannot reach the public-IP providers; when + // public_ip_detect is false we skip the network calls entirely and report + // no public IP rather than flap the home/away state (DESIGN-WINDOWS.md §10). + var ip string + if cfg.DetectPublicIP() { + ip = fetchPublicIP() + } if ip == "" { log.Println("[home-detect] could not determine public IP") } diff --git a/ldap_tunnel.go b/ldap_tunnel.go index b82d823..45339ac 100644 --- a/ldap_tunnel.go +++ b/ldap_tunnel.go @@ -39,19 +39,22 @@ func newLdapTunnel(send func(WSMessage) error) *ldapTunnel { // start binds both unix socket and TCP loopback, accepting connections until stopCh closes. func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) { - os.Remove(socketPath) - if dir := filepath.Dir(socketPath); dir != "." && dir != "/" { - os.MkdirAll(dir, 0755) - } + // 1. UNIX Domain Socket Listener. Windows passes an empty path and relies + // on the TCP loopback listener below. + if socketPath != "" { + os.Remove(socketPath) + if dir := filepath.Dir(socketPath); dir != "." && dir != "/" { + os.MkdirAll(dir, 0755) + } - // 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) + 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) + } } // 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890) diff --git a/main.go b/main.go index f1ef901..7328c4a 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "strings" + "sync" "sync/atomic" "syscall" ) @@ -14,15 +15,40 @@ import ( // comes up and drops, so StartHomeMonitor can read it without a mutex. var wsConnected atomic.Bool +// agentStopCh closes once the agent should exit: a SIGINT/SIGTERM in the +// foreground, or the SCM stop request when running as a Windows service. Both +// runAgent (foreground) and the service handler wait on it. +var ( + agentStopOnce sync.Once + agentStopCh = make(chan struct{}) +) + +// stopAgent signals the running agent to shut down. Idempotent. +func stopAgent() { + agentStopOnce.Do(func() { close(agentStopCh) }) +} + func main() { if len(os.Args) > 1 && handleCLI(os.Args[1:]) { return } + // Windows: when installed as a service the SCM starts us and svc.Run takes + // over the process lifecycle. Foreground (or any other OS) falls through to + // runAgent, which blocks until a signal arrives. + if maybeRunAsService() { + return + } + + runAgent() +} + +// runAgent runs the agent daemon until stopAgent is called. +func runAgent() { log.Println("Starting Theta Agent...") // Attempt to load configuration - configPath := "/etc/theta42/agent.yml" + configPath := defaultConfigPath() if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { configPath = os.Args[1] } @@ -41,8 +67,10 @@ func main() { cfg.Capabilities.ArbitraryBash, ) - // Initialize system executor + // Initialize system executor and pin the platform ops the command + // dispatcher runs behind. exec := &SystemExecutor{} + defaultPlatformOps = NewPlatformOps(cfg, exec) // Tray IPC server — desktop tray connects here for status updates. go globalTrayServer.Start() @@ -53,10 +81,16 @@ func main() { // Home detection + tray status push (polls public IP every 60s). go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() }) - // Block until signal is received - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - <-sigs + // Foreground: exit on SIGINT/SIGTERM. A Windows service ignores these and + // is driven by its own handler. + go func() { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + <-sigs + stopAgent() + }() + + <-agentStopCh fmt.Println("Shutting down Theta Agent...") } diff --git a/paths.go b/paths.go new file mode 100644 index 0000000..920c457 --- /dev/null +++ b/paths.go @@ -0,0 +1,47 @@ +package main + +// Platform paths. Windows has no /etc, /run or /tmp, so the agent's +// filesystem touchpoints move under %ProgramData%\Theta42, which SYSTEM (the +// service) and authenticated users (the tray) can both reach. + +import ( + "os" + "path/filepath" + "runtime" +) + +// windowsDataDir returns %ProgramData%\Theta42. +func windowsDataDir() string { + pd := os.Getenv("ProgramData") + if pd == "" { + pd = `C:\ProgramData` + } + return filepath.Join(pd, "Theta42") +} + +// defaultConfigPath returns the platform's agent.yml location. +func defaultConfigPath() string { + if runtime.GOOS == "windows" { + return filepath.Join(windowsDataDir(), "agent.yml") + } + return "/etc/theta42/agent.yml" +} + +// defaultLdapSocketPath returns the local LDAP byte-pump socket. Windows has no +// AF_UNIX in a stable location that both service and clients share, so it +// relies on the TCP loopback listener (127.0.0.1:389) that ldapTunnel.start +// falls back to. +func defaultLdapSocketPath() string { + if runtime.GOOS == "windows" { + return "" + } + return "/run/theta/ldap.sock" +} + +// windowsTraySocketPath returns the tray IPC socket path. It lives in the +// shared data dir (not the per-user temp dir) because the daemon runs as the +// SYSTEM service while the tray runs as the logged-in user; the installer +// grants Users write access to this directory and its children. +func windowsTraySocketPath() string { + return filepath.Join(windowsDataDir(), "tray.sock") +} diff --git a/platform_factory_other.go b/platform_factory_other.go new file mode 100644 index 0000000..ce86adb --- /dev/null +++ b/platform_factory_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package main + +// NewPlatformOps returns the Linux/POSIX implementation (systemctl, journalctl, +// bash). It is the fallback for any non-Windows host. +func NewPlatformOps(cfg *Config, exec Executor) PlatformOps { + return &linuxPlatformOps{exec: exec} +} diff --git a/platform_factory_windows.go b/platform_factory_windows.go new file mode 100644 index 0000000..3b00f01 --- /dev/null +++ b/platform_factory_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package main + +// NewPlatformOps returns the Windows implementation. +func NewPlatformOps(cfg *Config, exec Executor) PlatformOps { + return &windowsPlatformOps{ + exec: exec, + helperPath: cfg.DesktopHelper, + serviceName: cfg.ServiceName, + } +} diff --git a/platform_linuxops.go b/platform_linuxops.go new file mode 100644 index 0000000..cc19cd4 --- /dev/null +++ b/platform_linuxops.go @@ -0,0 +1,158 @@ +package main + +// linuxPlatformOps implements PlatformOps for Linux (and any POSIX host). +// The commands mirror what websocket.go executed before the PlatformOps split; +// behaviour is unchanged. Deliberately NOT build-tagged: it compiles on every +// OS (it only shells out to systemctl/journalctl/bash), so the shared command +// dispatch tests can pin it and run identically on Windows CI. + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" +) + +type linuxPlatformOps struct { + exec Executor +} + +func (p *linuxPlatformOps) Reboot() ([]byte, error) { + return p.exec.Execute("reboot") +} + +func (p *linuxPlatformOps) Shutdown() ([]byte, error) { + out, err := p.exec.Execute("shutdown", "-h", "now") + if err != nil { + return p.exec.Execute("poweroff") + } + return out, nil +} + +func (p *linuxPlatformOps) FetchLogs(service string, lines int) ([]byte, error) { + return p.exec.Execute("journalctl", "-u", service, "-n", fmt.Sprintf("%d", lines), "--no-pager") +} + +func (p *linuxPlatformOps) ServiceControl(service, action string) ([]byte, error) { + return p.exec.Execute("systemctl", action, service) +} + +func (p *linuxPlatformOps) RunScript(script string) ([]byte, error) { + return p.exec.Execute("bash", "-c", script) +} + +func (p *linuxPlatformOps) DesktopControl(subAction, targetUser string) ([]byte, error) { + switch subAction { + case "lock_session", "lock": + out, err := p.exec.Execute("loginctl", "lock-sessions") + if err != nil { + return p.exec.Execute("sh", "-c", "DISPLAY=:0 xdg-screensaver lock || DISPLAY=:0 xset dpms force off") + } + return out, nil + case "logout_user", "logout": + if targetUser != "" { + out, err := p.exec.Execute("loginctl", "terminate-user", targetUser) + if err != nil { + return p.exec.Execute("pkill", "-KILL", "-u", targetUser) + } + return out, nil + } + out, err := p.exec.Execute("loginctl", "terminate-session") + if err != nil { + return p.exec.Execute("pkill", "-9", "-f", "session-child") + } + return out, nil + case "display_off": + return p.exec.Execute("sh", "-c", "DISPLAY=:0 xset dpms force off || loginctl lock-sessions") + case "sleep_host", "sleep": + return p.exec.Execute("systemctl", "suspend") + default: + return nil, fmt.Errorf("unknown desktop action '%s'", subAction) + } +} + +// ConfigureLDAP reproduces the original SSSD/nsswitch/PAM configuration flow. +func (p *linuxPlatformOps) ConfigureLDAP(configData string) error { + log.Println("Pushing updated SSSD configuration...") + _ = os.MkdirAll("/etc/sssd", 0755) + if err := p.exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil { + return fmt.Errorf("failed to write SSSD config: %w", err) + } + + // 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 := p.exec.Execute("systemctl", "restart", "sssd"); err != nil { + log.Printf("SSSD restart failed (%v), attempting auto-install of missing packages...", err) + if _, err2 := p.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 { + _, _ = p.exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true") + if _, err3 := p.exec.Execute("systemctl", "restart", "sssd"); err3 == nil { + writeSSSDSshConf(p.exec) + return nil + } + } + return fmt.Errorf("failed to restart sssd") + } + + writeSSSDSshConf(p.exec) + return nil +} + +// writeSSSDSshConf installs the AuthorizedKeysCommand config sshd needs to look +// up LDAP-backed SSH keys. +func writeSSSDSshConf(exec Executor) { + _ = 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") + } + } + _, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true") +} + +func (p *linuxPlatformOps) ApplyUpdate(downloadURL, checksum string) error { + tmpPath, err := downloadBinary(downloadURL, checksum) + if err != nil { + return err + } + + selfPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to resolve current binary path: %w", err) + } + if resolved, err := filepath.EvalSymlinks(selfPath); err == nil { + selfPath = resolved + } + if err := os.Rename(tmpPath, selfPath); err != nil { + return fmt.Errorf("failed to replace binary: %w", err) + } + return nil +} + +func (p *linuxPlatformOps) SelfRestart() { + os.Exit(0) +} diff --git a/platform_ops.go b/platform_ops.go new file mode 100644 index 0000000..3e357f6 --- /dev/null +++ b/platform_ops.go @@ -0,0 +1,52 @@ +package main + +// PlatformOps abstracts the system operations the command dispatcher needs. +// Command dispatch, capability gating and Ed25519 verification are +// platform-neutral (websocket.go); the actual mechanics differ per OS: +// +// - linuxPlatformOps (platform_linuxops.go) — systemctl, journalctl, bash, ... +// - windowsPlatformOps (platform_windows.go) — sc.exe, powershell, the +// theta-agent-helper for session-0 ops and staged self-update. +// +// defaultPlatformOps is pinned at startup (runAgent) and swappable in tests so +// command dispatch can be exercised identically on any host. + +type PlatformOps interface { + // Reboot restarts the host. + Reboot() ([]byte, error) + + // Shutdown powers the host off. + Shutdown() ([]byte, error) + + // FetchLogs returns the last `lines` log lines for a service. + FetchLogs(service string, lines int) ([]byte, error) + + // ServiceControl runs an action (start/stop/restart/status/...) against a + // Windows service. Actions that don't map are executed best-effort. + ServiceControl(service, action string) ([]byte, error) + + // RunScript executes an operator-supplied script (arbitrary_bash). + RunScript(script string) ([]byte, error) + + // DesktopControl runs a desktop-control subaction (lock/logout/display/sleep) + // for a target user. + DesktopControl(subAction, targetUser string) ([]byte, error) + + // ConfigureLDAP writes the pushed LDAP configuration. Linux configures + // SSSD; Windows manages logon via OpenCredential instead and declines. + ConfigureLDAP(configData string) error + + // ApplyUpdate downloads, verifies and stages a new binary. The swap is + // platform-specific: Linux renames over the running binary, Windows writes + // a `.new` next to it and hands the swap to theta-agent-helper (the running + // exe is locked and the service must stop first). + ApplyUpdate(downloadURL, checksum string) error + + // SelfRestart terminates the agent so a staged update takes effect. Linux + // exits; the Windows service handler stops itself (the helper restarts it). + SelfRestart() +} + +// defaultPlatformOps is the ops implementation the command dispatcher uses. +// Pinned by runAgent after config load; tests override it directly. +var defaultPlatformOps = NewPlatformOps(&Config{}, &SystemExecutor{}) diff --git a/platform_windows.go b/platform_windows.go new file mode 100644 index 0000000..2e7f479 --- /dev/null +++ b/platform_windows.go @@ -0,0 +1,220 @@ +//go:build windows + +package main + +// windowsPlatformOps implements PlatformOps for Windows. Remote ops map to +// Windows equivalents: +// +// - reboot/shutdown → shutdown.exe +// - service control → sc.exe +// - fetch logs → Get-WinEvent (PowerShell) +// - arbitrary_bash → powershell -EncodedCommand (survives arbitrary quoting) +// - desktop control → theta-agent-helper (session-0 workaround, see +// DESIGN-WINDOWS.md §4) +// - self-update → staged `.new` + helper swap (the running exe is locked) +// +// configure_ldap is declined: Windows logon goes through the OpenCredential +// credential provider, which the installer configures directly. + +import ( + "encoding/base64" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" + "syscall" + "unicode/utf16" + + "golang.org/x/sys/windows" +) + +type windowsPlatformOps struct { + exec Executor + helperPath string // theta-agent-helper.exe (DESIGN-WINDOWS.md §8) + serviceName string // Windows service name (defaults to theta-agent) +} + +func (p *windowsPlatformOps) Reboot() ([]byte, error) { + return p.exec.Execute("shutdown", "/r", "/t", "0") +} + +func (p *windowsPlatformOps) Shutdown() ([]byte, error) { + return p.exec.Execute("shutdown", "/s", "/t", "0") +} + +func (p *windowsPlatformOps) FetchLogs(service string, lines int) ([]byte, error) { + script := fmt.Sprintf("Get-WinEvent -LogName Application -MaxEvents %d -ErrorAction SilentlyContinue | Format-List TimeCreated, ProviderName, Id, LevelDisplayName, Message", lines) + return p.runPowerShell(script) +} + +// ServiceControl maps systemd-style actions onto sc.exe. `restart` has no +// one-shot sc command, so it stops then starts; `status` is `sc query`. +func (p *windowsPlatformOps) ServiceControl(service, action string) ([]byte, error) { + switch action { + case "status": + return p.exec.Execute("sc.exe", "query", service) + case "restart": + // A service that is already stopped is not an error worth failing on. + if _, err := p.exec.Execute("sc.exe", "stop", service); err != nil { + log.Printf("[windows] sc stop %s: %v (continuing to start)", service, err) + } + out, err := p.exec.Execute("sc.exe", "start", service) + if err != nil && strings.Contains(string(out), "1056") { + // ERROR_SERVICE_ALREADY_RUNNING — the stop never landed; treat as up. + return out, nil + } + return out, err + default: + return p.exec.Execute("sc.exe", action, service) + } +} + +func (p *windowsPlatformOps) RunScript(script string) ([]byte, error) { + return p.runPowerShell(script) +} + +// runPowerShell invokes powershell with a UTF-16LE base64 -EncodedCommand. An +// operator script can contain arbitrary quotes, `&`, `%`, etc.; passing it as a +// plain argument would be mangled by cmd.exe/arg quoting rules, while the +// encoded form is byte-exact on both sides. +func (p *windowsPlatformOps) runPowerShell(script string) ([]byte, error) { + units := utf16.Encode([]rune(script)) + b := make([]byte, len(units)*2) + for i, r := range units { + b[i*2] = byte(r) + b[i*2+1] = byte(r >> 8) + } + enc := base64.StdEncoding.EncodeToString(b) + return p.exec.Execute("powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc) +} + +// DesktopControl routes ops that need an interactive desktop to the helper, +// which the service launches in the target session (DESIGN-WINDOWS.md §4). +// Sleep can run from session 0 and is handled in-process. +func (p *windowsPlatformOps) DesktopControl(subAction, targetUser string) ([]byte, error) { + var action string + switch subAction { + case "sleep_host", "sleep": + return nil, setSuspendState() + case "lock_session", "lock": + action = "lock" + case "display_off": + action = "display_off" + case "logout_user", "logout": + action = "logout" + default: + return nil, fmt.Errorf("unknown desktop action '%s'", subAction) + } + + if p.helperPath == "" { + return nil, fmt.Errorf("desktop control requires theta-agent-helper (desktop_helper not configured)") + } + args := []string{action} + if targetUser != "" { + args = append(args, targetUser) + } + return p.exec.Execute(p.helperPath, args...) +} + +// setSuspendState puts the machine to sleep. Requires SeShutdownPrivilege, +// which the SYSTEM service holds. +func setSuspendState() error { + powrprof := syscall.NewLazyDLL("powrprof.dll") + proc := powrprof.NewProc("SetSuspendState") + r, _, err := proc.Call(0, 0, 0) // Hibernate=false, ForceCritical=false, WakeIfDisarmed=false + if r == 0 { + return err + } + return nil +} + +// ConfigureLDAP is not applicable on Windows: directory logon uses the +// OpenCredential credential provider configured by the installer. +func (p *windowsPlatformOps) ConfigureLDAP(configData string) error { + return fmt.Errorf("configure_ldap is not applicable on Windows; logon is managed by the OpenCredential credential provider") +} + +// ApplyUpdate downloads and verifies the new binary to `.new`, then hands +// the swap to the helper: the running service holds the exe open, so the agent +// must stop before the file can be replaced. The helper outlives the service +// (detached process), swaps the files, and restarts the service. +func (p *windowsPlatformOps) ApplyUpdate(downloadURL, checksum string) error { + selfPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to resolve current binary path: %w", err) + } + + tmpPath, err := downloadBinary(downloadURL, checksum) + if err != nil { + return err + } + + newPath := selfPath + ".new" + if err := moveFile(tmpPath, newPath); err != nil { + return fmt.Errorf("failed to stage new binary: %w", err) + } + + helper := p.helperPath + if helper == "" { + return fmt.Errorf("desktop_helper not configured; cannot complete self-update") + } + service := p.serviceName + if service == "" { + service = "theta-agent" + } + + log.Printf("[windows] staging self-update via helper (%s -> %s)", newPath, selfPath) + if err := spawnDetached(helper, "update", newPath, selfPath, service); err != nil { + return fmt.Errorf("failed to launch updater helper: %w", err) + } + return nil +} + +func (p *windowsPlatformOps) SelfRestart() { + stopAgent() +} + +// spawnDetached launches exe as a background process that survives this one. +func spawnDetached(exe string, args ...string) error { + cmd := execCommand(exe, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP, + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Start() +} + +// execCommand is a thin wrapper so tests that build the windows ops can stub it. +func execCommand(name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) +} + +// moveFile renames src onto dst, falling back to a copy when the source is on a +// different volume than the destination (os.Rename fails across volumes). +func moveFile(src, dst string) error { + if err := os.Rename(src, dst); err == nil { + return nil + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(dst) + return err + } + if err := out.Close(); err != nil { + os.Remove(dst) + return err + } + return os.Remove(src) +} diff --git a/secrets_test.go b/secrets_test.go index 6ae8c30..e5de530 100644 --- a/secrets_test.go +++ b/secrets_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "testing" ) @@ -68,10 +69,14 @@ func TestRenderSecrets(t *testing.T) { t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected) } - // The target should be 0600 (holds secrets). - info, _ := os.Stat(target) - if info.Mode().Perm() != 0600 { - t.Fatalf("expected 0600, got %o", info.Mode().Perm()) + // The target should be 0600 (holds secrets). Windows has no POSIX modes and + // reports 0666 regardless; the intent there is covered by the ACLs the + // installer sets on the target directory. + if runtime.GOOS != "windows" { + info, _ := os.Stat(target) + if info.Mode().Perm() != 0600 { + t.Fatalf("expected 0600, got %o", info.Mode().Perm()) + } } } diff --git a/service_cli_other.go b/service_cli_other.go new file mode 100644 index 0000000..146b522 --- /dev/null +++ b/service_cli_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +// handleServiceCommand is a no-op outside Windows: the agent is managed by +// systemd/init there. +func handleServiceCommand(args []string) {} diff --git a/service_cli_windows.go b/service_cli_windows.go new file mode 100644 index 0000000..7732eb4 --- /dev/null +++ b/service_cli_windows.go @@ -0,0 +1,88 @@ +//go:build windows + +package main + +// Service management CLI (theta-agent install-service / remove-service). The +// Inno installer also drives this rather than hand-rolling `sc create`. + +import ( + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/windows/svc/mgr" +) + +func handleServiceCommand(args []string) { + action := "install" + if len(args) > 0 { + action = args[0] + } + switch action { + case "install": + installService() + case "remove": + removeService() + default: + fmt.Fprintf(os.Stderr, "usage: theta-agent %s [install|remove]\n", action) + os.Exit(1) + } +} + +func installService() { + exe, err := os.Executable() + if err != nil { + logFatal("cannot resolve agent path: %v", err) + } + + m, err := mgr.Connect() + if err != nil { + logFatal("cannot connect to service manager: %v", err) + } + defer m.Disconnect() + + s, err := m.OpenService("theta-agent") + if err == nil { + s.Close() + fmt.Println("[+] theta-agent service already exists") + return + } + + cfg := mgr.Config{ + DisplayName: "Theta Agent", + Description: "Theta42 unified endpoint management agent (telemetry, remote ops, LDAP logon, WireGuard mesh)", + ServiceStartName: "LocalSystem", + StartType: mgr.StartAutomatic, + } + s, err = m.CreateService("theta-agent", exe, cfg, "is", "auto") + if err != nil { + logFatal("cannot create service: %v", err) + } + defer s.Close() + fmt.Printf("[+] Registered theta-agent service (%s)\n", filepath.Base(exe)) +} + +func removeService() { + m, err := mgr.Connect() + if err != nil { + logFatal("cannot connect to service manager: %v", err) + } + defer m.Disconnect() + + s, err := m.OpenService("theta-agent") + if err != nil { + fmt.Println("[!] theta-agent service not found") + return + } + defer s.Close() + + if err := s.Delete(); err != nil { + logFatal("cannot delete service: %v", err) + } + fmt.Println("[+] Removed theta-agent service") +} + +func logFatal(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "[!] "+format+"\n", args...) + os.Exit(1) +} diff --git a/service_other.go b/service_other.go new file mode 100644 index 0000000..aa4d716 --- /dev/null +++ b/service_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package main + +// maybeRunAsService is a no-op on non-Windows hosts: the agent runs in the +// foreground under systemd / init and is driven by SIGTERM. +func maybeRunAsService() bool { + return false +} diff --git a/service_windows.go b/service_windows.go new file mode 100644 index 0000000..f88b51f --- /dev/null +++ b/service_windows.go @@ -0,0 +1,65 @@ +//go:build windows + +package main + +// Windows service wrapper. The installer registers theta-agent as a SYSTEM +// auto-start service; when the SCM launches the process this file takes over. +// The service is up before any user session, which is what allows the +// credential provider to validate LDAP logins at Ctrl+Alt+Del and what makes +// the tray IPC socket path (shared data dir) correct. + +import ( + "log" + "os" + + "golang.org/x/sys/windows/svc" +) + +// maybeRunAsService returns true (and runs the service loop) when launched by +// the Service Control Manager; false when run in a console. +func maybeRunAsService() bool { + isSvc, err := svc.IsWindowsService() + if err != nil || !isSvc { + return false + } + if err := svc.Run("theta-agent", &agentService{}); err != nil { + log.Printf("service: %v", err) + os.Exit(1) + } + return true +} + +type agentService struct{} + +func (s *agentService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown + + changes <- svc.Status{State: svc.StartPending} + + done := make(chan struct{}) + go func() { + defer close(done) + runAgent() + }() + + changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + changes <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending} + stopAgent() + <-done + changes <- svc.Status{State: svc.Stopped} + return false, 0 + } + case <-done: + changes <- svc.Status{State: svc.Stopped} + return false, 0 + } + } +} diff --git a/telemetry.go b/telemetry.go index c83b941..3917709 100644 --- a/telemetry.go +++ b/telemetry.go @@ -425,7 +425,7 @@ func collectHostDetails() HostDetails { return details } -const AgentVersion = "v2.0.0" +const AgentVersion = "v2.1.0" // CollectDiscoveryData gathers static host information. func CollectDiscoveryData(cfg *Config) DiscoveryData { @@ -446,7 +446,10 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData { cpuDet := collectCPUDetails() loggedUsers := collectLoggedUsers() - pubIP := getPublicIP() + pubIP := "" + if cfg.DetectPublicIP() { + pubIP = getPublicIP() + } diskTotalGB := 0.0 for _, d := range disks { diff --git a/tray_ipc.go b/tray_ipc.go index bab09e0..d02b13b 100644 --- a/tray_ipc.go +++ b/tray_ipc.go @@ -9,19 +9,19 @@ package main import ( "encoding/json" - "os" - "path/filepath" "runtime" ) // TraySocketPaths are the paths the daemon tries to bind, in order. // -// Windows has no /run or /tmp; a Unix socket in the per-user temp dir works -// there (AF_UNIX is supported since Windows 10 1803) and needs no admin -// rights. Linux keeps the original /run/theta path with a /tmp fallback. +// Windows has no /run or /tmp. The daemon runs as the SYSTEM service while the +// tray runs as the logged-in user, so the socket lives in the shared data dir +// (%ProgramData%\Theta42, created by the installer with a Users-writable ACL) +// rather than a per-user temp dir — the two processes must agree on one path. +// Linux keeps the original /run/theta path with a /tmp fallback. var TraySocketPaths = func() []string { if runtime.GOOS == "windows" { - return []string{filepath.Join(os.TempDir(), "theta-tray.sock")} + return []string{windowsTraySocketPath()} } return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"} }() @@ -30,7 +30,7 @@ var TraySocketPaths = func() []string { // entry in TraySocketPaths on each platform. var TraySocket = func() string { if runtime.GOOS == "windows" { - return filepath.Join(os.TempDir(), "theta-tray.sock") + return windowsTraySocketPath() } return "/tmp/theta-tray.sock" }() diff --git a/tray_server.go b/tray_server.go index c54466d..4233d40 100644 --- a/tray_server.go +++ b/tray_server.go @@ -15,6 +15,7 @@ import ( "log" "net" "os" + "path/filepath" "strings" "sync" ) @@ -37,6 +38,7 @@ func (ts *trayServer) Start() { for _, p := range TraySocketPaths { os.Remove(p) + os.MkdirAll(filepath.Dir(p), 0755) //nolint:errcheck l, err = net.Listen("unix", p) if err == nil { boundPath = p diff --git a/websocket.go b/websocket.go index 6fce044..f3f12e1 100644 --- a/websocket.go +++ b/websocket.go @@ -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 } diff --git a/websocket_test.go b/websocket_test.go index 19a91c2..33f4f13 100644 --- a/websocket_test.go +++ b/websocket_test.go @@ -245,6 +245,14 @@ func TestHandleCommand(t *testing.T) { mockConn := &MockConn{} mockExec := &MockExecutor{} cm := &ConfigManager{current: tc.cfg} + + // The dispatch tests assert the exact command lines the Linux + // executor produces; pin the platform ops so they behave the same + // on any CI host (Windows included). + prevOps := defaultPlatformOps + defaultPlatformOps = &linuxPlatformOps{exec: mockExec} + defer func() { defaultPlatformOps = prevOps }() + msg := tc.msg if tc.signed { msg.Payload = sign(t, msg.Payload)