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.
This commit is contained in:
2026-08-09 16:13:13 -07:00
parent 565a171797
commit 7a6eb84d36
+263
View File
@@ -0,0 +1,263 @@
# 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>`; `status``sc.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_session``LockWorkStation` — must run in the interactive session → **helper**.
- `display_off``SC_MONITORPOWER` broadcast — needs a window station → **helper**.
- `logout_user``logoff.exe <session-id>` via `WTSEnumerateSessions` — service can do it.
- `sleep_host``SetSuspendState` (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 /bind`s 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`)
```yaml
# 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.