The Windows CI/CD story is now one idempotent script that both local dev and
GitHub Actions run, so they cannot drift.
scripts/setup-build-env.ps1:
- idempotent: skips anything already present/valid; safe to re-run (verified
no-op on second run)
- Go toolchain: pinned 1.22.2, user-space zip extract, no admin; accepts >= 1.22
- Inno Setup: pinned 7.0.2 from jrsoftware GitHub release, per-user install
(/CURRENTUSER, no admin), sha256-verified installer
- vendor assets: fetches WireGuard MSI, VC++ redist, OpenCredential CP into
installer/windows/vendor/ and verifies each against the pinned manifest;
writes .sha256 sidecars
- -Build: builds agent/tray/helper for windows amd64+arm64, runs go test, and
compiles the installer; -CI: fail loudly for workflows
installer/windows/vendor-manifest.json: pinned urls + sha256 for all three
third-party assets and the toolchain; nothing large is committed to git
(installer/windows/vendor/ is gitignored).
installer/windows/installer.iss: bundle + silently install the real
OpenCredential installer (Inno-built -> /VERYSILENT) and read /SERVER_URL and
/JOIN_KEY via {param:...}; validated by compiling with Inno Setup 7.0.2.
.github/workflows/build-windows.yml now delegates the entire build to the script
(setup-go + setup-build-env.ps1 -SkipGo -Build -CI), then hashes, optionally
signs with Azure Trusted Signing, attaches to the release, and publishes to the
SSO resource tree.
Built locally: dist/theta-agent-2.1.0-windows-amd64-setup.exe (63MB fully
offline bundle) plus windows amd64/arm64 agent, tray, and helper, all verified.
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
- 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.mdapply unchanged; only the executors change. - One installer, fully offline. A single Inno Setup
.exeinstalls 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. - Single outbound connection preserved. The agent still dials one persistent WSS connection to the SSO. No inbound ports, no firewall rules.
- 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 buildforwindows/amd64succeeds; the agent enrolls via join key, persists its issued token + the SSO's Ed25519 public key toagent.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
0600modes that Windows does not implement; they are the only failures and are not runtime defects (go testgate 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_DISPLAYgraphical 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_MONITORPOWERbroadcast — needs a window station → helper.logout_user→logoff.exe <session-id>viaWTSEnumerateSessions— service can do it.sleep_host→SetSuspendState(needsSeShutdownPrivilege) — 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_keysor per-profile.ssh\authorized_keysdepending 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
- Delivery: new signed command
wireguard_applypushed over the existing WSS channel (same model asiam_apply): server includes the peer conf; agent verifies the Ed25519 signature, persists it, and applies it.wireguard_removetears it down. No new outbound ports or HTTP endpoints. - 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.)
- install:
- State detection: poll
sc.exe query "WireGuardTunnel$<name>"(or adapter existence) → setvpn_active→ tray turns blue; drives the existing auto-VPN logic inhome_detect.go. - Auto-VPN: when away-from-home and
auto_vpnis set, bring the tunnel up; the tray checkbox now persists the preference toagent.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/systraysupports 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) andtheta-agent-tray-windows-amd64.exetheta-agent-helper.exe(desktop-control helper)- OpenCredential CP installer + 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.ymltemplate + self-signed CP cert installed into the machine trusted root
The build environment is bootstrapped idempotently by
scripts/setup-build-env.ps1 (pinned Go version, per-user Inno Setup install, and
vendor assets fetched into installer/windows/vendor/ — verified against the pinned
sha256 in installer/windows/vendor-manifest.json). The CI workflow calls the same
script, so a local build and CI/CD cannot drift.
Install-time behavior:
/SILENTsupported;SERVER_URL=andJOIN_KEY=as install parameters (the SSO's "Install Agent" modal emits the Windows command instead of the bash one).- Creates the
theta-agentservice (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;SHA256SUMSmanifest is generated.
- Build matrix: agent
- 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, andSHA256SUMS. - 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_binaryflow; the agent's fetch is made platform-aware (currentlycli.gohardcodes the Linux artifact name).
- New resource tree:
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.gohit 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
- Desktop-control helper: one exe for all ops vs. per-op; interaction with multiple interactive sessions.
- IAM
sudo_ruleson Windows: local-group mapping vs. explicit no-op. - 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.
- OpenCredential: whether the LDAP plugin's schema expectations (username/group attributes) match the suite's OpenLDAP without a small config-only shim.
- Air-gap first-boot logon: rely on OpenCredential's offline cache vs. pre-seeding local accounts at install time.
13. Build order
executor_windows.go(remote ops) + service wrapper — unlocks everything.- Tray enrichment + enrollment dialog + auto-VPN persistence.
- WireGuard client (
wireguard_apply/remove+ state). - LDAP byte-pump on Windows (already portable; gate + verify).
- Inno installer bundling the above; CI + Azure signing; SSO resource tree.
- OpenCredential submodule + plugin config + logon validation.