Files
theta-agent/DESIGN-WINDOWS.md
T
wmantly 7a6eb84d36 docs(design): add DESIGN-WINDOWS.md for the Windows agent
Covers full Windows parity for the theta-agent: remote operations executors,
Windows service layout, WireGuard mesh client (WS-pushed config), LDAP directory
logins via a vendored OpenCredential credential provider, enriched tray,
fully-offline Inno installer, and GitHub Actions + Azure Trusted Signing build
with the SSO holding all release resources. Optimized for air-gapped deployment.
2026-08-09 16:13:13 -07:00

14 KiB

theta-agent on Windows — design

Status: planning / design draft. Companion to DESIGN.md, which describes the Linux-first agent. This document covers bringing the agent to Windows as a first-class platform, optimized for air-gapped deployment: everything the host needs ships in one installer, and nothing on the target machine requires internet access.

1. Goals

  1. Parity, not a port. Every capability Linux has, Windows has too: telemetry, remote operations (reboot/shutdown, service control, desktop control, arbitrary commands, logs, IAM, self-update), LDAP-backed directory logins, secrets, and a WireGuard mesh client. The capability matrix and Ed25519 signature model from DESIGN.md apply unchanged; only the executors change.
  2. One installer, fully offline. A single Inno Setup .exe installs everything the Windows host needs — agent service, tray, credential provider, WireGuard client, runtime dependencies. No downloads at install time and no internet calls at runtime.
  3. Single outbound connection preserved. The agent still dials one persistent WSS connection to the SSO. No inbound ports, no firewall rules.
  4. The SSO holds all resources. The Windows installer, loose binaries, and the self-update feed are served from the SSO, mirroring the existing /resources/theta-agent/... tree.

2. Current Windows compatibility status

The agent already compiles, runs, and enrolls on Windows. Validated against a live SSO (sso.suite.vm42.us):

  • go build for windows/amd64 succeeds; the agent enrolls via join key, persists its issued token + the SSO's Ed25519 public key to agent.yml, and streams discovery / telemetry over WSS.
  • Unix-domain sockets work on Windows (Go ≥ 1.18, needs Win10 1803+); the LDAP byte-pump and tray IPC both bind and accept connections under the per-user temp dir.
  • Two unit tests assert POSIX 0600 modes that Windows does not implement; they are the only failures and are not runtime defects (go test gate must be made Windows-aware).

Changes already merged

  • Tray IPC socket paths are platform-aware. Linux keeps /run/theta/tray.sock + /tmp/theta-tray.sock; Windows uses a Unix socket under the per-user temp dir (tray_ipc.go). The tray companion dials the same path.
  • The tray no longer exits on Windows. The DISPLAY/WAYLAND_DISPLAY graphical session guard is now non-Windows only (cmd/theta-agent-tray/main.go).

3. Process & services layout

Component Runs as Purpose
theta-agent Windows service (SYSTEM, auto-start) WSS to SSO, command dispatch, telemetry, LDAP byte-pump, loopback /bind, IAM, WG control
theta-agent-tray Per-user, logon autostart Status icon, enrollment dialog, VPN control, auto-VPN
theta-agent-helper (new) Per-interactive-session, spawned by the service Desktop controls that cannot run from session 0
OpenCredential CP DLL Loaded by LogonUI.exe (Winlogon) LDAP-backed Windows logon
WireGuardTunnel$<name> Service installed by WireGuard client Mesh tunnel

The service is the lynchpin: it is up before any user session, which is what lets the credential provider validate logins at Ctrl+Alt+Del.

4. Remote operations (full parity)

Command dispatch, capability gating, and Ed25519 verification in websocket.go are platform-neutral. A executor_windows.go (plus a small executor_linux.go containing today's Linux mapping) selects the underlying implementation.

Command Linux (today) Windows
reboot reboot shutdown.exe /r /t 0
shutdown shutdown -h now / poweroff shutdown.exe /s /t 0
service_restart systemctl restart <svc> sc.exe stop/start <svc> (allowed-list unchanged)
systemd_action systemctl <action> <svc> sc.exe <action> <svc>; statussc.exe query
arbitrary_bash bash -c <script> powershell -NoProfile -Command <script>
fetch_logs journalctl -u <svc> Get-WinEvent -LogName <svc logs> (application/system log for the service)
configure_ldap write /etc/sssd/sssd.conf, restart sssd N/A on Windows — see §6
render_secrets render templates atomically identical (Go is cross-platform)
iam_apply sudoers.d, authorized_keys, access.conf local groups + Windows OpenSSH authorized_keys (see below)
update_binary download → verify SHA256 → rename download → verify → staged rename (running exe is locked; write .new, stop service, replace, start)
reboot-gated desktop ops loginctl/xset see desktop control below

Desktop control (session 0 problem)

A SYSTEM service runs in session 0, which has no interactive desktop. Ops that need an interactive session must run in the user's session:

  • lock_sessionLockWorkStation — must run in the interactive session → helper.
  • display_offSC_MONITORPOWER broadcast — needs a window station → helper.
  • logout_userlogoff.exe <session-id> via WTSEnumerateSessions — service can do it.
  • sleep_hostSetSuspendState (needs SeShutdownPrivilege) — service can do it.

The service launches the helper in the interactive session (CreateProcessAsUser / WTS), passing the action as an argument; the helper performs the op and exits. The helper is installed by the installer and is a tiny self-contained exe.

IAM on Windows

iam_apply semantics map to local-account security:

  • allowed_login_groups → local groups (create/ensure membership on the mapped local accounts), consumed by OpenCredential authorization rules.
  • ssh_keys → per-user %ProgramData%\ssh\administrators_authorized_keys or per-profile .ssh\authorized_keys depending on OpenSSH server configuration.
  • revoke_users → terminate sessions (logoff <id> / rwinsta) and disable the mapped local account.
  • sudo_rules → no direct equivalent; mapped to local group membership / UAC elevation policy, or ignored with a logged warning.

5. WireGuard mesh client

Server side (jump-host) — already complete

jump-host mints peers and exit sites, renders standard wg0.conf (nodejs/utils/wg_conf.js), and serves /api/wireguard/peers/:id/conf + QR. The generated config is compatible with the WireGuard Windows client.

Agent side — to build

  1. Delivery: new signed command wireguard_apply pushed over the existing WSS channel (same model as iam_apply): server includes the peer conf; agent verifies the Ed25519 signature, persists it, and applies it. wireguard_remove tears it down. No new outbound ports or HTTP endpoints.
  2. Apply/teardown (Windows): official WireGuard client, bundled:
    • install: wireguard.exe /installtunnelservice "<name>" <conf>
    • remove: wireguard.exe /uninstalltunnelservice "<name>"
    • (Linux would use wg-quick up|down.)
  3. State detection: poll sc.exe query "WireGuardTunnel$<name>" (or adapter existence) → set vpn_active → tray turns blue; drives the existing auto-VPN logic in home_detect.go.
  4. Auto-VPN: when away-from-home and auto_vpn is set, bring the tunnel up; the tray checkbox now persists the preference to agent.yml (today it is memory-only).

6. LDAP: directory logins and the byte-pump

The byte-pump tunnel (already Windows-portable)

The LDAP tunnel is a pure byte pump (ldap_tunnel.go) and is transport-agnostic. On Windows it binds 127.0.0.1:389 (fallback 3890) — the same TCP loopback path Linux falls back to. Gated by the existing ldap_tunnel / configure_ldap capability flags.

Windows logon via OpenCredential (vendored pGina fork)

Windows has no native OpenLDAP logon (AD only). The plan is the credential-provider pattern, using OpenCredential, a maintained BSD-3 fork of pGina (github.com/pedropablobm/OpenCredential), vendored as a submodule and built from source in CI:

LogonUI (Secure Desktop)
   │  OpenCredential LDAP auth plugin
   ▼
127.0.0.1:389  ──►  theta-agent byte-pump  ──►  WSS (ldap_tunnel)  ──►  SSO  ──►  OpenLDAP bind
  • Zero custom CP code. OpenCredential's LDAP auth plugin points at 127.0.0.1:389 (simple bind, TLS off). The installer pre-seeds its plugin config (server, username and group attributes matching the OpenLDAP schema) so logon works unattended.
  • Local-account bridge. OpenLDAP cannot mint a Windows token; OpenCredential performs the standard bridge — validate against LDAP, then log into a mapped local account (auto-provisioned on first logon), applying LDAP group membership to local groups.
  • Offline cache. OpenCredential ships a SQLite offline auth cache, which addresses first-boot / SSO-unreachable logon.
  • Security note. The password crosses loopback as a plaintext LDAP simple bind, then rides the already-TLS WSS tunnel; the agent never parses or stores it. Loopback-only listener, no inbound exposure.

Alternative (documented, not chosen)

A thin managed .NET OpenCredential plugin that POST /binds to an agent loopback HTTP endpoint (mirroring DESIGN.md §3's POST /api/v1/ldap/bind), giving the agent control of the transport and caching. Kept as a fallback if the LDAP plugin's TLS expectations prove inflexible.

7. Tray companion (enriched)

cmd/theta-agent-tray gains:

  • Enrollment dialog — server URL + join key entry; writes agent.yml (or a user-scoped config) and signals the service to reload.
  • Status panel — connection state, home/away, VPN, public IP.
  • VPN control — connect/disconnect, auto-VPN checkbox now persisted.
  • Rendered with the existing systray stack (fyne.io/systray supports Windows natively).

8. Installer (Inno Setup, fully offline)

One .exe, built on a connected machine, runnable on an air-gapped one. Bundles:

  • theta-agent-windows-amd64.exe (+ arm64) and theta-agent-tray-windows-amd64.exe
  • theta-agent-helper.exe (desktop-control helper)
  • OpenCredential CP binaries + VC++ v14 redistributable (its native runtime; .NET Framework 4.8 is built into Windows 10/11 so needs no bundle)
  • The official, vendor-signed WireGuard for Windows client (signed drivers install offline without signature phone-home)
  • OpenCredential's pre-seeded LDAP plugin config
  • Agent agent.yml template + self-signed CP cert installed into the machine trusted root

Install-time behavior:

  • /SILENT supported; SERVER_URL= and JOIN_KEY= as install parameters (the SSO's "Install Agent" modal emits the Windows command instead of the bash one).
  • Creates the theta-agent service (SYSTEM, auto-start), registers the tray for logon autostart, registers the CP with Winlogon, installs the WireGuard service components.
  • Writes agent.yml; a blank join key leaves enrollment to the first service start.

9. Build & release (GitHub Actions + Azure Trusted Signing)

  • Local dev builds are self-signed / unsigned.
  • Production builds run in GitHub Actions:
    • Build matrix: agent windows-amd64/arm64, tray, helper, OpenCredential CP (MSBuild/.NET 4.8), then the Inno installer.
    • Azure Trusted Signing signs the agent, tray, helper, CP DLL, and installer (workflow federated identity → AzureSignTool). Authenticode chains verify offline, which suits air-gap; SmartScreen reputation simply won't accumulate, which is expected.
    • Release tags (e.g. v2.0.1) name the artifacts; SHA256SUMS manifest is generated.
  • The SSO holds all resources (client installer is the deliverable; the server stack is assumed to already run inside the air-gap):
    • New resource tree: /resources/theta-agent/windows/... for the installer, loose binaries, and SHA256SUMS.
    • Release workflow uploads artifacts (admin-gated publish endpoint or mounted resource dir); SSO pins a "latest" pointer.
    • Self-update feed uses the existing signed update_binary flow; the agent's fetch is made platform-aware (currently cli.go hardcodes the Linux artifact name).

10. Air-gap considerations

  • No internet calls at runtime. The only external-internet dependency in the agent today is public-IP detection (telemetry.go, home_detect.go hit ipify/icanhazip etc.); on an air-gapped host these fail and degrade silently (accepted behavior). "Home/away" tray logic is meaningless without a public IP and must not flap.
  • Self-update is LAN-only (SSO serves the binary), so it works inside the air-gap.
  • All runtime deps bundled: VC++ redist, WireGuard client + driver, CP + cert, .NET 4.8 is OS-built-in.
  • No SmartScreen/driver phone-home for bundled vendor-signed components.

11. Configuration additions (agent.yml)

# Windows-specific
service_name: theta-agent            # windows service name
desktop_helper: "C:\\Program Files\\Theta42\\theta-agent-helper.exe"
public_ip_detect: true               # false disables external lookups (air-gap)
wireguard:
  tunnel_name: theta-mesh
  conf: "C:\\Program Files\\Theta42\\wg\\theta-mesh.conf"
# OpenCredential plugin config is managed by the installer, not agent.yml

12. Open questions

  1. Desktop-control helper: one exe for all ops vs. per-op; interaction with multiple interactive sessions.
  2. IAM sudo_rules on Windows: local-group mapping vs. explicit no-op.
  3. Self-update of the service binary on Windows: staged rename requires the service to stop; whether the service restarts itself or defers to the tray.
  4. OpenCredential: whether the LDAP plugin's schema expectations (username/group attributes) match the suite's OpenLDAP without a small config-only shim.
  5. Air-gap first-boot logon: rely on OpenCredential's offline cache vs. pre-seeding local accounts at install time.

13. Build order

  1. executor_windows.go (remote ops) + service wrapper — unlocks everything.
  2. Tray enrichment + enrollment dialog + auto-VPN persistence.
  3. WireGuard client (wireguard_apply/remove + state).
  4. LDAP byte-pump on Windows (already portable; gate + verify).
  5. Inno installer bundling the above; CI + Azure signing; SSO resource tree.
  6. OpenCredential submodule + plugin config + logon validation.