Files
theta-agent/cmd/theta-agent-helper/main.go
T
wmantly e613874de5 feat(windows): WireGuard client, auto-VPN, IAM, tray enrichment, installer, CI
Second milestone of the Windows parity work (DESIGN-WINDOWS.md §13).

WireGuard mesh client (signed, WSS-delivered):
- wireguard_apply / wireguard_remove commands: Ed25519-verified, gated on a new
  wireguard capability. Linux applies via wg-quick up/down; Windows installs the
  peer config as a WireGuardTunnel service via wireguard.exe
  (/installtunnelservice, /uninstalltunnelservice)
- state polling in the home monitor drives the blue tray icon and auto-VPN:
  connect when away from home + auto_vpn, disconnect on return (2m cooldown)
- tray VPN toggle and the auto-VPN checkbox are now live; the preference
  persists to agent.yml (PersistAutoVPN)

IAM on Windows (iam_windows.go):
- allowed_login_groups -> net localgroup; ssh_keys -> per-profile
  authorized_keys + %ProgramData%\ssh\administrators_authorized_keys;
  revoke_users -> helper logs off all of the user's WTS sessions;
  sudo_rules logged as no direct equivalent

Tray enrichment:
- Open Config (opens agent.yml), Clear enrollment (re-enroll) menu items
- set_auto_vpn persists; vpn_connect/vpn_disconnect/reinit/open_config commands
  handled by the daemon (tray_server.go)

Packaging & release:
- installer/windows/installer.iss: fully-offline Inno Setup bundle (agent,
  tray, helper, vendor-signed WireGuard MSI, OpenCredential CP, VC++ redist),
  /SILENT /SERVER_URL /JOIN_KEY parameters, SYSTEM service + HKLM Run tray
  autostart, Users-writable %ProgramData%\Theta42 for the IPC socket
- .github/workflows/build-windows.yml: build + test, pinned vendor downloads,
  ISCC compile, Azure Trusted Signing (OIDC), SHA256SUMS, GH release attach,
  optional SSO resource publish
- agent.yml.example documents auto_vpn, wireguard, service_name,
  desktop_helper, public_ip_detect

Tests:
- wireguard_apply/remove dispatch (allowed + capability-denied), PersistAutoVPN,
  ClearEnrollment; dispatch tests pin linuxPlatformOps with a temp WireGuard conf
- end-to-end verified against a local mock SSO on Windows: join-key enrollment
  (token persisted, join key blanked), discovery/telemetry pushed, signed
  arbitrary_bash verified + executed via powershell -EncodedCommand; tray IPC
  socket binds %ProgramData%\Theta42; LDAP byte-pump binds 127.0.0.1:389;
  helper update swap verified

Rebuilds all tracked dist binaries (v2.1.0).
2026-08-09 17:49:40 -07:00

242 lines
6.6 KiB
Go

//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 every session of the
// given user (matched by WTS session enumeration).
func logoutSession(user string) {
var ids []uint32
if user == "" {
if id := activeConsoleSessionID(); id != 0 {
ids = []uint32{id}
}
} else {
var err error
ids, err = sessionsForUser(user)
if err != nil {
fmt.Fprintf(os.Stderr, "logout: %v\n", err)
os.Exit(1)
}
}
if len(ids) == 0 {
fmt.Fprintln(os.Stderr, "logout: no active session to log off")
os.Exit(1)
}
for _, id := range ids {
logoffSession(id)
}
}
func logoffSession(sessionID uint32) {
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)
}
// sessionsForUser returns every session whose logged-in user matches.
func sessionsForUser(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 nil, fmt.Errorf("WTSEnumerateSessionsW: %v", err)
}
defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pInfo)))
want := strings.ToLower(user)
var ids []uint32
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 {
ids = append(ids, info.SessionID)
}
}
if len(ids) == 0 {
return nil, fmt.Errorf("no active session for user %q", user)
}
return ids, nil
}
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")
}