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).
This commit is contained in:
2026-08-09 17:49:40 -07:00
parent 4a619f7adc
commit e613874de5
29 changed files with 920 additions and 22 deletions
+24 -10
View File
@@ -116,23 +116,32 @@ func sleepHost() {
}
}
// logoutSession logs off the active console session, or the session of the
// logoutSession logs off the active console session, or every session of the
// given user (matched by WTS session enumeration).
func logoutSession(user string) {
sessionID := activeConsoleSessionID()
if user != "" {
id, err := sessionForUser(user)
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)
}
sessionID = id
}
if sessionID == 0 {
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)
@@ -146,25 +155,30 @@ func activeConsoleSessionID() uint32 {
return uint32(id)
}
func sessionForUser(user string) (uint32, error) {
// 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 0, fmt.Errorf("WTSEnumerateSessionsW: %v", err)
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 {
return info.SessionID, nil
ids = append(ids, info.SessionID)
}
}
return 0, fmt.Errorf("no active session for user %q", user)
if len(ids) == 0 {
return nil, fmt.Errorf("no active session for user %q", user)
}
return ids, nil
}
func wtsSessionUsername(sessionID uint32) string {
+11 -1
View File
@@ -111,6 +111,8 @@ var (
mAutoVPN *systray.MenuItem
mVPNToggle *systray.MenuItem
mSeparator *systray.MenuItem
mOpenConfig *systray.MenuItem
mReinit *systray.MenuItem
mQuit *systray.MenuItem
currentStatus TrayStatus
@@ -130,7 +132,9 @@ func onReady() {
mAutoVPN = systray.AddMenuItemCheckbox("Auto-connect VPN when away", "Automatically connect to home via WireGuard when not on the home LAN", false)
mVPNToggle = systray.AddMenuItem("Connect VPN", "Manually connect or disconnect the WireGuard tunnel")
systray.AddSeparator()
mQuit = systray.AddMenuItem("Quit Tray", "Exit the tray icon (daemon keeps running)")
mOpenConfig = systray.AddMenuItem("Open Config", "Open agent.yml in the default editor")
mReinit = systray.AddMenuItem("Clear enrollment…", "Blank auth_token/public_key so the agent re-enrolls on reconnect")
mQuit = systray.AddMenuItem("Quit Tray", "Exit the tray icon (daemon keeps running)")
// ── IPC loop — connect with retry ──
go connectWithRetry()
@@ -156,6 +160,12 @@ func onReady() {
sendCmd(TrayCommand{Command: "vpn_connect"})
}
case <-mOpenConfig.ClickedCh:
sendCmd(TrayCommand{Command: "open_config"})
case <-mReinit.ClickedCh:
sendCmd(TrayCommand{Command: "reinit"})
case <-mQuit.ClickedCh:
systray.Quit()
}