New cmd/icon-gen renders the four state badges (Red/Yellow/Green/Blue) as a
rounded-square badge with a subtle vertical gradient and a crisp white theta,
256px with 4x4 supersampling. The tray embeds these (icons.go, generated) and
builds a proper multi-size Windows ICO (16..256) via exact box filtering --
replacing the old flat 48px circle and nearest-neighbour scaling.
The installer now bundles theta-agent.ico (multi-size, Blue badge) and uses it
for the Start menu 'Theta Agent Tray' shortcut, the setup.exe icon
(SetupIconFile), and the uninstaller display icon. Dead duplicate icon arrays
in the root package (tray_icons.go) removed.
installer.iss had #define MyAppVersion "2.1.0", so every release's setup.exe
(and its AppVersion/VersionInfoVersion) self-identified as 2.1.0 regardless of
the actual tag. setup-build-env.ps1 now passes /DMyAppVersion derived from
GITHUB_REF_NAME (CI tag run) or git describe, falling back to a 0.0.0-dev
placeholder in the .iss only for bare manual compiles.
Caught by running the actual test suite on Linux (go test ./...) after
pulling v2.2.0 -- local_route_test.go is a shared cross-platform suite
exercising the same "already exists" / "not found" tolerance for both
platforms' addHostRoute/delHostRoute, but local_route_windows.go had the
tolerance checks and local_route_unix.go didn't:
- addHostRoute (unix): had no "already exists" check at all. `ip route
replace` is idempotent in real usage so this rarely bites in practice,
but the implementation should match its own test coverage rather than
relying on that.
- delHostRoute (unix): checked "no such process"/"cannot find" but not
"route not found", the fixture text the shared test uses (and a real
message some `ip route del` failures produce).
Both now mirror local_route_windows.go's tolerance logic. go test ./...
passes on Linux again.
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.
v2.1.2's release CI failed on the Windows build leg: TestApplyHostsOverride_*
call applyHostsOverride(), which correctly refuses unconditionally on
non-Linux (hosts_override.go) -- but the tests didn't account for `go
test ./...` running on every platform the CI matrix builds for, only
Linux. Skip them on non-Linux with a clear reason instead.
Adds the CHANGELOG entry for this pass's mDNS work (local_discovery.go,
hosts_override.go -- see prior commit for the two real bugs found via
live testing), bumps AgentVersion, and documents prefer_local_directory
in agent.yml.example.
Also backfills CHANGELOG entries for v2.1.0/v2.1.1 (Windows agent,
WireGuard client, installer, CI), which were tagged and released but
never documented here.
Implements the Linux half of AGENT_LOCAL_DISCOVERY_SPEC.md: when a local
theta-gateway/theta-proxy announces itself via mDNS as fronting this
agent's ServerURL host, skip the relay/WAN path and talk to it directly.
Off by default (config.PreferLocalDirectory / prefer_local_directory)
since it changes host name resolution.
- local_discovery.go: polls for _theta-suite._tcp every 30s via
hashicorp/mdns, matches the TXT "hosts" field against the agent's own
target host, applies/clears a hosts-file override on change. Presence/
absence of the mDNS announcement IS the "on this LAN or not" signal --
no separate network detection needed, since multicast doesn't cross
routers/VLANs.
- hosts_override.go: writes a single marked, idempotent block into
/etc/hosts (never touches anything else in the file); clearing removes
the block entirely rather than leaving empty markers.
- HARD RULE preserved: this only ever changes DNS resolution, never TLS
trust -- nothing here touches certificate validation, so a spoofed
rogue mDNS announcement produces a TLS failure against the real
hostname's cert, not a silent MITM.
Verified end-to-end with real containers (Node mDNS announcer + this
actual Go binary, not mocked), which caught two real bugs neither showed
up in code review:
1. mdns.Lookup()'s DefaultParams() requests both IPv4 and IPv6. The
underlying client sends the v4 query (which got a real, valid
response per a packet capture), then sends the v6 query, and if THAT
send fails (no IPv6 route -- common on plain v4 hosts/containers) the
whole Query() returns that error synchronously, before ever entering
the response-listening loop. The v4 response was silently discarded.
Fixed by building QueryParam manually with DisableIPv6: true instead
of using the Lookup() convenience wrapper.
2. The original hosts-file writer used write-tmp-then-rename for
atomicity. /etc/hosts is frequently a bind mount (every container
runtime does this) -- rename() onto a bind-mounted file fails with
EBUSY, since you can't atomically replace a mountpoint. Switched to
truncate-and-rewrite in place; the process-local mutex already
serializes writers, so the lost atomicity is a small, acceptable
tradeoff against a confirmed hard failure.
Full cycle verified: announcer starts -> agent discovers it -> hosts
override applied -> announcer stops -> override cleanly reverts, no
stale entry, no discovery trace left.
Windows/macOS remain unbuilt -- need platform-native testing this
environment can't do (see AGENT_LOCAL_DISCOVERY_SPEC.md §3's open
question: hosts-file edits vs. a local stub resolver, per-OS elevation
and DNS-cache behavior).
Fresh-install bug report fixes:
- Silent installs wrote server_url: '' -- CurPageChanged fires even when the
wizard is walked programmatically in silent mode, so it read the empty edit
boxes and clobbered the /SERVER_URL /JOIN_KEY command-line params. Guard the
read with WizardSilent() so silent installs keep the params and interactive
installs keep the wizard values. (This is also why the service exited on
first connect and the Directory showed the agent as 'v2.0.0': the agent never
connected, so the server fell back to its placeholder version.)
- The tray post-install launch had skipifsilent, so a silent install (the
common path from the Directory's Windows command) never started the tray.
Removed it -- the tray starts in silent installs too.
- theta-agent update (cli.go) downloaded from the SSO /resources path, which no
longer serves binaries (they are GitHub release artifacts now) -- 404.
Pointed it at releases/latest/download via releaseAssetURL.
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.
Per decision: no binaries committed to the repo; everything is built on GitHub
Actions and hosted as release artifacts (releases/latest/download/<artifact>).
- .github/workflows/release.yml: matrix builds the agent for
linux(amd64/arm64/armv7), windows(amd64/arm64), darwin(amd64/arm64); tray for
linux/windows; helper for windows; the fully-offline Inno setup.exe compiles on
a windows runner via scripts/setup-build-env.ps1 -SkipGo -Build -CI. The
publish job merges everything, writes SHA256SUMS, optionally signs with Azure
Trusted Signing (secret-gated), and attaches to the tag's release.
- Replaces build-windows.yml (removed) — one release pipeline for all platforms.
- Untracks the committed dist binaries (they stay gitignored for local dev and
are produced by CI now).
- DESIGN-WINDOWS.md §9 updated: consumers (install.sh, the SSO modal) download
from GitHub release artifacts; SSO may mirror them into /resources for air-gap.
fyne.io/systray requires .ico content on Windows, and LoadImage cannot read
PNG-in-ICO. The embedded icons are PNG, so SetIcon always failed and no tray
icon ever appeared. pngToIco decodes the PNG and re-encodes classic BMP
(XOR + AND mask) entries at 16/32/48px — the format LoadImage has always
supported. Verified: the 'unable to set icon' error is gone and the tray
process stays up.
feat(installer): wizard page, Start Menu icons, silent install params
- Wizard page asks for the SSO Manager URL + join key, with an 'Open SSO
install-agent page' button (ShellExec); values feed agent.yml
- /SERVER_URL /JOIN_KEY /AUTH_TOKEN /PUBLIC_KEY and /B64_CONFIG (base64 of a
full agent.yml, decoded by an inline B64Decode) drive the same result in
/SILENT mode, so the SSO's Install Agent modal can emit one Windows command
- [Icons]: Theta Agent Tray / Open Agent Config / Uninstall in Start Menu
- WireGuard client launches its UI at the end of the MSI; taskkill after the
msiexec step closes it (the tunnel is agent-managed)
- tray starts right after install (not just at next logon)
- Inno 7 fixes: controls parent to Page.Surface, SaveStringsToUTF8FileWithoutBOM
Adds pngToIco structure tests (valid ICO dir + 32bpp DIBs) and a platform
passthrough test for toWindowsIcon.
Rebuilds all tracked dist binaries + the setup.exe.
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.
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).
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.
The tray IPC socket was hardcoded to /run/theta/tray.sock and /tmp/theta-tray.sock,
which cannot be bound on Windows (they resolve to C:\run\... and C:\tmp\... and need
admin rights). The daemon now binds a Unix socket under the per-user temp dir on
Windows, and the tray companion dials the same path.
The tray also exited immediately on Windows because it checked DISPLAY/WAYLAND_DISPLAY,
which are never set there. That graphical-session guard is now Windows-only; the tray
always runs on Windows.
- 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>
theta-suite's setup.sh installs the committed theta-agent-linux-amd64
rather than building from source, so a stale binary means the fix in this
repo never reaches the host.
The v1.5.0 binary predated join-key support: setup.sh would write a
join_key into agent.yml that the running agent did not understand, and it
would have looped on "close 4001: Unauthorized" -- the same trap the
v1.3.0 heartbeat fix hit.
Co-Authored-By: Claude Opus 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.