e613874de5
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).
242 lines
6.6 KiB
Go
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")
|
|
}
|