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
+2
View File
@@ -4,3 +4,5 @@ theta-agent-*
dist/ dist/
*.exe *.exe
agent.yml agent.yml
!cmd/theta-agent-helper/
!cmd/theta-agent-helper/main.go
+12
View File
@@ -25,6 +25,12 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_D
echo " -> windows/arm64..." echo " -> windows/arm64..."
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-arm64.exe" 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)..." echo " -> darwin/amd64 (macOS Intel)..."
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-amd64" 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..." 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/ 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 ""
echo "Build complete! Artifacts in $DIST_DIR:" echo "Build complete! Artifacts in $DIST_DIR:"
ls -lh "$DIST_DIR" ls -lh "$DIST_DIR"
+28 -4
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
"runtime"
"strings" "strings"
"time" "time"
) )
@@ -31,6 +32,12 @@ func handleCLI(args []string) bool {
case "--reinitialize", "reinitialize", "--reinit", "reinit": case "--reinitialize", "reinitialize", "--reinit", "reinit":
runReinitialize(args[1:]) runReinitialize(args[1:])
return true 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": case "--version", "version", "-v":
fmt.Println("Theta Agent " + AgentVersion) fmt.Println("Theta Agent " + AgentVersion)
return true 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 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 update Self-update binary from SSO Manager")
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register") 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(" theta-agent version Show version info")
fmt.Println() fmt.Println()
fmt.Println("Reinitialize Flags:") fmt.Println("Reinitialize Flags:")
@@ -58,7 +67,7 @@ func printUsage() {
} }
func runSelfUpdate(args []string) { func runSelfUpdate(args []string) {
configPath := "/etc/theta42/agent.yml" configPath := defaultConfigPath()
cm, err := NewConfigManager(configPath) cm, err := NewConfigManager(configPath)
if err != nil { if err != nil {
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err) 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) 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) log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
client := &http.Client{Timeout: 30 * time.Second} client := &http.Client{Timeout: 30 * time.Second}
@@ -106,7 +124,7 @@ func runSelfUpdate(args []string) {
} }
func runReinitialize(args []string) { func runReinitialize(args []string) {
configPath := "/etc/theta42/agent.yml" configPath := defaultConfigPath()
joinKey := "" joinKey := ""
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) { if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
@@ -146,6 +164,12 @@ func runReinitialize(args []string) {
func restartAffectedServices(exec Executor) { func restartAffectedServices(exec Executor) {
log.Printf("[+] Restarting theta-agent service...") 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") _, _ = exec.Execute("systemctl", "restart", "theta-agent")
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil { if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
@@ -235,7 +259,7 @@ func runGetSecrets(args []string) {
} }
func fetchAgentSecrets() (map[string]string, error) { func fetchAgentSecrets() (map[string]string, error) {
configPath := "/etc/theta42/agent.yml" configPath := defaultConfigPath()
cm, err := NewConfigManager(configPath) cm, err := NewConfigManager(configPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err) return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
+227
View File
@@ -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 <newExe> <currentExe> <serviceName>
//
// 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 <lock|display_off|logout|update|sleep> [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 <newExe> <currentExe> <serviceName>")
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")
}
+9 -4
View File
@@ -36,12 +36,17 @@ import (
// ── IPC types (duplicated from the main agent package; tray is its own binary) ── // ── IPC types (duplicated from the main agent package; tray is its own binary) ──
// traySocketPaths returns the daemon IPC socket paths for this platform, in // 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, // the same order the daemon tries to bind them. Windows has no /run or /tmp and
// so it uses a Unix socket under the per-user temp dir; Linux keeps the // the daemon runs as a SYSTEM service, so both sides use the shared
// original pair. // %ProgramData%\Theta42\tray.sock (installer creates the dir with a
// Users-writable ACL); Linux keeps the original pair.
func traySocketPaths() []string { func traySocketPaths() []string {
if runtime.GOOS == "windows" { 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"} return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
} }
+33
View File
@@ -29,6 +29,14 @@ type SecretTarget struct {
Reload string `yaml:"reload"` 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 { type Config struct {
ServerURL string `yaml:"server_url"` ServerURL string `yaml:"server_url"`
AuthToken string `yaml:"auth_token"` AuthToken string `yaml:"auth_token"`
@@ -42,6 +50,31 @@ type Config struct {
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4) LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5) Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
Capabilities Capabilities `yaml:"capabilities"` 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 // Credential returns the value to present when connecting: our own token once
+5 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
) )
@@ -155,11 +156,14 @@ capabilities:
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential()) t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential())
} }
// file must stay 0600 -- it now holds a credential // 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) fi, _ := os.Stat(path)
if fi.Mode().Perm() != 0600 { if fi.Mode().Perm() != 0600 {
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm()) t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
} }
}
} }
func TestPersistEnrollmentAddsMissingKeys(t *testing.T) { func TestPersistEnrollmentAddsMissingKeys(t *testing.T) {
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -3,13 +3,14 @@ module github.com/theta42/theta-agent
go 1.22.2 go 1.22.2
require ( require (
fyne.io/systray v1.12.2
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/shirou/gopsutil/v3 v3.24.5 github.com/shirou/gopsutil/v3 v3.24.5
golang.org/x/sys v0.20.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
fyne.io/systray v1.12.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // 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/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sys v0.20.0 // indirect
) )
-2
View File
@@ -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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 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.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= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+7 -1
View File
@@ -82,7 +82,13 @@ func StartHomeMonitor(cfg *Config, connectedFn func() bool) {
} }
func checkAndPush(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 == "" { if ip == "" {
log.Println("[home-detect] could not determine public IP") log.Println("[home-detect] could not determine public IP")
} }
+4 -1
View File
@@ -39,12 +39,14 @@ func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
// start binds both unix socket and TCP loopback, accepting 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{}) { func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
// 1. UNIX Domain Socket Listener. Windows passes an empty path and relies
// on the TCP loopback listener below.
if socketPath != "" {
os.Remove(socketPath) os.Remove(socketPath)
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" { if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
os.MkdirAll(dir, 0755) os.MkdirAll(dir, 0755)
} }
// 1. UNIX Domain Socket Listener
lnUnix, err := net.Listen("unix", socketPath) lnUnix, err := net.Listen("unix", socketPath)
if err == nil { if err == nil {
os.Chmod(socketPath, 0666) os.Chmod(socketPath, 0666)
@@ -53,6 +55,7 @@ func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
} else { } else {
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err) 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) // 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") lnTcp, errTcp := net.Listen("tcp", "127.0.0.1:389")
+37 -3
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
) )
@@ -14,15 +15,40 @@ import (
// comes up and drops, so StartHomeMonitor can read it without a mutex. // comes up and drops, so StartHomeMonitor can read it without a mutex.
var wsConnected atomic.Bool 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() { func main() {
if len(os.Args) > 1 && handleCLI(os.Args[1:]) { if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
return 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...") log.Println("Starting Theta Agent...")
// Attempt to load configuration // Attempt to load configuration
configPath := "/etc/theta42/agent.yml" configPath := defaultConfigPath()
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
configPath = os.Args[1] configPath = os.Args[1]
} }
@@ -41,8 +67,10 @@ func main() {
cfg.Capabilities.ArbitraryBash, cfg.Capabilities.ArbitraryBash,
) )
// Initialize system executor // Initialize system executor and pin the platform ops the command
// dispatcher runs behind.
exec := &SystemExecutor{} exec := &SystemExecutor{}
defaultPlatformOps = NewPlatformOps(cfg, exec)
// Tray IPC server — desktop tray connects here for status updates. // Tray IPC server — desktop tray connects here for status updates.
go globalTrayServer.Start() go globalTrayServer.Start()
@@ -53,10 +81,16 @@ func main() {
// Home detection + tray status push (polls public IP every 60s). // Home detection + tray status push (polls public IP every 60s).
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() }) go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() })
// Block until signal is received // 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) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs <-sigs
stopAgent()
}()
<-agentStopCh
fmt.Println("Shutting down Theta Agent...") fmt.Println("Shutting down Theta Agent...")
} }
+47
View File
@@ -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")
}
+9
View File
@@ -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}
}
+12
View File
@@ -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,
}
}
+158
View File
@@ -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)
}
+52
View File
@@ -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{})
+220
View File
@@ -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 `<self>.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)
}
+6 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
) )
@@ -68,11 +69,15 @@ func TestRenderSecrets(t *testing.T) {
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected) t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
} }
// The target should be 0600 (holds secrets). // 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) info, _ := os.Stat(target)
if info.Mode().Perm() != 0600 { if info.Mode().Perm() != 0600 {
t.Fatalf("expected 0600, got %o", info.Mode().Perm()) t.Fatalf("expected 0600, got %o", info.Mode().Perm())
} }
}
} }
// TestRenderSecretsReload verifies the reload command runs after rendering. // TestRenderSecretsReload verifies the reload command runs after rendering.
+7
View File
@@ -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) {}
+88
View File
@@ -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)
}
+9
View File
@@ -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
}
+65
View File
@@ -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
}
}
}
+5 -2
View File
@@ -425,7 +425,7 @@ func collectHostDetails() HostDetails {
return details return details
} }
const AgentVersion = "v2.0.0" const AgentVersion = "v2.1.0"
// CollectDiscoveryData gathers static host information. // CollectDiscoveryData gathers static host information.
func CollectDiscoveryData(cfg *Config) DiscoveryData { func CollectDiscoveryData(cfg *Config) DiscoveryData {
@@ -446,7 +446,10 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
cpuDet := collectCPUDetails() cpuDet := collectCPUDetails()
loggedUsers := collectLoggedUsers() loggedUsers := collectLoggedUsers()
pubIP := getPublicIP() pubIP := ""
if cfg.DetectPublicIP() {
pubIP = getPublicIP()
}
diskTotalGB := 0.0 diskTotalGB := 0.0
for _, d := range disks { for _, d := range disks {
+7 -7
View File
@@ -9,19 +9,19 @@ package main
import ( import (
"encoding/json" "encoding/json"
"os"
"path/filepath"
"runtime" "runtime"
) )
// TraySocketPaths are the paths the daemon tries to bind, in order. // 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 // Windows has no /run or /tmp. The daemon runs as the SYSTEM service while the
// there (AF_UNIX is supported since Windows 10 1803) and needs no admin // tray runs as the logged-in user, so the socket lives in the shared data dir
// rights. Linux keeps the original /run/theta path with a /tmp fallback. // (%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 { var TraySocketPaths = func() []string {
if runtime.GOOS == "windows" { 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"} return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
}() }()
@@ -30,7 +30,7 @@ var TraySocketPaths = func() []string {
// entry in TraySocketPaths on each platform. // entry in TraySocketPaths on each platform.
var TraySocket = func() string { var TraySocket = func() string {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return filepath.Join(os.TempDir(), "theta-tray.sock") return windowsTraySocketPath()
} }
return "/tmp/theta-tray.sock" return "/tmp/theta-tray.sock"
}() }()
+2
View File
@@ -15,6 +15,7 @@ import (
"log" "log"
"net" "net"
"os" "os"
"path/filepath"
"strings" "strings"
"sync" "sync"
) )
@@ -37,6 +38,7 @@ func (ts *trayServer) Start() {
for _, p := range TraySocketPaths { for _, p := range TraySocketPaths {
os.Remove(p) os.Remove(p)
os.MkdirAll(filepath.Dir(p), 0755) //nolint:errcheck
l, err = net.Listen("unix", p) l, err = net.Listen("unix", p)
if err == nil { if err == nil {
boundPath = p boundPath = p
+28 -117
View File
@@ -12,7 +12,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -174,7 +173,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP { if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
socketPath := cfg.LdapSocket socketPath := cfg.LdapSocket
if socketPath == "" { if socketPath == "" {
socketPath = "/run/theta/ldap.sock" socketPath = defaultLdapSocketPath()
} }
go tunnel.start(socketPath, stopCh) 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) 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 { if err != nil {
log.Printf("Log fetch failed: %v", err) log.Printf("Log fetch failed: %v", err)
sendResponse("error", "failed to fetch logs") 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) 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) log.Printf("Update failed: %v", err)
sendResponse("error", fmt.Sprintf("update failed: %v", err)) sendResponse("error", fmt.Sprintf("update failed: %v", err))
return return
} }
sendResponse("ok", "update applied successfully; restarting agent...") sendResponse("ok", "update applied successfully; restarting agent...")
os.Exit(0) defaultPlatformOps.SelfRestart()
case "config": case "config":
// A config frame carrying credentials means the server accepted our // A config frame carrying credentials means the server accepted our
// join key and enrolled this host. // join key and enrolled this host.
@@ -370,7 +369,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return return
} }
log.Printf("Executing reboot...") log.Printf("Executing reboot...")
if _, err := exec.Execute("reboot"); err != nil { if _, err := defaultPlatformOps.Reboot(); err != nil {
log.Printf("Reboot failed: %v", err) log.Printf("Reboot failed: %v", err)
sendResponse("error", "reboot failed") sendResponse("error", "reboot failed")
return return
@@ -388,8 +387,8 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
} }
log.Printf("Executing shutdown...") log.Printf("Executing shutdown...")
sendResponse("ok", "system shutting down") sendResponse("ok", "system shutting down")
if _, err := exec.Execute("shutdown", "-h", "now"); err != nil { if _, err := defaultPlatformOps.Shutdown(); err != nil {
exec.Execute("poweroff") log.Printf("Shutdown failed: %v", err)
} }
return return
case "desktop_control", "lock_session", "logout_user", "display_off", "sleep_host": 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) targetUser, _ := msg.Payload["user"].(string)
log.Printf("Executing desktop control action '%s' for user '%s'...", subAction, targetUser) log.Printf("Executing desktop control action '%s' for user '%s'...", subAction, targetUser)
var out []byte
var err error
switch subAction { switch subAction {
case "lock_session", "lock": case "lock_session", "lock", "logout_user", "logout", "display_off", "sleep_host", "sleep":
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")
default: default:
sendResponse("error", fmt.Sprintf("unknown desktop action '%s'", subAction)) sendResponse("error", fmt.Sprintf("unknown desktop action '%s'", subAction))
return return
} }
out, err := defaultPlatformOps.DesktopControl(subAction, targetUser)
errMsg := "" errMsg := ""
if err != nil { if err != nil {
errMsg = err.Error() errMsg = err.Error()
@@ -457,7 +436,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return return
} }
log.Printf("Executing systemctl %s %s...", action, serviceName) log.Printf("Executing systemctl %s %s...", action, serviceName)
out, err := exec.Execute("systemctl", action, serviceName) out, err := defaultPlatformOps.ServiceControl(serviceName, action)
errMsg := "" errMsg := ""
if err != nil { if err != nil {
errMsg = err.Error() errMsg = err.Error()
@@ -480,7 +459,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return return
} }
log.Printf("Restarting service %s...", serviceName) 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) log.Printf("Service restart failed: %v", err)
sendResponse("error", "restart failed") sendResponse("error", "restart failed")
return return
@@ -504,70 +483,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return return
} }
log.Println("Pushing updated SSSD configuration...") if err := defaultPlatformOps.ConfigureLDAP(configData); err != nil {
_ = os.MkdirAll("/etc/sssd", 0755) log.Printf("LDAP configuration failed: %v", err)
if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil { sendResponse("error", err.Error())
log.Printf("Failed to write SSSD config: %v", err)
sendResponse("error", "failed to write config")
return 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") sendResponse("ok", "LDAP configuration updated")
case "render_secrets": case "render_secrets":
if !verifySignature(cfg, msg) { 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) log.Printf("Executing remote script: %s", script)
out, err := exec.Execute("bash", "-c", script) out, err := defaultPlatformOps.RunScript(script)
if err != nil { if err != nil {
log.Printf("Script execution failed: %v", err) log.Printf("Script execution failed: %v", err)
sendResponse("error", fmt.Sprintf("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) resp, err := http.Get(downloadURL)
if err != nil { if err != nil {
return fmt.Errorf("http fetch failed: %w", err) return "", fmt.Errorf("http fetch failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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-*") tmpFile, err := os.CreateTemp("", "theta-agent-update-*")
if err != nil { 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() tmpPath := tmpFile.Name()
defer os.Remove(tmpPath) defer os.Remove(tmpPath)
@@ -678,32 +603,18 @@ func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
if _, err := io.Copy(writer, resp.Body); err != nil { if _, err := io.Copy(writer, resp.Body); err != nil {
tmpFile.Close() tmpFile.Close()
return fmt.Errorf("failed to save binary: %w", err) return "", fmt.Errorf("failed to save binary: %w", err)
} }
tmpFile.Close() tmpFile.Close()
actualSHA256 := fmt.Sprintf("%x", hasher.Sum(nil)) actualSHA256 := fmt.Sprintf("%x", hasher.Sum(nil))
if !strings.EqualFold(actualSHA256, strings.TrimSpace(expectedSHA256)) { 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 { 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() return tmpPath, nil
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
} }
+8
View File
@@ -245,6 +245,14 @@ func TestHandleCommand(t *testing.T) {
mockConn := &MockConn{} mockConn := &MockConn{}
mockExec := &MockExecutor{} mockExec := &MockExecutor{}
cm := &ConfigManager{current: tc.cfg} 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 msg := tc.msg
if tc.signed { if tc.signed {
msg.Payload = sign(t, msg.Payload) msg.Payload = sign(t, msg.Payload)