Completes the mDNS local-discovery feature on Windows (was Linux-only since
v2.1.2). Three parts:
1. Windows hosts override (hosts_override_windows.go): %SystemRoot%...\\hosts,
CRLF-aware read/write, ipconfig /flushdns after each change. The agent runs
as a SYSTEM service so elevation is a non-issue. hosts_override.go split
into shared rewrite logic + platform files; the hosts tests now run the real
Windows write path on CI instead of skipping.
2. Local route pinning (local_route*.go): the hosts override only fixes name
resolution -- the packet path is the routing table's job. If the WG mesh
tunnel is up with AllowedIPs covering the LAN (or full-tunnel 0.0.0.0/0) it
swallows the direct connection. Discovery now pins a /32 host route via the
owning local interface (route.exe metric 1 on Windows, ip route replace on
Linux) and drops it on revert. Closes a gap in the shipped Linux path too.
3. Prompt reconnect: apply/revert signals the WS loop so it reconnects
immediately instead of waiting out the 5s backoff.
Route/hosts code is injectable + unit tested; go test passes natively on
Windows (this machine), and linux/amd64 + windows/arm64 cross-builds are clean.
User-reported install fixes:
- Branding: every user-facing 'SSO Manager' string now says 'Theta Directory'
(agent logs, CLI usage, agent.yml.example, installer wizard).
- Wizard page: the URL/join-key text boxes were never shown. The layout used
Surface.Width (0 at wizard init) instead of SurfaceWidth and combined
WordWrap with AutoSize (mutually exclusive in VCL). Rewritten with the
canonical Inno pattern (SurfaceWidth + ScaleY + explicit label height).
- No console window after install: the tray and helper now build as
GUI-subsystem binaries (-H=windowsgui) in build_all.sh and
scripts/setup-build-env.ps1. The agent stays a console app for foreground
debugging (as a service it never shows a console).
- The daemon never came up after install: install-service now starts the
service immediately, so the tray IPC socket exists right away and the tray
connects instead of logging 'actively refused' until a reboot.
Verified: go build/vet/test green; tray+helper PE subsystem = GUI (2), agent =
console (3); installer compiles; tray runs silently.
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).
First Windows parity milestone (DESIGN-WINDOWS.md §13 build order item 1).
- Add a PlatformOps abstraction so command dispatch is OS-neutral:
- linuxPlatformOps keeps today's systemctl/journalctl/bash behavior (deliberately
untagged so shared dispatch tests run on Windows CI)
- windowsPlatformOps maps reboot/shutdown to shutdown.exe, service control to
sc.exe (stop+start for restart), fetch_logs to Get-WinEvent, arbitrary_bash to
powershell -EncodedCommand (byte-exact under arbitrary quoting), and declines
configure_ldap (Windows logon goes through OpenCredential)
- Run theta-agent as a Windows service (x/sys/windows/svc): SYSTEM auto-start,
SCM stop/shutdown handling; CLI install-service/remove-service via svc/mgr
- Add theta-agent-helper (session-0 companion): lock/display_off/logout via
user32/wtsapi32, and staged self-update (wait for service stop, swap the
locked exe, sc start)
- Self-update becomes platform-aware: Linux renames over the running binary;
Windows stages .new and hands the swap to the helper (running exe is locked)
- Platform paths: agent.yml and tray.sock under %ProgramData%\Theta42 (the
service runs as SYSTEM while the tray runs as the user, so the per-user temp
dir no longer works for tray IPC); LDAP byte-pump falls back to TCP loopback
- config: service_name, desktop_helper, public_ip_detect (air-gap: skips
external public-IP lookups in telemetry + home monitor), wireguard block
- cli: platform-aware config path + self-update artifact name + service restart
- tests: dispatch tests pin linuxPlatformOps; 0600 mode assertions gated to
POSIX so the suite is green on Windows
Rebuilds all tracked dist binaries (v2.1.0).
- tray_icons.go / cmd/theta-agent-tray: Renders iconic Theta 42 logo in status colors:
- 🔴 Red: Not connected to directory
- 🟡 Yellow: Connected to directory, but not on home LAN
- 🟢 Green: Connected to directory on home LAN
- 🔵 Blue: Connected to directory with active WireGuard tunnel
- home_detect.go: Compares agent public IP with home site public IP
- tray_server.go / tray_ipc.go: Unix socket IPC daemon server (/run/theta/tray.sock or /tmp/theta-tray.sock)
- cmd/theta-agent-tray: Desktop GUI binary with system tray menu (Auto-connect toggle, Connect/Disconnect VPN)
- Auto-detects DISPLAY / WAYLAND_DISPLAY environment variables
See CHANGELOG.md for the full breakdown. Summary:
- ldap_tunnel.go: serves a local unix socket for SSSD/PAM and relays raw
bytes to the SSO over the existing WSS channel (ldap_tunnel messages);
the agent never parses LDAP (DESIGN.md §4). Adds safeWriter to
serialize WebSocket writes now that telemetry, heartbeat, the LDAP
tunnel, and command responses all share one connection.
- secrets.go: renders local templates ({{ bao "path#key" }} placeholders)
by fetching node-scoped values from the SSO and writing the target
atomically at 0600, on a signed render_secrets command (DESIGN.md §5).
demo/ has minimal bash + Node consumers of the rendered file.
- iam.go: applies signed node IAM pushes -- sudoers.d rules (visudo -c
validated), SSH AuthorizedKeysCommand keys, /etc/security/access.conf,
and revocation via sss_cache -E + pkill -u (DESIGN.md §6).
- Capability reporting: the agent's enabled capabilities ride along in
its discovery frame so the SSO can show them in the Directory.
- DESIGN.md: the v2 protocol design this implements.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Installing the agent with one key is now all it takes to add a host.
New `join_key` config field, presented while auth_token is empty. The SSO
exchanges it for this agent's own token and the public key it must pin,
both delivered in the config frame; the agent persists them and blanks
the join key. Nothing has to be copied between two machines by hand.
PersistEnrollment rewrites only the credential lines -- line-based rather
than a YAML round-trip -- so operator comments, the capability matrix and
formatting survive. It re-reads afterwards so the credential is live
without a restart, and keeps the file 0600.
The connect URL carries ?hostname= so a self-enrolling host is named
after itself, and the agent refuses to connect at all (with a long
back-off) when it has no credential rather than presenting an empty one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements protocol v1.2.0.
verifySignature() returned true when no public_key was configured,
logging "skipping signature verification". The SSO installer never wrote
a public_key, so a default install executed reboot, service_restart,
configure_ldap, arbitrary_bash and update_binary UNVERIFIED from anything
that could reach its socket. An agent that cannot verify now refuses.
Canonicalization also disagreed with the server. Go's encoding/json
escapes <, > and & by default; JSON.stringify does not. Any payload
containing them hashed differently on each side and failed verification
-- for arbitrary_bash that is most real scripts (`>` redirection, `&&`).
Now uses json.Encoder with SetEscapeHTML(false), trailing newline
trimmed.
The SSO now rejects tokens it did not issue. Handles its close codes
(4001/4002/4003/4004) and backs off 5 minutes on an enrollment failure
instead of retrying every 5s forever. The connect log no longer prints
the URL, which carried ?token=.
install.sh gains --public-key and warns when none is configured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prebuilt theta-agent-linux-amd64 was built before the v1.3.0 heartbeat_ack
fix, so the installed agent still logged 'Unknown command type: heartbeat_ack'.
Rebuild it with the fix; add a test asserting heartbeat_ack is silently ignored
(no response, no command, no log).
The server replies to the agent's own heartbeat with heartbeat_ack; the agent
had no case for it, so it fell through to the unknown-command handler, logged
'Unknown command type: heartbeat_ack' every minute, and answered with a spurious
error response. heartbeats are fire-and-forget acks — nothing to run, nothing to
reply.