Compare commits

28 Commits

Author SHA1 Message Date
wmantly 1837d18da8 release(v2.1.2): Linux mDNS local-discovery
release / agent-darwin-amd64 (push) Successful in 54s
release / agent-darwin-arm64 (push) Successful in 30s
release / agent-linux-amd64 (push) Successful in 29s
release / agent-linux-arm64 (push) Successful in 29s
release / agent-linux-arm7 (push) Successful in 28s
release / agent-windows-amd64 (push) Successful in 28s
release / agent-windows-arm64 (push) Successful in 28s
release / tray/helper/setup (push) Has been cancelled
release / Attach to GitHub release (push) Has been cancelled
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.
2026-08-10 19:02:00 -04:00
wmantly 9fdbb8aab0 feat(mdns): Linux local-discovery -- skip the WAN relay when on-site
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).
2026-08-10 17:54:39 -04:00
wmantly d937f8dcba fix(installer): silent install kept empty server_url; tray now starts; self-update uses GitHub releases
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.
2026-08-09 23:23:42 -07:00
wmantly 2c42960c61 Merge feat/windows-agent: Windows agent (platform ops, service wrapper, WireGuard, IAM, tray, installer, CI) 2026-08-09 20:40:51 -07:00
wmantly db6253e263 ci(release): sign only Windows PE files, hash after signing 2026-08-09 20:40:19 -07:00
wmantly 5013148ffe fix(installer): Theta Directory branding, visible URL/join-key fields, GUI tray, service autostart
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.
2026-08-09 20:33:06 -07:00
wmantly da158b0adc ci(release): dedupe windows agent exes (build-agent matrix owns them) 2026-08-09 19:46:56 -07:00
wmantly 98daa81a92 ci(release): build every binary on GitHub and attach to releases
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.
2026-08-09 19:40:12 -07:00
wmantly d18de7d109 test(tray): add pngToIco + toWindowsIcon structure tests
Overlooked when the tray dir was gitignored by theta-agent-*; the negation in
.gitignore now lets the test be tracked.
2026-08-09 19:07:12 -07:00
wmantly 016a8fc6b6 fix(tray): Windows tray icon now loads — PNG->ICO with BMP entries
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.
2026-08-09 19:06:00 -07:00
wmantly ba2a619db5 build(win): idempotent setup script + verified vendor manifest; real installer build
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.
2026-08-09 18:26:03 -07:00
wmantly e613874de5 feat(windows): WireGuard client, auto-VPN, IAM, tray enrichment, installer, CI
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).
2026-08-09 17:49:40 -07:00
wmantly 4a619f7adc feat(windows): platform ops, service wrapper, helper, and air-gap paths
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).
2026-08-09 17:10:07 -07:00
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
wmantly 565a171797 fix(tray): support Windows socket paths and always run the tray companion on Windows
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.
2026-08-09 16:11:42 -07:00
wmantly e13aa6a15c release(v2.0.1): rebuild binaries with correct version string 2026-08-09 15:35:06 -04:00
wmantly 775878437e release(v2.0.1): fix version CLI reporting, secrets access permissions, and tray companion autostart 2026-08-09 14:46:19 -04:00
wmantly a3a21c3884 docs(changelog): add v2.0.0 release notes (#12) 2026-08-09 00:15:00 -04:00
wmantly a423fec835 feat(telemetry): include logged_users and host_details in periodic stream and detect systemd logind user sessions (#11) 2026-08-09 00:09:23 -04:00
wmantly 37e7ffd25d fix(tray): render full-color badge disk icons from theta42.svg and fix home LAN detection for local servers 2026-08-08 23:29:44 -04:00
wmantly 16cab898ed feat(tray): update tray icons to use rasterized official theta42.svg asset 2026-08-08 23:27:29 -04:00
wmantly c676c658ed feat(tray): desktop tray icon companion with Theta logo, color-coded status, and home LAN detection (#10)
- 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
2026-08-08 23:16:20 -04:00
wmantly 34f6f685fc bump: version v2.0.0 and rebuild multiarch binaries (#9) 2026-08-08 21:33:32 -04:00
wmantly 2a4fb21440 docs: update README to reflect Theta Directory and Theta Suite 2.0 branding (#8) 2026-08-08 21:22:04 -04:00
wmantly efae05686e feat(telemetry): add hostnamectl system details, df -h device partition filtering, who logged-in users fallback, robust desktop controls, multiarch build (#7) 2026-08-08 19:35:43 -04:00
wmantly 57d5dfc174 release: v1.8.0 - Active Logged-in Users, Physical Disks & Desktop Operations 2026-08-08 18:12:16 -04:00
wmantly 4aa233f507 Merge release branch release-v1.7.0 2026-08-08 15:38:53 -04:00
wmantly 48248475c4 release: v1.7.0 - Linux ARM, Windows, and macOS theta-agent binaries 2026-08-08 15:38:53 -04:00
45 changed files with 4817 additions and 230 deletions
+143
View File
@@ -0,0 +1,143 @@
name: release
# Builds every theta-agent binary on GitHub and attaches them to the release as
# artifacts (DESIGN-WINDOWS.md §9, and the "host binaries as release artifacts"
# decision): the agent for linux/windows/darwin, the tray for linux/windows,
# the session helper for windows, and the fully-offline Inno setup.exe. Nothing
# binary is committed to the repo; consumers (install.sh, the SSO Install Agent
# modal) download from releases/latest/download/<artifact>.
#
# git tag v2.1.0 && git push origin v2.1.0
# -> builds all binaries, compiles the installer, attaches everything
on:
push:
tags: ["v*"]
workflow_dispatch:
env:
GO_VERSION: "1.22.2"
jobs:
build-agent:
name: agent-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
artifact: theta-agent-linux-amd64
- goos: linux
goarch: arm64
artifact: theta-agent-linux-arm64
- goos: linux
goarch: arm
goarm: "7"
artifact: theta-agent-linux-armv7
- goos: windows
goarch: amd64
artifact: theta-agent-windows-amd64.exe
- goos: windows
goarch: arm64
artifact: theta-agent-windows-arm64.exe
- goos: darwin
goarch: amd64
artifact: theta-agent-darwin-amd64
- goos: darwin
goarch: arm64
artifact: theta-agent-darwin-arm64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Build
shell: bash
run: |
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} GOARM=${{ matrix.goarm }} \
go build -ldflags="-s -w" -o dist/${{ matrix.artifact }} .
- name: Upload
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: dist/${{ matrix.artifact }}
if-no-files-found: error
build-desktop:
name: tray/helper/setup
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
# One idempotent script: installs per-user Inno Setup, fetches the
# checksum-verified vendor assets, builds agent/tray/helper for windows
# amd64+arm64, runs go test, and compiles the offline setup.exe.
- name: Build windows artifacts + installer
shell: pwsh
run: |
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/setup-build-env.ps1 -SkipGo -Build -CI
if ($LASTEXITCODE -ne 0) { throw "setup-build-env failed" }
- name: Upload windows desktop artifacts
uses: actions/upload-artifact@v4
with:
name: windows-desktop
path: |
dist/theta-agent-tray-windows-*.exe
dist/theta-agent-helper-windows-*.exe
dist/theta-agent-*-setup.exe
if-no-files-found: error
publish:
name: Attach to GitHub release
needs: [build-agent, build-desktop]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
# Optional Azure Trusted Signing (OIDC federation). Signs only Windows PE
# files (Authenticode applies to PE/MSI; the linux/darwin binaries are
# already self-identifying). Runs only when the Azure secrets exist;
# otherwise the artifacts ship unsigned.
- name: Sign with Azure Trusted Signing
if: env.AZURE_TENANT_ID != ''
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: AzureSignTool
if: env.AZURE_TENANT_ID != ''
uses: azure/trusted-signing-action@v0.4.0
with:
endpoint: ${{ secrets.AZURE_TS_ENDPOINT }}
trusted-signing-account-name: ${{ secrets.AZURE_TS_ACCOUNT }}
certificate-profile-name: ${{ secrets.AZURE_TS_CERT_PROFILE }}
files: |
dist/*.exe
# Hash AFTER signing so SHA256SUMS matches what ships.
- name: Generate SHA256SUMS
shell: bash
run: |
cd dist
sha256sum * | tee SHA256SUMS
- name: Attach to release
uses: softprops/action-gh-release@v2
with:
files: dist/**
fail_on_unmatched_files: false
+12
View File
@@ -0,0 +1,12 @@
# Binaries and build output
theta-agent
theta-agent-*
dist/
*.exe
agent.yml
!cmd/theta-agent-helper/
!cmd/theta-agent-helper/main.go
!cmd/theta-agent-tray/
!cmd/theta-agent-tray/main_test.go
# Fetched by scripts/setup-build-env.ps1, pinned in installer/windows/vendor-manifest.json
installer/windows/vendor/
+61
View File
@@ -5,6 +5,67 @@ All notable changes to the `theta-agent` daemon will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v2.1.2] - 2026-08-10
### Added
- **Linux mDNS local-discovery** (`local_discovery.go`, `hosts_override.go`) — when a `theta-gateway`/`theta-proxy` on the local network segment announces itself as fronting this agent's `server_url` host, the agent skips the relay/WAN path and talks to it directly. Opt-in via `prefer_local_directory` (off by default, since it changes host name resolution). Presence/absence of the mDNS announcement is the "on this LAN or not" signal — no separate network detection needed. Never touches TLS/certificate validation: this only ever changes *where* the agent connects, never *whether* it trusts what answers, so a spoofed rogue announcement produces a TLS failure, not a silent MITM.
- Companion piece to `theta-gateway`'s new mDNS announcer (`services/mdns_announce.js`, `theta-gateway` v2.1.0).
### Fixed (found via real two-container testing over live multicast, not by inspection)
- The naive `mdns.Lookup()` call requests both IPv4 and IPv6 by default; the underlying client sends the v4 query (which got a real, valid response, confirmed with a packet capture) and then the v6 query, and if the v6 send fails — no IPv6 route, common on plain v4 hosts/containers — the whole `Query()` call returns that error synchronously before the response-listening loop ever starts, silently discarding the already-received v4 response. Fixed by disabling IPv6 querying explicitly rather than depending on IPv6 being configured.
- The hosts-file writer used write-tmp-then-rename for atomicity; `/etc/hosts` is frequently a bind mount (every container runtime does this), and `rename()` onto a bind-mounted file fails with `EBUSY` — you cannot atomically replace a mountpoint. Switched to truncate-and-rewrite in place.
Windows/macOS local-discovery remain unbuilt — needs platform-native testing (hosts-file vs. stub-resolver tradeoff, elevation, DNS-cache behavior per OS) that wasn't available for this pass. See `theta-suite`'s `docs/AGENT_LOCAL_DISCOVERY_SPEC.md`.
## [v2.1.1] - 2026-08-10 (undocumented at the time; recorded retroactively)
### Fixed
- **Windows silent install** kept an empty `server_url`; the tray now actually starts after a silent install; self-update now uses GitHub releases instead of the prior mechanism.
## [v2.1.0] - 2026-08-10 (undocumented at the time; recorded retroactively)
### Added
- **Windows agent**: platform ops, Windows service wrapper, desktop helper, air-gap paths.
- **Windows WireGuard client**: auto-VPN (connect when away from home), IAM enrichment, tray integration.
- **Windows installer**: idempotent setup script, verified vendor manifest, GUI tray, Theta Directory branding, visible URL/join-key fields, service autostart.
- **CI**: builds every platform's binaries on GitHub and attaches them to releases; Windows PE files are signed (hash computed after signing, not before).
### Fixed
- Tray icon now loads correctly on Windows (PNG→ICO conversion was missing proper BMP entries).
- Tray companion supports Windows socket paths and always runs on Windows.
## [v2.0.1] - 2026-08-09
### Fixed
- **CLI version reporting**: Expose correct version string (`v2.0.0` / `AgentVersion`) dynamically via CLI command flags.
- **Secrets access for non-root users**: Configure `theta-secrets` and `theta` groups with appropriate directory permissions (`0750` / `0640` on `/etc/theta42/agent.yml`) to allow authorized non-root users to retrieve secrets.
- **Autostart Tray Icon Companion**: Install tray icon companion app and configure `/etc/xdg/autostart/theta-agent-tray.desktop` entry for desktop environments.
## [v2.0.0] - 2026-08-09
### Added
- **Full-Color Desktop System Tray Companion.** Built `theta-agent-tray` with full-color rasterized `theta42.svg` status badges (🔴 Disconnected, 🟡 Away/WAN, 🟢 Home LAN, 🔵 WireGuard Active) and Unix socket IPC.
- **Systemd Logind User Session Detection.** Updated `collectLoggedUsers()` to query `loginctl list-sessions --no-legend` so active Wayland/GDM/LightDM desktop sessions are captured.
- **Full Telemetry Field Preservation.** Preserved `logged_users` and `host_details` in periodic telemetry stream payloads.
## [v1.8.0] - 2026-08-08
### Added
- **Active Logged-in User Sessions.** Added `collectLoggedUsers()` gathering terminal sessions (`who` / `host.Users()`) reported in discovery and live telemetry payloads.
- **Full Physical Partition & Filesystem Collection.** Switched to `disk.Partitions(true)` to list all physical drives, ZFS pools, and mount points while filtering pseudo/virtual filesystems.
- **Desktop Control Operations.** Implemented `desktop_control` WebSocket actions supporting `lock_session` (`loginctl lock-sessions`), `logout_user` (`pkill -u <user>`), `display_off` (`xset dpms force off`), and `sleep_host` (`systemctl suspend`).
- **Binary Version Reporting.** Included `AgentVersion` (`v1.8.0`) in discovery and telemetry frames.
## [v1.7.0] - 2026-08-08
### Added
- **Multi-Architecture & Multi-OS Binaries.** Built cross-platform targets for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon M1/M2/M3/M4).
- **Cross-Compilation Pipeline (`build_all.sh`).** Automated Go build toolchain generating static binaries for all 7 target platforms.
- **Installer OS & Architecture Auto-Detection.** Updated `install.sh` to auto-detect `uname -s` and `uname -m` to download matching release binaries.
### Fixed
- **Systemd & Docker Command Dispatching.** Handled systemd actions (`start`, `stop`, `restart`, `reload`) and Docker container metrics/actions cleanly across Linux distributions.
## [v1.6.0] - 2026-08-07
### Added
+271
View File
@@ -0,0 +1,271 @@
# 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 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.yml` template + 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:
- `/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)
- **No binaries live in the repos.** Every artifact is built on GitHub Actions and
attached to the release (`releases/latest/download/<artifact>`) — see
`.github/workflows/release.yml`. Consumers download from there:
- `install.sh` (Linux) already fetches the agent/tray from `releases/latest/download/`.
- The SSO's Install Agent modal emits a PowerShell one-liner that downloads the
Windows `setup.exe` from `releases/latest/download/`.
- **Production builds** run in GitHub Actions:
- Matrix: agent for `linux`(amd64/arm64/armv7), `windows`(amd64/arm64), `darwin`(amd64/arm64);
tray for linux/windows; helper for windows.
- The fully-offline Inno installer compiles on a `windows-latest` runner via
`scripts/setup-build-env.ps1 -SkipGo -Build -CI` (which also runs `go test ./...`).
- **Azure Trusted Signing** optionally signs everything (workflow federated identity →
AzureSignTool, gated on secrets). Authenticode chains verify offline, which suits
air-gap; SmartScreen reputation simply won't accumulate, which is expected.
- Release tags (e.g. `v2.1.0`) name the artifacts; a `SHA256SUMS` manifest is attached.
- **Self-update** uses the existing signed `update_binary` flow; the agent's fetch is
platform-aware. On an air-gapped LAN the SSO can mirror the release artifacts into
`/resources/theta-agent/` at deploy time; nothing on the target host hits the internet.
## 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.
+2 -2
View File
@@ -1,8 +1,8 @@
# Theta Agent
Theta Agent is a unified endpoint management daemon for the theta42 stack. It replaces legacy bash installation scripts and one-way metric scripts with a powerful, 2-way Command & Control (C2) Go daemon.
Theta Agent is a lightweight, cross-platform host telemetry, secret delivery, and desktop control daemon for the [Theta Suite](https://github.com/theta42/theta-suite) ecosystem. Built in Go, it replaces legacy bash scripts and one-way metrics with a secure, 2-way WebSocket connection to **Theta Directory** (`theta-directory`).
The agent dials out to the central SSO Manager via a persistent WebSocket connection, enabling real-time telemetry, dynamic discovery, and secure remote operations.
The agent dials out over a single persistent outbound WebSocket connection (`wss://sso.example.com/api/agent/ws`), enabling real-time telemetry, hardware discovery, desktop session controls, and secret delivery.
## What you get
+38 -10
View File
@@ -1,5 +1,5 @@
# theta-agent configuration file
# Default location: /etc/theta42/agent.yml
# Default location: /etc/theta42/agent.yml (Linux) or %ProgramData%\Theta42\agent.yml (Windows)
server_url: "https://sso.example.com"
@@ -26,33 +26,61 @@ public_key: ""
location: "default" # Location identifier (e.g., site, datacenter) for naming
# Local LDAP byte-pump socket (DESIGN.md §4). The agent forwards raw LDAP bytes
# Local LDAP byte-pump socket (DESIGN.md ??4). The agent forwards raw LDAP bytes
# from this socket to the SSO, which relays them into its OpenLDAP. The agent
# never parses LDAP. Point SSSD at it with:
# ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock
# (Windows relies on the TCP loopback listener 127.0.0.1:389 instead.)
ldap_socket: "/run/theta/ldap.sock"
# Auto-connect the WireGuard tunnel when this host is away from home and the
# directory WebSocket is up. The tray checkbox persists here too.
auto_vpn: false
# mDNS local-discovery (MULTI_SITE_SPEC.md Appendix B): when a
# theta-gateway/theta-proxy on the local network segment announces itself as
# fronting this agent's server_url host, skip the relay/WAN path and talk to
# it directly. Off by default -- it changes host name resolution on this
# machine. Linux only for now; Windows/macOS need their own platform-native
# support before this does anything there.
prefer_local_directory: false
# Windows-specific (DESIGN-WINDOWS.md ??11). Ignored on Linux.
service_name: "theta-agent" # Windows service name
desktop_helper: "" # theta-agent-helper.exe path (session-0 ops)
public_ip_detect: true # false = air-gap: never call external IP services
# WireGuard mesh client (DESIGN-WINDOWS.md ??5). The signed wireguard_apply
# command pushes the peer config down the WSS channel; these are local paths.
wireguard:
tunnel_name: "theta-mesh"
conf: "" # "" = platform default (/etc/wireguard/... or %ProgramData%\Theta42\wg\...)
executable: "" # wireguard.exe path (Windows; "" = PATH/default install)
capabilities:
# ---------------------------------------------------------
# Basic Capabilities (Safe, read-only or infrastructure management)
# ---------------------------------------------------------
# Push CPU, RAM, GPU, and ZFS metrics to the SSO Manager
# Push CPU, RAM, GPU, and ZFS metrics to Theta Directory
telemetry: true
# Allow the SSO Manager to push down SSSD and SSH keys configuration
# Allow Theta Directory to push down SSSD and SSH keys configuration
configure_ldap: true
# Serve the local LDAP byte-pump socket for SSSD/PAM (DESIGN.md §4)
# Serve the local LDAP byte-pump socket for SSSD/PAM (DESIGN.md ??4)
ldap_tunnel: true
# Render OpenBao secrets to local files from /etc/theta/templates (DESIGN.md §5)
# Render OpenBao secrets to local files from /etc/theta/templates (DESIGN.md ??5)
secrets: true
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md §6)
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md ??6)
iam: true
# Secret templates to render (DESIGN.md §5). Each maps a local template to a
# Accept signed wireguard_apply/wireguard_remove commands (DESIGN-WINDOWS.md ??5)
wireguard: false
# Secret templates to render (DESIGN.md ??5). Each maps a local template to a
# target file and an optional post-render reload. The template embeds secrets as
# {{ bao "secret/data/nodes/<node-id>/<name>#<key>" }}.
# secrets:
@@ -64,7 +92,7 @@ capabilities:
# Advanced Capabilities (High risk, remote operations)
# ---------------------------------------------------------
# Allow remote system reboots via the SSO Manager
# Allow remote system reboots via Theta Directory
reboot: false
# Allow restarting, starting, or stopping specific systemd services.
@@ -73,6 +101,6 @@ capabilities:
# Setting to true or [] denies all.
service_control: []
# CRITICAL: Allow the execution of raw bash scripts sent from the SSO Manager.
# CRITICAL: Allow the execution of raw bash scripts sent from Theta Directory.
# Useful for GitOps deployments, but allows remote code execution.
arbitrary_bash: false
Executable
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -e
# Cross-compilation script for Theta Agent across Linux (amd64, arm64, armv7), Windows (amd64, arm64), and macOS (amd64, arm64).
DIST_DIR="./dist"
mkdir -p "$DIST_DIR"
LDFLAGS="-s -w"
# Windows GUI binaries (tray, helper) build as GUI-subsystem so no console
# window pops up when the installer starts the tray or the service spawns the
# helper. The agent stays a console app (handy for foreground debugging; as a
# Windows service it never shows a console anyway).
GUI_LDFLAGS="$LDFLAGS -H=windowsgui"
echo "Building Theta Agent binaries..."
echo " -> linux/amd64..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-linux-amd64"
echo " -> linux/arm64..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-linux-arm64"
echo " -> linux/armv7..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-linux-armv7"
echo " -> windows/amd64..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-amd64.exe"
echo " -> windows/arm64..."
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-windows-arm64.exe"
echo " -> windows/amd64 helper..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$GUI_LDFLAGS" -o "$DIST_DIR/theta-agent-helper-windows-amd64.exe" ./cmd/theta-agent-helper/
echo " -> windows/arm64 helper..."
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$GUI_LDFLAGS" -o "$DIST_DIR/theta-agent-helper-windows-arm64.exe" ./cmd/theta-agent-helper/
echo " -> darwin/amd64 (macOS Intel)..."
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-amd64"
echo " -> darwin/arm64 (macOS Apple Silicon)..."
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-arm64"
echo "Building Theta Agent Tray binaries..."
echo " -> linux/amd64 tray..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-amd64" ./cmd/theta-agent-tray/
echo " -> linux/arm64 tray..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-arm64" ./cmd/theta-agent-tray/
echo " -> windows/amd64 tray..."
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$GUI_LDFLAGS" -o "$DIST_DIR/theta-agent-tray-windows-amd64.exe" ./cmd/theta-agent-tray/
echo " -> windows/arm64 tray..."
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$GUI_LDFLAGS" -o "$DIST_DIR/theta-agent-tray-windows-arm64.exe" ./cmd/theta-agent-tray/
echo ""
echo "Build complete! Artifacts in $DIST_DIR:"
ls -lh "$DIST_DIR"
+40 -11
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"regexp"
"runtime"
"strings"
"time"
)
@@ -31,8 +32,14 @@ func handleCLI(args []string) bool {
case "--reinitialize", "reinitialize", "--reinit", "reinit":
runReinitialize(args[1:])
return true
case "install-service":
handleServiceCommand(args[1:])
return true
case "remove-service", "uninstall-service":
handleServiceCommand(append([]string{"remove"}, args[1:]...))
return true
case "--version", "version", "-v":
fmt.Println("Theta Agent v1.2.0")
fmt.Println("Theta Agent " + AgentVersion)
return true
case "--help", "help", "-h":
printUsage()
@@ -48,8 +55,10 @@ func printUsage() {
fmt.Println(" theta-agent Run agent daemon in foreground")
fmt.Println(" theta-agent get-secret <key> Fetch single secret value from OpenBao")
fmt.Println(" theta-agent get-secrets [flags] Fetch all host/resource secrets (flags: --json, --env)")
fmt.Println(" theta-agent update Self-update binary from SSO Manager")
fmt.Println(" theta-agent update Self-update binary from Theta Directory")
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register")
fmt.Println(" theta-agent install-service (Windows) register the agent as a service")
fmt.Println(" theta-agent remove-service (Windows) unregister the agent service")
fmt.Println(" theta-agent version Show version info")
fmt.Println()
fmt.Println("Reinitialize Flags:")
@@ -58,18 +67,25 @@ func printUsage() {
}
func runSelfUpdate(args []string) {
configPath := "/etc/theta42/agent.yml"
configPath := defaultConfigPath()
cm, err := NewConfigManager(configPath)
if err != nil {
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
}
cfg := cm.Get()
serverURL := strings.TrimRight(cfg.ServerURL, "/")
if serverURL == "" {
log.Fatalf("[!] Update failed: server_url is empty in %s", configPath)
}
_ = cm.Get()
downloadURL := fmt.Sprintf("%s/resources/theta-agent/theta-agent-linux-amd64", serverURL)
// Binaries are GitHub release artifacts (DESIGN-WINDOWS.md §9); nothing
// binary is served from the SSO's /resources anymore.
arch := "amd64"
if runtime.GOARCH == "arm64" {
arch = "arm64"
}
ext := ""
if runtime.GOOS == "windows" {
ext = ".exe"
}
artifact := fmt.Sprintf("theta-agent-%s-%s%s", runtime.GOOS, arch, ext)
downloadURL := releaseAssetURL(artifact)
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
client := &http.Client{Timeout: 30 * time.Second}
@@ -106,7 +122,7 @@ func runSelfUpdate(args []string) {
}
func runReinitialize(args []string) {
configPath := "/etc/theta42/agent.yml"
configPath := defaultConfigPath()
joinKey := ""
for i := 0; i < len(args); i++ {
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
@@ -144,8 +160,21 @@ func runReinitialize(args []string) {
os.Exit(0)
}
// releaseAssetURL returns the GitHub release download URL for a theta-agent
// artifact (e.g. "theta-agent-linux-amd64"). Binaries are built by CI and
// attached to the release; nothing binary lives in the repos (DESIGN-WINDOWS.md §9).
func releaseAssetURL(artifact string) string {
return "https://github.com/theta42/theta-agent/releases/latest/download/" + artifact
}
func restartAffectedServices(exec Executor) {
log.Printf("[+] Restarting theta-agent service...")
if runtime.GOOS == "windows" {
// sc.exe has no one-shot restart.
_, _ = exec.Execute("sc", "stop", "theta-agent")
_, _ = exec.Execute("sc", "start", "theta-agent")
return
}
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
@@ -235,7 +264,7 @@ func runGetSecrets(args []string) {
}
func fetchAgentSecrets() (map[string]string, error) {
configPath := "/etc/theta42/agent.yml"
configPath := defaultConfigPath()
cm, err := NewConfigManager(configPath)
if err != nil {
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
+241
View File
@@ -0,0 +1,241 @@
//go:build windows
// theta-agent-helper — session-aware companion for the theta-agent Windows
// service (DESIGN-WINDOWS.md §4 and §8).
//
// The agent service runs in session 0 with no interactive desktop. Operations
// that need an interactive session (lock, display off, logout) or that must
// outlive the service process (staged self-update: swap the locked exe and
// restart the service) run here instead.
//
// theta-agent-helper lock
// theta-agent-helper display_off
// theta-agent-helper logout [user]
// theta-agent-helper update <newExe> <currentExe> <serviceName>
//
// Sleep is handled in-process by the service (SetSuspendState works from
// session 0); it is also exposed here for manual use.
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"unsafe"
)
const (
wmSyscommand = 0x0112
scMonitorpower = 0xF170
sleepHwnd = ^uintptr(0) // HWND_BROADCAST
monitorPowerOff = 2
wtsCurrentServerHandle = 0
wtsUserName = 5
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
wtsapi32 = syscall.NewLazyDLL("wtsapi32.dll")
powrprof = syscall.NewLazyDLL("powrprof.dll")
procLockWorkStation = user32.NewProc("LockWorkStation")
procSendMessageW = user32.NewProc("SendMessageW")
procWTSLogoffSession = wtsapi32.NewProc("WTSLogoffSession")
procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW")
procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW")
procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory")
procSetSuspendState = powrprof.NewProc("SetSuspendState")
)
type wtsSessionInfo struct {
SessionID uint32
WinStation *uint16
ConnectState uint32
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: theta-agent-helper <lock|display_off|logout|update|sleep> [args...]")
os.Exit(1)
}
switch os.Args[1] {
case "lock":
lockSession()
case "display_off":
displayOff()
case "logout":
logoutSession(argOrEmpty(2))
case "sleep":
sleepHost()
case "update":
if len(os.Args) < 5 {
fmt.Fprintln(os.Stderr, "usage: theta-agent-helper update <newExe> <currentExe> <serviceName>")
os.Exit(1)
}
doUpdate(os.Args[2], os.Args[3], os.Args[4])
default:
fmt.Fprintf(os.Stderr, "unknown action %q\n", os.Args[1])
os.Exit(1)
}
}
func argOrEmpty(i int) string {
if len(os.Args) > i {
return os.Args[i]
}
return ""
}
func lockSession() {
r, _, err := procLockWorkStation.Call()
if r == 0 {
fmt.Fprintf(os.Stderr, "LockWorkStation failed: %v\n", err)
os.Exit(1)
}
}
func displayOff() {
r, _, err := procSendMessageW.Call(sleepHwnd, wmSyscommand, scMonitorpower, monitorPowerOff)
if r == 0 {
fmt.Fprintf(os.Stderr, "SendMessage(SC_MONITORPOWER) failed: %v\n", err)
os.Exit(1)
}
}
func sleepHost() {
r, _, err := procSetSuspendState.Call(0, 0, 0)
if r == 0 {
fmt.Fprintf(os.Stderr, "SetSuspendState failed: %v\n", err)
os.Exit(1)
}
}
// logoutSession logs off the active console session, or every session of the
// given user (matched by WTS session enumeration).
func logoutSession(user string) {
var ids []uint32
if user == "" {
if id := activeConsoleSessionID(); id != 0 {
ids = []uint32{id}
}
} else {
var err error
ids, err = sessionsForUser(user)
if err != nil {
fmt.Fprintf(os.Stderr, "logout: %v\n", err)
os.Exit(1)
}
}
if len(ids) == 0 {
fmt.Fprintln(os.Stderr, "logout: no active session to log off")
os.Exit(1)
}
for _, id := range ids {
logoffSession(id)
}
}
func logoffSession(sessionID uint32) {
r, _, err := procWTSLogoffSession.Call(wtsCurrentServerHandle, uintptr(sessionID), 0)
if r == 0 {
fmt.Fprintf(os.Stderr, "WTSLogoffSession(%d) failed: %v\n", sessionID, err)
os.Exit(1)
}
fmt.Printf("logged off session %d\n", sessionID)
}
func activeConsoleSessionID() uint32 {
id, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("WTSGetActiveConsoleSessionId").Call()
return uint32(id)
}
// sessionsForUser returns every session whose logged-in user matches.
func sessionsForUser(user string) ([]uint32, error) {
var pInfo *wtsSessionInfo
var count uint32
r, _, err := procWTSEnumerateSessionsW.Call(wtsCurrentServerHandle, 0, 1, uintptr(unsafe.Pointer(&pInfo)), uintptr(unsafe.Pointer(&count)))
if r == 0 {
return nil, fmt.Errorf("WTSEnumerateSessionsW: %v", err)
}
defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pInfo)))
want := strings.ToLower(user)
var ids []uint32
for i := uint32(0); i < count; i++ {
info := (*wtsSessionInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(pInfo)) + uintptr(i)*unsafe.Sizeof(*pInfo)))
name := wtsSessionUsername(info.SessionID)
if name != "" && strings.ToLower(name) == want {
ids = append(ids, info.SessionID)
}
}
if len(ids) == 0 {
return nil, fmt.Errorf("no active session for user %q", user)
}
return ids, nil
}
func wtsSessionUsername(sessionID uint32) string {
var pBuf *uint16
var bytes uint32
r, _, _ := procWTSQuerySessionInformationW.Call(wtsCurrentServerHandle, uintptr(sessionID), wtsUserName, uintptr(unsafe.Pointer(&pBuf)), uintptr(unsafe.Pointer(&bytes)))
if r == 0 || pBuf == nil {
return ""
}
defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pBuf)))
return syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(pBuf))[:bytes/2])
}
// doUpdate swaps the staged new binary over the running one and restarts the
// service. The service that spawned us stops itself (stopAgent) once we are
// launched; we wait for it to actually stop (the exe is locked while running),
// swap, and start it again.
func doUpdate(newExe, currentExe, serviceName string) {
if serviceName == "" {
serviceName = "theta-agent"
}
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if !serviceRunning(serviceName) {
break
}
time.Sleep(500 * time.Millisecond)
}
// The file can stay locked a moment after the SCM reports STOPPED, so retry.
swapped := false
for i := 0; i < 40; i++ {
if err := os.Rename(newExe, currentExe); err == nil {
swapped = true
break
}
time.Sleep(250 * time.Millisecond)
}
if !swapped {
fmt.Fprintf(os.Stderr, "update: could not replace %s (is the service still running?)\n", currentExe)
os.Exit(1)
}
cmd := exec.Command("sc", "start", serviceName)
if out, err := cmd.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "update: sc start %s: %v: %s\n", serviceName, err, out)
os.Exit(1)
}
fmt.Printf("update: swapped %s and restarted %s\n", currentExe, serviceName)
}
func serviceRunning(name string) bool {
out, err := exec.Command("sc", "query", name).CombinedOutput()
if err != nil {
return false
}
text := strings.ToUpper(string(out))
return strings.Contains(text, "RUNNING") || strings.Contains(text, "START_PENDING")
}
File diff suppressed because one or more lines are too long
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"bytes"
"runtime"
"testing"
)
// pngToIco wraps a PNG in a Windows ICO with BMP (not PNG-compressed) entries,
// because LoadImage cannot read PNG-in-ICO. Verify the container is well-formed
// and every entry's DIB is a valid 32bpp bitmap with an AND mask.
func TestPNGToIco(t *testing.T) {
ico := pngToIco(iconGreen)
if len(ico) < 6+16 {
t.Fatal("ico too short")
}
// ICONDIR
if ico[0] != 0 || ico[1] != 0 {
t.Errorf("reserved must be 0")
}
if ico[2] != 1 || ico[3] != 0 {
t.Errorf("type must be 1 (icon)")
}
count := int(ico[4]) | int(ico[5])<<8
if count != 3 {
t.Fatalf("expected 3 icon entries, got %d", count)
}
// Each ICONDIRENTRY: valid size, planes=1, bpp=32, DIB with BITMAPINFOHEADER.
for i := 0; i < count; i++ {
e := 6 + i*16
w := int(ico[e])
h := int(ico[e+1])
if w == 0 {
w = 256
}
if h == 0 {
h = 256
}
planes := int(ico[e+4]) | int(ico[e+5])<<8
bpp := int(ico[e+6]) | int(ico[e+7])<<8
size := int(ico[e+8]) | int(ico[e+9])<<8 | int(ico[e+10])<<16 | int(ico[e+11])<<24
offset := int(ico[e+12]) | int(ico[e+13])<<8 | int(ico[e+14])<<16 | int(ico[e+15])<<24
if planes != 1 || bpp != 32 {
t.Errorf("entry %d: want planes=1 bpp=32, got %d/%d", i, planes, bpp)
}
if offset+size > len(ico) {
t.Fatalf("entry %d: DIB out of range", i)
}
dib := ico[offset : offset+size]
// BITMAPINFOHEADER: biSize=40, biHeight = 2x for XOR+AND.
biSize := int(dib[0]) | int(dib[1])<<8 | int(dib[2])<<16 | int(dib[3])<<24
biW := int(dib[4]) | int(dib[5])<<8 | int(dib[6])<<16 | int(dib[7])<<24
biH := int(dib[8]) | int(dib[9])<<8 | int(dib[10])<<16 | int(dib[11])<<24
if biSize != 40 {
t.Errorf("entry %d: expected BITMAPINFOHEADER (40), got %d", i, biSize)
}
if biW != w || biH != h*2 {
t.Errorf("entry %d: DIB dims %dx%d, want %dx%d", i, biW, biH, w, h*2)
}
}
}
// toWindowsIcon passes the PNG through untouched on non-Windows and returns an
// ICO on Windows (whose validity TestPNGToIco covers).
func TestToWindowsIcon(t *testing.T) {
pngMagic := []byte{0x89, 'P', 'N', 'G'}
if runtime.GOOS == "windows" {
if bytes.HasPrefix(toWindowsIcon(iconRed), pngMagic) {
t.Error("windows must not receive a raw PNG")
}
} else {
if !bytes.HasPrefix(toWindowsIcon(iconRed), pngMagic) {
t.Error("non-windows must receive the PNG unchanged")
}
}
}
+112 -7
View File
@@ -19,6 +19,7 @@ type Capabilities struct {
LdapTunnel bool `yaml:"ldap_tunnel"`
Secrets bool `yaml:"secrets"`
IAM bool `yaml:"iam"`
WireGuard bool `yaml:"wireguard"`
}
// SecretTarget maps a local template to a rendered target file and an optional
@@ -29,6 +30,17 @@ type SecretTarget struct {
Reload string `yaml:"reload"`
}
// WireGuardConfig holds the mesh client settings (DESIGN-WINDOWS.md §5).
type WireGuardConfig struct {
// TunnelName is the WireGuard interface (Linux) / service name (Windows).
TunnelName string `yaml:"tunnel_name"`
// Conf is where the pushed peer config is persisted on disk.
Conf string `yaml:"conf"`
// Executable is the wireguard.exe path (Windows); "" = PATH or default
// install location.
Executable string `yaml:"executable"`
}
type Config struct {
ServerURL string `yaml:"server_url"`
AuthToken string `yaml:"auth_token"`
@@ -36,12 +48,48 @@ type Config struct {
// the server exchanges it for a per-agent AuthToken (written back to this
// file), so it is a bootstrap value, not a long-term credential. Used only
// when AuthToken is empty.
JoinKey string `yaml:"join_key"`
Location string `yaml:"location"`
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
Capabilities Capabilities `yaml:"capabilities"`
JoinKey string `yaml:"join_key"`
Location string `yaml:"location"`
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
Capabilities Capabilities `yaml:"capabilities"`
// Windows-specific (DESIGN-WINDOWS.md §11).
ServiceName string `yaml:"service_name"` // Windows service name
DesktopHelper string `yaml:"desktop_helper"` // theta-agent-helper.exe path
PublicIPDetect *bool `yaml:"public_ip_detect"` // false disables external lookups (air-gap)
AutoVPN bool `yaml:"auto_vpn"` // auto-connect WireGuard when away
WireGuard WireGuardConfig `yaml:"wireguard"`
// PreferLocalDirectory opts into mDNS local-discovery (MULTI_SITE_SPEC.md
// Appendix B): when a theta-gateway/theta-proxy on the local network
// segment announces itself as fronting this agent's ServerURL host, skip
// the WAN/relay path and talk to it directly. Off by default -- it
// changes name resolution behavior on the host, so it's opt-in, not
// automatic. Linux only for now (see local_discovery.go); Windows/macOS
// need their own platform-native investigation before this flag does
// anything there.
PreferLocalDirectory bool `yaml:"prefer_local_directory"`
}
// DetectPublicIP reports whether the agent may perform external public-IP
// lookups. Defaults to true; an air-gapped host sets public_ip_detect: false so
// the agent never tries to reach ipify/icanhazip/etc.
func (c *Config) DetectPublicIP() bool {
if c.PublicIPDetect == nil {
return true
}
return *c.PublicIPDetect
}
// ServiceNameOrDefault returns the Windows service name, defaulting to
// theta-agent when unset.
func (c *Config) ServiceNameOrDefault() string {
if c.ServiceName != "" {
return c.ServiceName
}
return "theta-agent"
}
// Credential returns the value to present when connecting: our own token once
@@ -131,12 +179,69 @@ func (cm *ConfigManager) PersistEnrollment(token, publicKey string) error {
return nil
}
// PersistAutoVPN writes the tray's auto-VPN preference back into agent.yml so
// it survives a restart. Same line-preserving edit as PersistEnrollment.
func (cm *ConfigManager) PersistAutoVPN(value bool) error {
cm.mu.Lock()
defer cm.mu.Unlock()
raw, err := os.ReadFile(cm.configPath)
if err != nil {
return fmt.Errorf("read %s: %w", cm.configPath, err)
}
out := setYamlScalarValue(string(raw), "auto_vpn", fmt.Sprintf("%t", value), false)
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
return fmt.Errorf("write %s: %w", cm.configPath, err)
}
cfg, err := LoadConfig(cm.configPath)
if err != nil {
return fmt.Errorf("reload after auto_vpn: %w", err)
}
cm.current = cfg
return nil
}
// ClearEnrollment blanks the auth_token and public_key so the agent re-enrolls
// with whatever join_key is configured. Triggered by the tray's "re-enroll".
func (cm *ConfigManager) ClearEnrollment() error {
cm.mu.Lock()
defer cm.mu.Unlock()
raw, err := os.ReadFile(cm.configPath)
if err != nil {
return fmt.Errorf("read %s: %w", cm.configPath, err)
}
out := setYamlScalar(string(raw), "auth_token", "")
out = setYamlScalar(out, "public_key", "")
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
return fmt.Errorf("write %s: %w", cm.configPath, err)
}
cfg, err := LoadConfig(cm.configPath)
if err != nil {
return fmt.Errorf("reload after enrollment clear: %w", err)
}
cm.current = cfg
return nil
}
// setYamlScalar replaces the value of a top-level `key: "..."` line, or appends
// the key when it is absent. Deliberately line-based rather than a YAML
// round-trip so comments and formatting survive.
func setYamlScalar(doc, key, value string) string {
return setYamlScalarValue(doc, key, value, true)
}
// setYamlScalarValue is setYamlScalar with control over quoting. Numeric/bool
// scalars (e.g. auto_vpn: true) must stay unquoted or YAML decodes them as
// strings.
func setYamlScalarValue(doc, key, value string, quote bool) string {
line := fmt.Sprintf("%s: %s", key, value)
if quote {
line = fmt.Sprintf("%s: %q", key, value)
}
re := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*:.*$`)
line := fmt.Sprintf("%s: %q", key, value)
if re.MatchString(doc) {
return re.ReplaceAllString(doc, line)
}
+60 -4
View File
@@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -155,10 +156,13 @@ capabilities:
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential())
}
// file must stay 0600 -- it now holds a credential
fi, _ := os.Stat(path)
if fi.Mode().Perm() != 0600 {
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
// file must stay 0600 -- it now holds a credential. POSIX-only: Windows has
// no mode bits (0666 is reported regardless) and relies on ACLs instead.
if runtime.GOOS != "windows" {
fi, _ := os.Stat(path)
if fi.Mode().Perm() != 0600 {
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
}
}
}
@@ -202,3 +206,55 @@ func TestPersistEnrollmentRejectsEmptyToken(t *testing.T) {
t.Error("expected an error when the server sends no token")
}
}
// TestPersistAutoVPN writes the tray preference into the file and reloads it.
func TestPersistAutoVPN(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\njoin_key: \"tjk_abc123\"\n"), 0600)
cm, err := NewConfigManager(path)
if err != nil {
t.Fatal(err)
}
if err := cm.PersistAutoVPN(true); err != nil {
t.Fatalf("PersistAutoVPN: %v", err)
}
if !cm.Get().AutoVPN {
t.Errorf("AutoVPN should be true after persist")
}
out, _ := os.ReadFile(path)
if !strings.Contains(string(out), "auto_vpn: true") {
t.Errorf("expected auto_vpn: true in file, got:\n%s", out)
}
if err := cm.PersistAutoVPN(false); err != nil {
t.Fatalf("PersistAutoVPN(false): %v", err)
}
if cm.Get().AutoVPN {
t.Errorf("AutoVPN should be false after persist")
}
}
// TestClearEnrollment blanks credentials so the agent re-enrolls.
func TestClearEnrollment(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\nauth_token: \"tok-abc\"\npublic_key: \"PK\"\njoin_key: \"tjk_keep\"\n"), 0600)
cm, err := NewConfigManager(path)
if err != nil {
t.Fatal(err)
}
if err := cm.ClearEnrollment(); err != nil {
t.Fatalf("ClearEnrollment: %v", err)
}
cfg := cm.Get()
if cfg.AuthToken != "" || cfg.PublicKey != "" {
t.Errorf("expected cleared credentials, got token=%q pub=%q", cfg.AuthToken, cfg.PublicKey)
}
out, _ := os.ReadFile(path)
if strings.Contains(string(out), "tok-abc") {
t.Errorf("old token should be gone from file, got:\n%s", out)
}
}
+6 -1
View File
@@ -3,18 +3,23 @@ module github.com/theta42/theta-agent
go 1.22.2
require (
fyne.io/systray v1.12.2
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.5
github.com/shirou/gopsutil/v3 v3.24.5
golang.org/x/sys v0.20.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/miekg/dns v1.1.41 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect
)
+20 -2
View File
@@ -1,14 +1,22 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE=
github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
@@ -27,14 +35,24 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+183
View File
@@ -0,0 +1,183 @@
package main
// Home detection: compares this agent's current public IP with the home
// site's public IP as reported by the directory.
//
// "Home" is site-relative: each theta-suite deployment has a site name and a
// public-facing IP. When this agent's egress IP matches, the user is on that
// site's LAN (or behind its NAT). The directory reports each site's public IP
// through its telemetry data; we get it on the WebSocket config push.
import (
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
var homeState struct {
mu sync.RWMutex
agentPublicIP string
homePublicIP string // set by directory config push
vpnActive bool
autoVPN bool
}
// publicIPProviders are tried in order until one succeeds.
var publicIPProviders = []string{
"https://api4.my-ip.io/ip",
"https://ipv4.icanhazip.com",
"https://api.ipify.org",
}
// fetchPublicIP tries each provider and returns the first clean response.
func fetchPublicIP() string {
client := &http.Client{Timeout: 5 * time.Second}
for _, url := range publicIPProviders {
resp, err := client.Get(url)
if err != nil {
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
continue
}
ip := strings.TrimSpace(string(body))
if ip != "" && !strings.Contains(ip, "<") { // skip HTML error pages
return ip
}
}
return ""
}
// SetHomePublicIP is called when the directory pushes its site's public IP.
func SetHomePublicIP(ip string) {
homeState.mu.Lock()
homeState.homePublicIP = ip
homeState.mu.Unlock()
}
// SetVPNActive is called when WireGuard tunnel state changes.
func SetVPNActive(active bool) {
homeState.mu.Lock()
homeState.vpnActive = active
homeState.mu.Unlock()
}
// SetAutoVPN records the auto-connect preference (initialized from agent.yml,
// updated by the tray checkbox).
func SetAutoVPN(v bool) {
homeState.mu.Lock()
homeState.autoVPN = v
homeState.mu.Unlock()
}
// AutoVPN returns the current auto-connect preference.
func AutoVPN() bool {
homeState.mu.RLock()
defer homeState.mu.RUnlock()
return homeState.autoVPN
}
// StartHomeMonitor periodically refreshes the agent's public IP and pushes
// updated tray status. Call as a goroutine from main().
func StartHomeMonitor(cfg *Config, connectedFn func() bool) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
// Run immediately on start.
checkAndPush(cfg, connectedFn)
for range ticker.C {
checkAndPush(cfg, connectedFn)
}
}
func checkAndPush(cfg *Config, connectedFn func() bool) {
// An air-gapped host cannot reach the public-IP providers; when
// public_ip_detect is false we skip the network calls entirely and report
// no public IP rather than flap the home/away state (DESIGN-WINDOWS.md §10).
var ip string
if cfg.DetectPublicIP() {
ip = fetchPublicIP()
}
if ip == "" {
log.Println("[home-detect] could not determine public IP")
}
homeState.mu.Lock()
homeState.agentPublicIP = ip
agentIP := homeState.agentPublicIP
homeIP := homeState.homePublicIP
vpn := homeState.vpnActive
autoVPN := homeState.autoVPN
homeState.mu.Unlock()
connected := connectedFn()
siteName := cfg.Location
if siteName == "" {
siteName = "home"
}
// WireGuard state + auto-VPN (DESIGN-WINDOWS.md §5). The tunnel can be
// driven by the SSO (wireguard_apply/remove) or by the tray; polling keeps
// the tray icon blue and lets auto-VPN react to home/away changes.
if cfg.Capabilities.WireGuard {
vpn = defaultPlatformOps.WireGuardState()
SetVPNActive(vpn)
isHome := computeIsHome(agentIP, homeIP, connected, cfg.ServerURL)
handleAutoVPN(cfg, isHome, vpn, autoVPN, connected)
}
UpdateTrayStatus(connected, agentIP, homeIP, vpn, autoVPN, siteName, cfg.ServerURL)
}
// computeIsHome mirrors the home-LAN determination in UpdateTrayStatus: public
// IP matches the directory's site IP, the server is local/LAN, or the site IP
// is not yet known (assume local home).
func computeIsHome(agentPublicIP, homePublicIP string, connected bool, serverURL string) bool {
if !connected {
return false
}
isLocalServer := strings.Contains(serverURL, "localhost") ||
strings.Contains(serverURL, "127.0.0.1") ||
strings.Contains(serverURL, ".local") ||
strings.Contains(serverURL, "192.168.") ||
strings.Contains(serverURL, "10.")
return (homePublicIP != "" && agentPublicIP != "" && agentPublicIP == homePublicIP) || isLocalServer || homePublicIP == ""
}
// lastAutoVPNChange gates auto-VPN so the home monitor (60s tick) does not
// hammer connect/disconnect on every poll.
var lastAutoVPNChange time.Time
// handleAutoVPN connects the tunnel when away from home and auto-connect is on,
// and drops it again once back on the home LAN.
func handleAutoVPN(cfg *Config, isHome, vpn, autoVPN, connected bool) {
if !autoVPN || !connected {
return
}
now := time.Now()
if now.Sub(lastAutoVPNChange) < 2*time.Minute {
return
}
if isHome && vpn {
log.Println("[home-detect] back home; disconnecting WireGuard (auto-vpn)")
if err := defaultPlatformOps.DisconnectWireGuard(); err != nil {
log.Printf("[home-detect] disconnect failed: %v", err)
}
lastAutoVPNChange = now
return
}
if !isHome && !vpn {
log.Println("[home-detect] away from home; connecting WireGuard (auto-vpn)")
if err := defaultPlatformOps.ConnectWireGuard(); err != nil {
log.Printf("[home-detect] connect failed: %v", err)
}
lastAutoVPNChange = now
}
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"bufio"
"fmt"
"os"
"runtime"
"strings"
"sync"
)
// Linux-only for now (AGENT_LOCAL_DISCOVERY_SPEC.md §3) -- Windows/macOS
// hosts-file semantics (elevation, DNS caching, whether mDNSResponder should
// be used instead of hand-rolled hosts edits) need their own platform-native
// investigation before this mechanism is trusted there.
//
// var, not const, so tests can point it at a temp file instead of touching
// the real /etc/hosts.
var hostsFilePathLinux = "/etc/hosts"
const hostsBlockBegin = "# BEGIN theta-agent-local-discovery (managed, do not edit by hand)"
const hostsBlockEnd = "# END theta-agent-local-discovery"
var hostsMu sync.Mutex
// applyHostsOverride replaces the managed block in /etc/hosts with exactly
// `entries` (hostname -> IP). Passing an empty map removes the block
// entirely rather than leaving an empty marker pair, so a host that never
// discovers anything -- or stops discovering something it used to -- leaves
// hosts file with no discovery trace at all.
func applyHostsOverride(entries map[string]string) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("hosts-file override is Linux-only for now (see AGENT_LOCAL_DISCOVERY_SPEC.md §3)")
}
hostsMu.Lock()
defer hostsMu.Unlock()
existing, err := readLines(hostsFilePathLinux)
if err != nil {
return fmt.Errorf("reading %s: %w", hostsFilePathLinux, err)
}
kept := make([]string, 0, len(existing))
inBlock := false
for _, line := range existing {
trimmed := strings.TrimSpace(line)
if trimmed == hostsBlockBegin {
inBlock = true
continue
}
if trimmed == hostsBlockEnd {
inBlock = false
continue
}
if inBlock {
continue // drop old managed lines unconditionally; rebuilt below
}
kept = append(kept, line)
}
// Trim any trailing blank lines the block removal left, then rebuild.
for len(kept) > 0 && strings.TrimSpace(kept[len(kept)-1]) == "" {
kept = kept[:len(kept)-1]
}
out := strings.Join(kept, "\n")
if len(entries) > 0 {
out += "\n" + hostsBlockBegin + "\n"
for host, ip := range entries {
out += fmt.Sprintf("%s\t%s\n", ip, host)
}
out += hostsBlockEnd + "\n"
} else {
out += "\n"
}
// NOT write-tmp-then-rename: on a real host that's the safer, atomic
// way to update a file, but /etc/hosts is frequently a bind mount
// (every container runtime does this, Docker included) -- confirmed the
// hard way: rename() onto a bind-mounted /etc/hosts fails with EBUSY
// ("device or resource busy"), since you cannot atomically replace a
// mountpoint. Truncate-and-rewrite in place instead; hostsMu already
// serializes calls from this process, which is the only writer of the
// managed block, so the lost atomicity is a real but small tradeoff
// against a confirmed hard failure.
if err := os.WriteFile(hostsFilePathLinux, []byte(out), 0644); err != nil {
return fmt.Errorf("writing %s: %w", hostsFilePathLinux, err)
}
return nil
}
func readLines(path string) ([]string, error) {
f, err := os.Open(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/hashicorp/mdns"
)
func withTempHostsFile(t *testing.T, initial string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "hosts")
if initial != "" {
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
t.Fatalf("seeding temp hosts file: %v", err)
}
}
orig := hostsFilePathLinux
hostsFilePathLinux = path
t.Cleanup(func() { hostsFilePathLinux = orig })
return path
}
func TestApplyHostsOverride_AddsManagedBlock(t *testing.T) {
path := withTempHostsFile(t, "127.0.0.1\tlocalhost\n")
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil {
t.Fatalf("applyHostsOverride: %v", err)
}
got, _ := os.ReadFile(path)
s := string(got)
if !strings.Contains(s, "127.0.0.1\tlocalhost") {
t.Errorf("existing content was clobbered: %q", s)
}
if !strings.Contains(s, hostsBlockBegin) || !strings.Contains(s, hostsBlockEnd) {
t.Errorf("managed block markers missing: %q", s)
}
if !strings.Contains(s, "10.0.0.5\tsso.example.com") {
t.Errorf("override entry missing: %q", s)
}
}
func TestApplyHostsOverride_ReplacesPriorBlockRatherThanStacking(t *testing.T) {
path := withTempHostsFile(t, "")
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil {
t.Fatalf("first apply: %v", err)
}
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.9"}); err != nil {
t.Fatalf("second apply: %v", err)
}
got, _ := os.ReadFile(path)
s := string(got)
if strings.Count(s, hostsBlockBegin) != 1 {
t.Fatalf("expected exactly one managed block, got content: %q", s)
}
if strings.Contains(s, "10.0.0.5") {
t.Errorf("stale override (10.0.0.5) should have been replaced, got: %q", s)
}
if !strings.Contains(s, "10.0.0.9") {
t.Errorf("new override missing, got: %q", s)
}
}
func TestApplyHostsOverride_EmptyEntriesRemovesBlockEntirely(t *testing.T) {
path := withTempHostsFile(t, "127.0.0.1\tlocalhost\n")
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil {
t.Fatalf("apply: %v", err)
}
if err := applyHostsOverride(map[string]string{}); err != nil {
t.Fatalf("clear: %v", err)
}
got, _ := os.ReadFile(path)
s := string(got)
if strings.Contains(s, hostsBlockBegin) || strings.Contains(s, "10.0.0.5") {
t.Errorf("expected no discovery trace left after clearing, got: %q", s)
}
if !strings.Contains(s, "127.0.0.1\tlocalhost") {
t.Errorf("pre-existing content should survive a full clear, got: %q", s)
}
}
func TestHostFromURL(t *testing.T) {
cases := map[string]string{
"https://sso.example.com:443/api": "sso.example.com",
"http://sso.example.com": "sso.example.com",
"not a url at all": "",
"": "",
}
for in, want := range cases {
if got := hostFromURL(in); got != want {
t.Errorf("hostFromURL(%q) = %q, want %q", in, got, want)
}
}
}
func TestEntryAnnouncesHost(t *testing.T) {
entry := &mdns.ServiceEntry{InfoFields: []string{"hosts=sso.example.com,proxy.example.com"}}
if !entryAnnouncesHost(entry, "sso.example.com") {
t.Error("expected match for sso.example.com")
}
if !entryAnnouncesHost(entry, "proxy.example.com") {
t.Error("expected match for proxy.example.com")
}
if entryAnnouncesHost(entry, "jump.example.com") {
t.Error("expected no match for a host not in the TXT record")
}
}
+60
View File
@@ -0,0 +1,60 @@
//go:build windows
package main
// Windows IAM helpers (DESIGN-WINDOWS.md §4 "IAM on Windows"). The platform
// neutral parts live in iam.go; this file adds the Windows-specific pieces that
// applyIAM cannot express (it is the Linux engine).
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// applyWindowsSSHKeys writes each user's authorized_keys into their profile so
// the built-in Windows OpenSSH server picks them up. OpenSSH on Windows reads
// %USERPROFILE%\.ssh\authorized_keys for standard accounts (administrators use
// %ProgramData%\ssh\administrators_authorized_keys; we mirror the key there too
// when the profile belongs to an admin is unknowable here, so we write both
// locations if present).
func applyWindowsSSHKeys(keys []SSHKey) error {
pd := os.Getenv("ProgramData")
if pd == "" {
pd = `C:\ProgramData`
}
// administrators_authorized_keys covers members of Administrators.
adminKeys := programDataAdminKeysPath(pd)
for _, k := range keys {
if k.User == "" {
continue
}
content := strings.Join(k.Keys, "\n") + "\n"
profile := filepath.Join(`C:\Users`, k.User)
if st, err := os.Stat(profile); err == nil && st.IsDir() {
sshDir := filepath.Join(profile, ".ssh")
if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("mkdir %s: %w", sshDir, err)
}
dest := filepath.Join(sshDir, "authorized_keys")
if err := os.WriteFile(dest, []byte(content), 0600); err != nil {
return fmt.Errorf("write %s: %w", dest, err)
}
}
// Also mirror into administrators_authorized_keys so admin-keyed logins
// work through OpenSSH's admin ACL check.
if err := os.MkdirAll(filepath.Dir(adminKeys), 0700); err == nil {
_ = os.WriteFile(adminKeys, []byte(content), 0600)
}
}
return nil
}
func programDataAdminKeysPath(pd string) string {
return filepath.Join(pd, "ssh", "administrators_authorized_keys")
}
+73 -14
View File
@@ -110,9 +110,40 @@ fi
log "Starting Theta Agent installation..."
# Architecture and OS detection
OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH_NAME="$(uname -m)"
BINARY_NAME="theta-agent-linux-amd64"
case "$OS_NAME" in
linux*)
case "$ARCH_NAME" in
x86_64|amd64) BINARY_NAME="theta-agent-linux-amd64" ;;
aarch64|arm64) BINARY_NAME="theta-agent-linux-arm64" ;;
armv7*|armhf) BINARY_NAME="theta-agent-linux-armv7" ;;
*) BINARY_NAME="theta-agent-linux-amd64" ;;
esac
;;
darwin*)
case "$ARCH_NAME" in
x86_64|amd64) BINARY_NAME="theta-agent-darwin-amd64" ;;
arm64|aarch64) BINARY_NAME="theta-agent-darwin-arm64" ;;
*) BINARY_NAME="theta-agent-darwin-arm64" ;;
esac
;;
mingw*|msys*|cygwin*)
case "$ARCH_NAME" in
aarch64|arm64) BINARY_NAME="theta-agent-windows-arm64.exe" ;;
*) BINARY_NAME="theta-agent-windows-amd64.exe" ;;
esac
;;
esac
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${BINARY_NAME}"
# 3. Install binary
log "Downloading binary from $BINARY_URL..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary."
log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL"
chmod +x "$BIN_PATH.tmp"
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
@@ -143,20 +174,48 @@ EOF
else
log "Preserving existing configuration at $CONFIG_FILE"
fi
chmod 600 "$CONFIG_FILE"
# An agent with no public_key cannot verify signed commands and will refuse
# every one of them. That is the safe default, but it is silent at run time, so
# say it plainly here where the operator is watching.
if ! grep -qE '^public_key:[[:space:]]*"[^"]+"' "$CONFIG_FILE" 2>/dev/null; then
log "WARNING: no public_key configured — this agent will report telemetry but"
log " REFUSE reboot / configure_ldap / arbitrary_bash / update_binary."
log " Re-run with --public-key \"<base64 key>\" (shown at enrollment)."
# Ensure theta-secrets & theta groups exist for non-root secret access
log "Configuring non-root secret access groups (theta-secrets)..."
if command -v groupadd >/dev/null 2>&1; then
getent group theta-secrets >/dev/null 2>&1 || groupadd -r theta-secrets 2>/dev/null || true
getent group theta >/dev/null 2>&1 || groupadd -r theta 2>/dev/null || true
fi
SECRETS_GROUP="root"
if getent group theta-secrets >/dev/null 2>&1; then
SECRETS_GROUP="theta-secrets"
elif getent group theta >/dev/null 2>&1; then
SECRETS_GROUP="theta"
fi
chown -R "root:$SECRETS_GROUP" "$CONFIG_DIR" 2>/dev/null || true
chmod 750 "$CONFIG_DIR"
chmod 640 "$CONFIG_FILE"
# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled
if [ "$INSTALL_SSSD" -eq 1 ] || grep -qE -i 'configure_ldap:[[:space:]]*true' "$CONFIG_FILE" 2>/dev/null; then
install_sssd_deps
# 4c. Setup Desktop Tray Icon companion
TRAY_BINARY_NAME="theta-agent-tray-${OS_NAME}-${ARCH_NAME}"
case "$OS_NAME" in
linux*) TRAY_BINARY_NAME="theta-agent-tray-linux-amd64" ;;
windows*) TRAY_BINARY_NAME="theta-agent-tray-windows-amd64.exe" ;;
esac
TRAY_BIN_PATH="/usr/local/bin/theta-agent-tray"
TRAY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${TRAY_BINARY_NAME}"
log "Attempting to install desktop tray companion ($TRAY_BINARY_NAME)..."
if curl -fsSL "$TRAY_URL" -o "$TRAY_BIN_PATH.tmp" 2>/dev/null; then
chmod +x "$TRAY_BIN_PATH.tmp"
mv -f "$TRAY_BIN_PATH.tmp" "$TRAY_BIN_PATH"
mkdir -p /etc/xdg/autostart
cat <<EOF > /etc/xdg/autostart/theta-agent-tray.desktop
[Desktop Entry]
Type=Application
Name=Theta Agent Tray
Comment=Theta Agent Desktop Tray Companion
Exec=/usr/local/bin/theta-agent-tray
Icon=network-workgroup
Terminal=false
Categories=Utility;System;
X-GNOME-Autostart-enabled=true
EOF
log "Desktop tray companion installed at $TRAY_BIN_PATH with autostart."
fi
# 5. Setup systemd service
+295
View File
@@ -0,0 +1,295 @@
; Theta Agent ??? Windows installer (Inno Setup 6.4+)
;
; Fully offline: bundles the agent, tray, session helper, the official WireGuard
; for Windows client, the OpenCredential credential provider, and the VC++ v14
; runtime. Nothing on the target machine requires internet access.
;
; Usage:
; iscc installer\windows\installer.iss
; theta-agent-2.1.0-windows-amd64-setup.exe /SILENT ^
; /SERVER_URL=https://sso.example.com /JOIN_KEY=tjk_...
;
; Interactively, a wizard page asks for the Theta Directory URL and a join key
; (with a button that opens the Theta Directory's Directory -> Install Agent page
; to mint one). In silent mode, /SERVER_URL, /JOIN_KEY, /AUTH_TOKEN, /PUBLIC_KEY
; and /B64_CONFIG (base64 of a full agent.yml) drive the same result. The values
; are written into agent.yml so the installed service enrolls on first start.
#ifndef MyAppVersion
#define MyAppVersion "2.1.0"
#endif
#define MyAppName "Theta Agent"
#define MyAppPublisher "Theta42"
#define MyAppExeName "theta-agent-windows-amd64.exe"
#define AgentDir "..\..\dist"
#define VendorDir "vendor"
[Setup]
AppId={{E2F64E2C-7A2B-4F4D-9E8C-9C0D0E9F3A21}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
VersionInfoVersion={#MyAppVersion}
DefaultDirName={autopf}\Theta42
DefaultGroupName=Theta42
DisableProgramGroupPage=yes
PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir={#AgentDir}
OutputBaseFilename=theta-agent-{#MyAppVersion}-windows-amd64-setup
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
UninstallDisplayName={#MyAppName}
CloseApplications=no
MinVersion=10.0.17763
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Dirs]
; The daemon (SYSTEM) and the tray (logged-in user) share %ProgramData%\Theta42
; for agent.yml, the tray IPC socket and the WireGuard config. Users need write
; access for the socket; the agent.yml ACL is tightened by the code.
Name: "{commonappdata}\Theta42"; Permissions: users-modify
Name: "{app}\vendor"
[Files]
Source: "{#AgentDir}\theta-agent-windows-amd64.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#AgentDir}\theta-agent-tray-windows-amd64.exe"; DestDir: "{app}\tray"; Flags: ignoreversion
Source: "{#AgentDir}\theta-agent-helper-windows-amd64.exe"; DestDir: "{app}"; Flags: ignoreversion
; WireGuard for Windows ??? official, vendor-signed MSI. Installs offline (the
; driver is signed; no signature phone-home).
Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags: ignoreversion
; OpenCredential credential provider installer (BSD-3 pGina fork) + the VC++
; runtime it needs. Both install silently at [Run].
Source: "{#VendorDir}\OpenCredentialInstaller-1.0.0.0.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
Source: "{#VendorDir}\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
[Icons]
Name: "{group}\Theta Agent Tray"; Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Comment: "Theta Agent status tray"
Name: "{group}\Open Agent Config"; Filename: "notepad.exe"; Parameters: "{commonappdata}\Theta42\agent.yml"; Comment: "Open the agent configuration file"
Name: "{group}\Uninstall Theta Agent"; Filename: "{uninstallexe}"
[Registry]
; Start the tray for every interactive logon.
Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "ThetaAgentTray"; ValueData: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Flags: uninsdeletevalue
[Run]
; VC++ v14 runtime (OpenCredential native deps).
Filename: "{app}\vendor\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Installing VC++ runtime..."; Flags: runhidden waituntilterminated
; OpenCredential credential provider ??? must be registered before logon.
Filename: "{app}\vendor\OpenCredentialInstaller-1.0.0.0.exe"; Parameters: "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"; StatusMsg: "Installing OpenCredential credential provider..."; Flags: runhidden waituntilterminated
; WireGuard for Windows client.
Filename: "msiexec.exe"; Parameters: "/i ""{app}\vendor\wireguard-amd64-0.5.3.msi"" /qn /norestart"; StatusMsg: "Installing WireGuard client..."; Flags: runhidden waituntilterminated
; The WireGuard client launches its UI at the end of the MSI; close it ??? the
; tunnel is managed by the agent (wireguard.exe /installtunnelservice).
Filename: "taskkill.exe"; Parameters: "/f /im wireguard.exe"; Flags: runhidden
; Register the agent as a SYSTEM auto-start service.
Filename: "{app}\{#MyAppExeName}"; Parameters: "install-service"; StatusMsg: "Registering theta-agent service..."; Flags: runhidden waituntilterminated
; Show the tray right away, in silent and interactive installs alike (a silent
; install is the common path from the Directory's install command, and the tray
; should appear immediately there too, not only at the next logon).
Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Description: "Start Theta Agent tray"; StatusMsg: "Starting Theta Agent tray..."; Flags: nowait postinstall
[Code]
var
ServerURL: String;
JoinKey: String;
AuthToken: String;
PublicKey: String;
B64Config: String;
AgentConfigPage: TWizardPage;
ServerURLEdit: TNewEdit;
JoinKeyEdit: TNewEdit;
OpenSSOButton: TNewButton;
// Reads a custom setup command-line parameter (e.g. /SERVER_URL=https://...).
// {param:...} raises when the parameter is absent, so the exception becomes "".
function GetCmdParam(const Name: String): String;
begin
try
Result := ExpandConstant('{param:' + Name + '}');
except
Result := '';
end;
end;
function InitializeSetup(): Boolean;
begin
ServerURL := GetCmdParam('SERVER_URL');
JoinKey := GetCmdParam('JOIN_KEY');
AuthToken := GetCmdParam('AUTH_TOKEN');
PublicKey := GetCmdParam('PUBLIC_KEY');
B64Config := GetCmdParam('B64_CONFIG');
Result := True;
end;
// Opens the Theta Directory page so the operator can mint a join key right
// from the wizard. Uses the Server URL they just typed.
procedure OnOpenSSOClick(Sender: TObject);
var
Url: String;
ErrorCode: Integer;
begin
Url := Trim(ServerURLEdit.Text);
if Url = '' then begin
MsgBox('Enter the Theta Directory URL first (e.g. https://directory.example.com).',
mbInformation, MB_OK);
Exit;
end;
if not ShellExec('open', Url, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
MsgBox('Could not open the browser: ' + SysErrorMessage(ErrorCode), mbError, MB_OK);
end;
procedure CreateAgentConfigPage();
var
InfoLabel: TNewStaticText;
UrlLabel: TNewStaticText;
KeyLabel: TNewStaticText;
Y: Integer;
begin
AgentConfigPage := CreateCustomPage(wpWelcome,
'Theta Directory connection',
'Tell the agent which Theta Directory to enroll with.');
InfoLabel := TNewStaticText.Create(AgentConfigPage);
InfoLabel.Parent := AgentConfigPage.Surface;
InfoLabel.WordWrap := True;
InfoLabel.Caption := 'Paste the Theta Directory URL for this deployment. Then either paste a join key '
+ '(mint one with the button below, under Directory -> Install Agent) or leave it blank to '
+ 'enroll from the tray / CLI later.';
// WordWrap + AutoSize are mutually exclusive in VCL; give the wrapped label a
// fixed height so the fields below it land on screen.
InfoLabel.Width := AgentConfigPage.SurfaceWidth;
InfoLabel.Height := ScaleY(48);
Y := InfoLabel.Top + InfoLabel.Height + ScaleY(12);
UrlLabel := TNewStaticText.Create(AgentConfigPage);
UrlLabel.Parent := AgentConfigPage.Surface;
UrlLabel.Caption := 'Theta Directory URL:';
UrlLabel.Top := Y;
ServerURLEdit := TNewEdit.Create(AgentConfigPage);
ServerURLEdit.Parent := AgentConfigPage.Surface;
ServerURLEdit.Top := UrlLabel.Top + UrlLabel.Height + ScaleY(4);
ServerURLEdit.Width := AgentConfigPage.SurfaceWidth;
ServerURLEdit.Text := ServerURL;
OpenSSOButton := TNewButton.Create(AgentConfigPage);
OpenSSOButton.Parent := AgentConfigPage.Surface;
OpenSSOButton.Top := ServerURLEdit.Top + ServerURLEdit.Height + ScaleY(10);
OpenSSOButton.Left := ServerURLEdit.Left;
OpenSSOButton.Caption := 'Open Theta Directory install-agent page...';
OpenSSOButton.Width := WizardForm.CalculateButtonWidth([OpenSSOButton.Caption]);
OpenSSOButton.Height := ScaleY(23);
OpenSSOButton.OnClick := @OnOpenSSOClick;
KeyLabel := TNewStaticText.Create(AgentConfigPage);
KeyLabel.Parent := AgentConfigPage.Surface;
KeyLabel.Caption := 'Join key (optional):';
KeyLabel.Top := OpenSSOButton.Top + OpenSSOButton.Height + ScaleY(12);
JoinKeyEdit := TNewEdit.Create(AgentConfigPage);
JoinKeyEdit.Parent := AgentConfigPage.Surface;
JoinKeyEdit.Top := KeyLabel.Top + KeyLabel.Height + ScaleY(4);
JoinKeyEdit.Width := AgentConfigPage.SurfaceWidth;
JoinKeyEdit.Text := JoinKey;
end;
procedure InitializeWizard();
begin
CreateAgentConfigPage();
end;
// Pull the values the operator typed into the wizard so WriteAgentConfig can
// use them. In silent mode the wizard is walked programmatically and
// CurPageChanged fires too -- reading the (empty) edit boxes there would wipe
// the /SERVER_URL /JOIN_KEY command-line params and leave agent.yml with an
// empty server_url. Only take the edit values when the wizard is actually
// being shown interactively.
procedure CurPageChanged(CurPageID: Integer);
begin
if (CurPageID = AgentConfigPage.ID) and (not WizardSilent()) then begin
ServerURL := Trim(ServerURLEdit.Text);
JoinKey := Trim(JoinKeyEdit.Text);
end;
end;
// Minimal base64 decoder returning a plain String (agent.yml is ASCII).
function B64Decode(const S: String): String;
var
i, v, p: Integer;
buf: array[0..3] of Integer;
outStr: String;
begin
outStr := '';
v := 0;
for i := 1 to Length(S) do begin
if S[i] = '=' then begin
buf[v] := 0;
Inc(v);
end else begin
p := Pos(S[i], 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/');
buf[v] := p - 1;
Inc(v);
end;
if v = 4 then begin
outStr := outStr + Chr((buf[0] shl 2) or (buf[1] shr 4));
outStr := outStr + Chr(((buf[1] and $F) shl 4) or (buf[2] shr 2));
outStr := outStr + Chr(((buf[2] and 3) shl 6) or buf[3]);
v := 0;
end;
end;
Result := outStr;
end;
// Write agent.yml only after all files are in place. The file lives in
// ProgramData so the SYSTEM service and user processes share it.
procedure WriteAgentConfig(ConfigPath: String);
var
Lines: TArrayOfString;
Decoded: String;
begin
// /B64_CONFIG=<base64 agent.yml> overrides everything (the SSO's Custom Config
// wizard emits it).
if B64Config <> '' then begin
Decoded := B64Decode(B64Config);
SetArrayLength(Lines, 1);
Lines[0] := Decoded;
SaveStringsToUTF8FileWithoutBOM(ConfigPath, Lines, False);
Exit;
end;
SetArrayLength(Lines, 17);
Lines[0] := '# theta-agent configuration (written by installer)';
Lines[1] := 'server_url: "' + ServerURL + '"';
Lines[2] := 'auth_token: "' + AuthToken + '"';
Lines[3] := 'join_key: "' + JoinKey + '"';
Lines[4] := 'public_key: "' + PublicKey + '"';
Lines[5] := 'auto_vpn: false';
Lines[6] := 'service_name: "theta-agent"';
Lines[7] := 'desktop_helper: "' + ExpandConstant('{app}') + '\theta-agent-helper-windows-amd64.exe"';
Lines[8] := 'public_ip_detect: true';
Lines[9] := 'capabilities:';
Lines[10] := ' telemetry: true';
Lines[11] := ' ldap_tunnel: true';
Lines[12] := ' wireguard: true';
Lines[13] := ' secrets: false';
Lines[14] := ' iam: false';
Lines[15] := ' reboot: false';
Lines[16] := ' arbitrary_bash: false';
SaveStringsToUTF8FileWithoutBOM(ConfigPath, Lines, False);
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then
WriteAgentConfig(ExpandConstant('{commonappdata}\Theta42\agent.yml'));
end;
+34
View File
@@ -0,0 +1,34 @@
{
"comment": "Pinned third-party assets bundled into the Windows installer (DESIGN-WINDOWS.md §8). The setup script (scripts/setup-build-env.ps1) fetches these into installer/windows/vendor/ and verifies sha256; nothing is committed to git.",
"assets": [
{
"name": "wireguard-amd64-0.5.3.msi",
"url": "https://download.wireguard.com/windows-client/wireguard-amd64-0.5.3.msi",
"sha256": "76FCEC042C5989C5B816CD32EAED1E5B1C3B998A4B1C9ECA55F299E3314EF7E4",
"purpose": "Official WireGuard for Windows client (vendor-signed drivers install offline)"
},
{
"name": "vc_redist.x64.exe",
"url": "https://aka.ms/vs/17/release/vc_redist.x64.exe",
"sha256": "CC0FF0EB1DC3F5188AE6300FAEF32BF5BEEBA4BDD6E8E445A9184072096B713B",
"purpose": "VC++ v14 runtime required by the OpenCredential credential provider"
},
{
"name": "OpenCredentialInstaller-1.0.0.0.exe",
"url": "https://github.com/pedropablobm/OpenCredential/releases/download/v1.0.0.0/OpenCredentialInstaller-1.0.0.0.exe",
"sha256": "7687A99F0B3D6E910BBFB883C31231EB8C8F6E4439134CC3522A86CC63DA1A52",
"purpose": "OpenCredential (BSD-3 pGina fork) credential provider installer — LDAP-backed Windows logon"
}
],
"toolchain": {
"go": {
"version": "1.22.2",
"url": "https://go.dev/dl/go1.22.2.windows-amd64.zip"
},
"inno": {
"version": "7.0.2",
"url": "https://github.com/jrsoftware/issrc/releases/download/is-7_0_2/innosetup-7.0.2-x64.exe",
"sha256": "5AD54CA3DEF786F8F4212552E54CC6D8D61329E2D24A1CFEE0571D42C2684FF1"
}
}
}
+15 -12
View File
@@ -39,19 +39,22 @@ func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
os.Remove(socketPath)
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
os.MkdirAll(dir, 0755)
}
// 1. UNIX Domain Socket Listener. Windows passes an empty path and relies
// on the TCP loopback listener below.
if socketPath != "" {
os.Remove(socketPath)
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
os.MkdirAll(dir, 0755)
}
// 1. UNIX Domain Socket Listener
lnUnix, err := net.Listen("unix", socketPath)
if err == nil {
os.Chmod(socketPath, 0666)
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
go t.acceptLoop(lnUnix, stopCh)
} else {
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
lnUnix, err := net.Listen("unix", socketPath)
if err == nil {
os.Chmod(socketPath, 0666)
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
go t.acceptLoop(lnUnix, stopCh)
} else {
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
}
}
// 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890)
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"log"
"net/url"
"strings"
"time"
"github.com/hashicorp/mdns"
)
// mDNS local-discovery (AGENT_LOCAL_DISCOVERY_SPEC.md): when a
// theta-gateway/theta-proxy on the local network segment announces itself
// as fronting this agent's own server hostname, skip the relay/WAN path and
// talk to it directly. Opt-in via Config.PreferLocalDirectory.
//
// HARD RULE (non-negotiable): this changes WHERE we connect (DNS
// resolution via /etc/hosts), never WHETHER we trust what answers. Nothing
// here touches TLS/certificate validation -- the agent's normal TLS client
// code path is completely untouched, so a spoofed rogue mDNS announcement
// just produces a TLS handshake failure against the real hostname's cert,
// not a silent MITM. Do not "fix" a discovery-related connection failure by
// loosening cert checks; that would defeat the entire point of this rule.
const mdnsServiceName = "_theta-suite._tcp"
const mdnsPollInterval = 30 * time.Second
const mdnsLookupTimeout = 3 * time.Second
// StartLocalDiscovery runs until the process exits. No-op (logs once, then
// returns) if the feature isn't enabled or the target host can't be
// determined -- callers just `go StartLocalDiscovery(cm)` unconditionally.
func StartLocalDiscovery(cm *ConfigManager) {
cfg := cm.Get()
if !cfg.PreferLocalDirectory {
return
}
targetHost := hostFromURL(cfg.ServerURL)
if targetHost == "" {
log.Printf("[local-discovery] could not parse a hostname out of server_url %q -- disabled", cfg.ServerURL)
return
}
log.Printf("[local-discovery] enabled, watching for a local announcement fronting %s", targetHost)
currentlyOverridden := false
for {
ip := findLocalAnnouncement(targetHost)
switch {
case ip != "" && !currentlyOverridden:
if err := applyHostsOverride(map[string]string{targetHost: ip}); err != nil {
log.Printf("[local-discovery] found %s locally at %s but failed to apply hosts override: %v", targetHost, ip, err)
} else {
log.Printf("[local-discovery] %s announced locally at %s -- routing directly, skipping the relay/WAN path", targetHost, ip)
currentlyOverridden = true
}
case ip == "" && currentlyOverridden:
if err := applyHostsOverride(map[string]string{}); err != nil {
log.Printf("[local-discovery] lost local announcement for %s but failed to clear hosts override: %v", targetHost, err)
} else {
log.Printf("[local-discovery] %s no longer announced locally -- reverting to normal resolution", targetHost)
currentlyOverridden = false
}
}
time.Sleep(mdnsPollInterval)
}
}
func hostFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
return u.Hostname()
}
// findLocalAnnouncement browses for _theta-suite._tcp on the local segment
// and returns the announcing host's IP if its TXT "hosts" field lists
// targetHost, or "" if nothing matching is currently visible. mDNS is
// inherently link-local (multicast doesn't cross routers/VLANs), so "found
// vs not found" naturally tracks "on this LAN vs not" with no separate
// network-detection logic needed.
func findLocalAnnouncement(targetHost string) string {
entriesCh := make(chan *mdns.ServiceEntry, 8)
done := make(chan struct{})
var found string
go func() {
for entry := range entriesCh {
if entryAnnouncesHost(entry, targetHost) && found == "" {
if entry.AddrV4 != nil {
found = entry.AddrV4.String()
} else if entry.AddrV6 != nil {
found = entry.AddrV6.String()
}
}
}
close(done)
}()
// NOT mdns.Lookup() -- its DefaultParams() requests both IPv4 and IPv6,
// and the underlying client sends the v4 query, THEN the v6 query, and
// returns whatever error the v6 send produced -- aborting the entire
// Query() synchronously if IPv6 isn't available, even though the v4
// query it already sent may have already gotten (or will get) a valid
// response. Confirmed with a packet capture: the v4 query and its
// response both went out/came back fine, but Query() still returned
// "network is unreachable" (from the v6 send) before the response-
// listening loop ever started, so the entry was silently discarded.
// IPv6 multicast isn't guaranteed present on every host this runs on
// (many servers/containers are v4-only) -- disable it explicitly rather
// than depend on IPv6 being configured for IPv4 discovery to work at all.
params := mdns.DefaultParams(mdnsServiceName)
params.Entries = entriesCh
params.Timeout = mdnsLookupTimeout
params.DisableIPv6 = true
err := mdns.Query(params)
close(entriesCh)
<-done
if err != nil {
// Transient lookup errors (e.g. no multicast-capable interface at
// the moment) are expected on some networks -- treat as "not found
// right now", not a fatal condition.
return ""
}
return found
}
func entryAnnouncesHost(entry *mdns.ServiceEntry, targetHost string) bool {
for _, field := range entry.InfoFields {
// TXT format: "hosts=sso.example.com,proxy.example.com"
if !strings.HasPrefix(field, "hosts=") {
continue
}
hosts := strings.Split(strings.TrimPrefix(field, "hosts="), ",")
for _, h := range hosts {
if strings.TrimSpace(h) == targetHost {
return true
}
}
}
return false
}
+65 -8
View File
@@ -6,18 +6,53 @@ import (
"os"
"os/signal"
"strings"
"sync"
"sync/atomic"
"syscall"
)
// wsConnected is flipped atomically by connectWebSocket as the connection
// comes up and drops, so StartHomeMonitor can read it without a mutex.
var wsConnected atomic.Bool
// agentStopCh closes once the agent should exit: a SIGINT/SIGTERM in the
// foreground, or the SCM stop request when running as a Windows service. Both
// runAgent (foreground) and the service handler wait on it.
var (
agentStopOnce sync.Once
agentStopCh = make(chan struct{})
)
// currentCM lets the tray IPC server persist preferences (auto_vpn) and reset
// enrollment into the live config file. Set once in runAgent.
var currentCM *ConfigManager
// stopAgent signals the running agent to shut down. Idempotent.
func stopAgent() {
agentStopOnce.Do(func() { close(agentStopCh) })
}
func main() {
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
return
}
// Windows: when installed as a service the SCM starts us and svc.Run takes
// over the process lifecycle. Foreground (or any other OS) falls through to
// runAgent, which blocks until a signal arrives.
if maybeRunAsService() {
return
}
runAgent()
}
// runAgent runs the agent daemon until stopAgent is called.
func runAgent() {
log.Println("Starting Theta Agent...")
// Attempt to load configuration
configPath := "/etc/theta42/agent.yml"
configPath := defaultConfigPath()
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
configPath = os.Args[1]
}
@@ -28,7 +63,7 @@ func main() {
}
cfg := cm.Get()
log.Printf("Connecting to SSO Manager at %s", cfg.ServerURL)
log.Printf("Connecting to Theta Directory at %s", cfg.ServerURL)
log.Printf("Loaded capabilities: Telemetry=%v, LDAP=%v, Reboot=%v, Bash=%v",
cfg.Capabilities.Telemetry,
cfg.Capabilities.ConfigureLDAP,
@@ -36,16 +71,38 @@ func main() {
cfg.Capabilities.ArbitraryBash,
)
// Initialize system executor
// Initialize system executor and pin the platform ops the command
// dispatcher runs behind.
exec := &SystemExecutor{}
defaultPlatformOps = NewPlatformOps(cfg, exec)
currentCM = cm
// WebSocket connection to SSO Manager
// Seed the auto-VPN preference from disk; the tray checkbox updates it.
SetAutoVPN(cfg.AutoVPN)
// Tray IPC server — desktop tray connects here for status updates.
go globalTrayServer.Start()
// WebSocket connection to Theta Directory
go connectWebSocket(cm, exec)
// Block until signal is received
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
// Home detection + tray status push (polls public IP every 60s).
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() })
// mDNS local-discovery (MULTI_SITE_SPEC.md Appendix B) -- no-op unless
// prefer_local_directory is set.
go StartLocalDiscovery(cm)
// Foreground: exit on SIGINT/SIGTERM. A Windows service ignores these and
// is driven by its own handler.
go func() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
stopAgent()
}()
<-agentStopCh
fmt.Println("Shutting down Theta Agent...")
}
+57
View File
@@ -0,0 +1,57 @@
package main
// Platform paths. Windows has no /etc, /run or /tmp, so the agent's
// filesystem touchpoints move under %ProgramData%\Theta42, which SYSTEM (the
// service) and authenticated users (the tray) can both reach.
import (
"os"
"path/filepath"
"runtime"
)
// windowsDataDir returns %ProgramData%\Theta42.
func windowsDataDir() string {
pd := os.Getenv("ProgramData")
if pd == "" {
pd = `C:\ProgramData`
}
return filepath.Join(pd, "Theta42")
}
// defaultConfigPath returns the platform's agent.yml location.
func defaultConfigPath() string {
if runtime.GOOS == "windows" {
return filepath.Join(windowsDataDir(), "agent.yml")
}
return "/etc/theta42/agent.yml"
}
// defaultLdapSocketPath returns the local LDAP byte-pump socket. Windows has no
// AF_UNIX in a stable location that both service and clients share, so it
// relies on the TCP loopback listener (127.0.0.1:389) that ldapTunnel.start
// falls back to.
func defaultLdapSocketPath() string {
if runtime.GOOS == "windows" {
return ""
}
return "/run/theta/ldap.sock"
}
// windowsTraySocketPath returns the tray IPC socket path. It lives in the
// shared data dir (not the per-user temp dir) because the daemon runs as the
// SYSTEM service while the tray runs as the logged-in user; the installer
// grants Users write access to this directory and its children.
func windowsTraySocketPath() string {
return filepath.Join(windowsDataDir(), "tray.sock")
}
// defaultWireGuardConfPath returns where the pushed peer config is persisted.
// Linux uses /etc/wireguard so wg-quick finds it by interface name; Windows
// keeps it in the shared data dir next to the tray socket.
func defaultWireGuardConfPath(name string) string {
if runtime.GOOS == "windows" {
return filepath.Join(windowsDataDir(), "wg", name+".conf")
}
return filepath.Join("/etc/wireguard", name+".conf")
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !windows
package main
// NewPlatformOps returns the Linux/POSIX implementation (systemctl, journalctl,
// bash). It is the fallback for any non-Windows host.
func NewPlatformOps(cfg *Config, exec Executor) PlatformOps {
name := cfg.WireGuard.TunnelName
if name == "" {
name = "theta-mesh"
}
conf := cfg.WireGuard.Conf
if conf == "" {
conf = defaultWireGuardConfPath(name)
}
return &linuxPlatformOps{
exec: exec,
tunnelName: name,
confPath: conf,
}
}
+23
View File
@@ -0,0 +1,23 @@
//go:build windows
package main
// NewPlatformOps returns the Windows implementation.
func NewPlatformOps(cfg *Config, exec Executor) PlatformOps {
name := cfg.WireGuard.TunnelName
if name == "" {
name = "theta-mesh"
}
conf := cfg.WireGuard.Conf
if conf == "" {
conf = defaultWireGuardConfPath(name)
}
return &windowsPlatformOps{
exec: exec,
helperPath: cfg.DesktopHelper,
serviceName: cfg.ServiceName,
tunnelName: name,
confPath: conf,
wgExe: cfg.WireGuard.Executable,
}
}
+215
View File
@@ -0,0 +1,215 @@
package main
// linuxPlatformOps implements PlatformOps for Linux (and any POSIX host).
// The commands mirror what websocket.go executed before the PlatformOps split;
// behaviour is unchanged. Deliberately NOT build-tagged: it compiles on every
// OS (it only shells out to systemctl/journalctl/bash), so the shared command
// dispatch tests can pin it and run identically on Windows CI.
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
type linuxPlatformOps struct {
exec Executor
tunnelName string // WireGuard interface/tunnel name
confPath string // persisted peer config path
}
func (p *linuxPlatformOps) Reboot() ([]byte, error) {
return p.exec.Execute("reboot")
}
func (p *linuxPlatformOps) Shutdown() ([]byte, error) {
out, err := p.exec.Execute("shutdown", "-h", "now")
if err != nil {
return p.exec.Execute("poweroff")
}
return out, nil
}
func (p *linuxPlatformOps) FetchLogs(service string, lines int) ([]byte, error) {
return p.exec.Execute("journalctl", "-u", service, "-n", fmt.Sprintf("%d", lines), "--no-pager")
}
func (p *linuxPlatformOps) ServiceControl(service, action string) ([]byte, error) {
return p.exec.Execute("systemctl", action, service)
}
func (p *linuxPlatformOps) RunScript(script string) ([]byte, error) {
return p.exec.Execute("bash", "-c", script)
}
func (p *linuxPlatformOps) DesktopControl(subAction, targetUser string) ([]byte, error) {
switch subAction {
case "lock_session", "lock":
out, err := p.exec.Execute("loginctl", "lock-sessions")
if err != nil {
return p.exec.Execute("sh", "-c", "DISPLAY=:0 xdg-screensaver lock || DISPLAY=:0 xset dpms force off")
}
return out, nil
case "logout_user", "logout":
if targetUser != "" {
out, err := p.exec.Execute("loginctl", "terminate-user", targetUser)
if err != nil {
return p.exec.Execute("pkill", "-KILL", "-u", targetUser)
}
return out, nil
}
out, err := p.exec.Execute("loginctl", "terminate-session")
if err != nil {
return p.exec.Execute("pkill", "-9", "-f", "session-child")
}
return out, nil
case "display_off":
return p.exec.Execute("sh", "-c", "DISPLAY=:0 xset dpms force off || loginctl lock-sessions")
case "sleep_host", "sleep":
return p.exec.Execute("systemctl", "suspend")
default:
return nil, fmt.Errorf("unknown desktop action '%s'", subAction)
}
}
// ConfigureLDAP reproduces the original SSSD/nsswitch/PAM configuration flow.
func (p *linuxPlatformOps) ConfigureLDAP(configData string) error {
log.Println("Pushing updated SSSD configuration...")
_ = os.MkdirAll("/etc/sssd", 0755)
if err := p.exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
return fmt.Errorf("failed to write SSSD config: %w", err)
}
// Ensure /etc/nsswitch.conf enables sss for passwd, group, shadow, sudoers
if nssBytes, err := os.ReadFile("/etc/nsswitch.conf"); err == nil {
nssContent := string(nssBytes)
updatedNss := false
lines := strings.Split(nssContent, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if (strings.HasPrefix(trimmed, "passwd:") || strings.HasPrefix(trimmed, "group:") || strings.HasPrefix(trimmed, "shadow:") || strings.HasPrefix(trimmed, "sudoers:")) && !strings.Contains(trimmed, "sss") {
lines[i] = line + " sss"
updatedNss = true
}
}
if updatedNss {
_ = os.WriteFile("/etc/nsswitch.conf", []byte(strings.Join(lines, "\n")), 0644)
}
}
log.Println("Restarting SSSD service...")
if _, err := p.exec.Execute("systemctl", "restart", "sssd"); err != nil {
log.Printf("SSSD restart failed (%v), attempting auto-install of missing packages...", err)
if _, err2 := p.exec.Execute("sh", "-c", "DEBIAN_FRONTEND=noninteractive apt-get update -y -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || dnf install -y sssd sssd-ldap sssd-tools || yum install -y sssd sssd-ldap sssd-tools"); err2 == nil {
_, _ = p.exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
if _, err3 := p.exec.Execute("systemctl", "restart", "sssd"); err3 == nil {
writeSSSDSshConf(p.exec)
return nil
}
}
return fmt.Errorf("failed to restart sssd")
}
writeSSSDSshConf(p.exec)
return nil
}
// writeSSSDSshConf installs the AuthorizedKeysCommand config sshd needs to look
// up LDAP-backed SSH keys.
func writeSSSDSshConf(exec Executor) {
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
if err := os.WriteFile(sshConfPath, []byte(sshConfContent), 0644); err == nil {
_, _ = exec.Execute("systemctl", "reload", "sshd")
}
if sshdBytes, err2 := os.ReadFile("/etc/ssh/sshd_config"); err2 == nil {
sshdStr := string(sshdBytes)
if !strings.Contains(sshdStr, "sss_ssh_authorizedkeys") {
sshdStr += "\nAuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
_ = os.WriteFile("/etc/ssh/sshd_config", []byte(sshdStr), 0644)
_, _ = exec.Execute("systemctl", "reload", "sshd")
}
}
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
}
func (p *linuxPlatformOps) ApplyUpdate(downloadURL, checksum string) error {
tmpPath, err := downloadBinary(downloadURL, checksum)
if err != nil {
return err
}
selfPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to resolve current binary path: %w", err)
}
if resolved, err := filepath.EvalSymlinks(selfPath); err == nil {
selfPath = resolved
}
if err := os.Rename(tmpPath, selfPath); err != nil {
return fmt.Errorf("failed to replace binary: %w", err)
}
return nil
}
func (p *linuxPlatformOps) SelfRestart() {
os.Exit(0)
}
// ApplyWireGuard persists the peer config and brings the tunnel up with
// wg-quick (wg-quick reads /etc/wireguard/<name>.conf by interface name).
func (p *linuxPlatformOps) ApplyWireGuard(conf string) error {
if err := os.MkdirAll(filepath.Dir(p.confPath), 0700); err != nil {
return fmt.Errorf("wireguard: create config dir: %w", err)
}
if err := os.WriteFile(p.confPath, []byte(conf), 0600); err != nil {
return fmt.Errorf("wireguard: persist config: %w", err)
}
out, err := p.exec.Execute("wg-quick", "up", p.tunnelName)
if err != nil {
return fmt.Errorf("wireguard: wg-quick up %s: %v: %s", p.tunnelName, err, out)
}
return nil
}
func (p *linuxPlatformOps) RemoveWireGuard() error {
out, err := p.exec.Execute("wg-quick", "down", p.tunnelName)
if err != nil {
return fmt.Errorf("wireguard: wg-quick down %s: %v: %s", p.tunnelName, err, out)
}
return nil
}
// WireGuardState reports whether the tunnel interface exists.
func (p *linuxPlatformOps) WireGuardState() bool {
out, err := p.exec.Execute("ip", "link", "show", p.tunnelName)
return err == nil && len(out) > 0
}
// ConnectWireGuard brings the persisted config up unless already active.
func (p *linuxPlatformOps) ConnectWireGuard() error {
if p.WireGuardState() {
return nil
}
conf, err := os.ReadFile(p.confPath)
if err != nil {
return fmt.Errorf("wireguard: no persisted config at %s: %w", p.confPath, err)
}
return p.ApplyWireGuard(string(conf))
}
func (p *linuxPlatformOps) DisconnectWireGuard() error {
if !p.WireGuardState() {
return nil
}
return p.RemoveWireGuard()
}
// ApplyIAM is the Linux node-identity engine (sudoers.d, SSH keys, PAM access,
// SSSD cache flush + session kill).
func (p *linuxPlatformOps) ApplyIAM(payload IAMPayload) error {
return applyIAM(payload, p.exec)
}
+65
View File
@@ -0,0 +1,65 @@
package main
// PlatformOps abstracts the system operations the command dispatcher needs.
// Command dispatch, capability gating and Ed25519 verification are
// platform-neutral (websocket.go); the actual mechanics differ per OS:
//
// - linuxPlatformOps (platform_linuxops.go) — systemctl, journalctl, bash, ...
// - windowsPlatformOps (platform_windows.go) — sc.exe, powershell, the
// theta-agent-helper for session-0 ops and staged self-update.
//
// defaultPlatformOps is pinned at startup (runAgent) and swappable in tests so
// command dispatch can be exercised identically on any host.
type PlatformOps interface {
// Reboot restarts the host.
Reboot() ([]byte, error)
// Shutdown powers the host off.
Shutdown() ([]byte, error)
// FetchLogs returns the last `lines` log lines for a service.
FetchLogs(service string, lines int) ([]byte, error)
// ServiceControl runs an action (start/stop/restart/status/...) against a
// Windows service. Actions that don't map are executed best-effort.
ServiceControl(service, action string) ([]byte, error)
// RunScript executes an operator-supplied script (arbitrary_bash).
RunScript(script string) ([]byte, error)
// DesktopControl runs a desktop-control subaction (lock/logout/display/sleep)
// for a target user.
DesktopControl(subAction, targetUser string) ([]byte, error)
// ConfigureLDAP writes the pushed LDAP configuration. Linux configures
// SSSD; Windows manages logon via OpenCredential instead and declines.
ConfigureLDAP(configData string) error
// ApplyUpdate downloads, verifies and stages a new binary. The swap is
// platform-specific: Linux renames over the running binary, Windows writes
// a `.new` next to it and hands the swap to theta-agent-helper (the running
// exe is locked and the service must stop first).
ApplyUpdate(downloadURL, checksum string) error
// SelfRestart terminates the agent so a staged update takes effect. Linux
// exits; the Windows service handler stops itself (the helper restarts it).
SelfRestart()
// WireGuard mesh client (DESIGN-WINDOWS.md §5). ApplyWireGuard persists and
// brings up a peer tunnel; RemoveWireGuard tears it down; WireGuardState
// reports whether the tunnel is up; ConnectWireGuard/DisconnectWireGuard
// drive the tunnel from the persisted config (auto-VPN).
ApplyWireGuard(conf string) error
RemoveWireGuard() error
WireGuardState() (active bool)
ConnectWireGuard() error
DisconnectWireGuard() error
// ApplyIAM applies a verified node identity payload (DESIGN.md §6).
ApplyIAM(payload IAMPayload) error
}
// defaultPlatformOps is the ops implementation the command dispatcher uses.
// Pinned by runAgent after config load; tests override it directly.
var defaultPlatformOps = NewPlatformOps(&Config{}, &SystemExecutor{})
+329
View File
@@ -0,0 +1,329 @@
//go:build windows
package main
// windowsPlatformOps implements PlatformOps for Windows. Remote ops map to
// Windows equivalents:
//
// - reboot/shutdown → shutdown.exe
// - service control → sc.exe
// - fetch logs → Get-WinEvent (PowerShell)
// - arbitrary_bash → powershell -EncodedCommand (survives arbitrary quoting)
// - desktop control → theta-agent-helper (session-0 workaround, see
// DESIGN-WINDOWS.md §4)
// - self-update → staged `.new` + helper swap (the running exe is locked)
//
// configure_ldap is declined: Windows logon goes through the OpenCredential
// credential provider, which the installer configures directly.
import (
"encoding/base64"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"unicode/utf16"
"golang.org/x/sys/windows"
)
type windowsPlatformOps struct {
exec Executor
helperPath string // theta-agent-helper.exe (DESIGN-WINDOWS.md §8)
serviceName string // Windows service name (defaults to theta-agent)
tunnelName string // WireGuard tunnel/service name
confPath string // persisted peer config path
wgExe string // wireguard.exe client path ("" = PATH lookup)
}
func (p *windowsPlatformOps) Reboot() ([]byte, error) {
return p.exec.Execute("shutdown", "/r", "/t", "0")
}
func (p *windowsPlatformOps) Shutdown() ([]byte, error) {
return p.exec.Execute("shutdown", "/s", "/t", "0")
}
func (p *windowsPlatformOps) FetchLogs(service string, lines int) ([]byte, error) {
script := fmt.Sprintf("Get-WinEvent -LogName Application -MaxEvents %d -ErrorAction SilentlyContinue | Format-List TimeCreated, ProviderName, Id, LevelDisplayName, Message", lines)
return p.runPowerShell(script)
}
// ServiceControl maps systemd-style actions onto sc.exe. `restart` has no
// one-shot sc command, so it stops then starts; `status` is `sc query`.
func (p *windowsPlatformOps) ServiceControl(service, action string) ([]byte, error) {
switch action {
case "status":
return p.exec.Execute("sc.exe", "query", service)
case "restart":
// A service that is already stopped is not an error worth failing on.
if _, err := p.exec.Execute("sc.exe", "stop", service); err != nil {
log.Printf("[windows] sc stop %s: %v (continuing to start)", service, err)
}
out, err := p.exec.Execute("sc.exe", "start", service)
if err != nil && strings.Contains(string(out), "1056") {
// ERROR_SERVICE_ALREADY_RUNNING — the stop never landed; treat as up.
return out, nil
}
return out, err
default:
return p.exec.Execute("sc.exe", action, service)
}
}
func (p *windowsPlatformOps) RunScript(script string) ([]byte, error) {
return p.runPowerShell(script)
}
// runPowerShell invokes powershell with a UTF-16LE base64 -EncodedCommand. An
// operator script can contain arbitrary quotes, `&`, `%`, etc.; passing it as a
// plain argument would be mangled by cmd.exe/arg quoting rules, while the
// encoded form is byte-exact on both sides.
func (p *windowsPlatformOps) runPowerShell(script string) ([]byte, error) {
units := utf16.Encode([]rune(script))
b := make([]byte, len(units)*2)
for i, r := range units {
b[i*2] = byte(r)
b[i*2+1] = byte(r >> 8)
}
enc := base64.StdEncoding.EncodeToString(b)
return p.exec.Execute("powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc)
}
// DesktopControl routes ops that need an interactive desktop to the helper,
// which the service launches in the target session (DESIGN-WINDOWS.md §4).
// Sleep can run from session 0 and is handled in-process.
func (p *windowsPlatformOps) DesktopControl(subAction, targetUser string) ([]byte, error) {
var action string
switch subAction {
case "sleep_host", "sleep":
return nil, setSuspendState()
case "lock_session", "lock":
action = "lock"
case "display_off":
action = "display_off"
case "logout_user", "logout":
action = "logout"
default:
return nil, fmt.Errorf("unknown desktop action '%s'", subAction)
}
if p.helperPath == "" {
return nil, fmt.Errorf("desktop control requires theta-agent-helper (desktop_helper not configured)")
}
args := []string{action}
if targetUser != "" {
args = append(args, targetUser)
}
return p.exec.Execute(p.helperPath, args...)
}
// setSuspendState puts the machine to sleep. Requires SeShutdownPrivilege,
// which the SYSTEM service holds.
func setSuspendState() error {
powrprof := syscall.NewLazyDLL("powrprof.dll")
proc := powrprof.NewProc("SetSuspendState")
r, _, err := proc.Call(0, 0, 0) // Hibernate=false, ForceCritical=false, WakeIfDisarmed=false
if r == 0 {
return err
}
return nil
}
// ConfigureLDAP is not applicable on Windows: directory logon uses the
// OpenCredential credential provider configured by the installer.
func (p *windowsPlatformOps) ConfigureLDAP(configData string) error {
return fmt.Errorf("configure_ldap is not applicable on Windows; logon is managed by the OpenCredential credential provider")
}
// ApplyUpdate downloads and verifies the new binary to `<self>.new`, then hands
// the swap to the helper: the running service holds the exe open, so the agent
// must stop before the file can be replaced. The helper outlives the service
// (detached process), swaps the files, and restarts the service.
func (p *windowsPlatformOps) ApplyUpdate(downloadURL, checksum string) error {
selfPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to resolve current binary path: %w", err)
}
tmpPath, err := downloadBinary(downloadURL, checksum)
if err != nil {
return err
}
newPath := selfPath + ".new"
if err := moveFile(tmpPath, newPath); err != nil {
return fmt.Errorf("failed to stage new binary: %w", err)
}
helper := p.helperPath
if helper == "" {
return fmt.Errorf("desktop_helper not configured; cannot complete self-update")
}
service := p.serviceName
if service == "" {
service = "theta-agent"
}
log.Printf("[windows] staging self-update via helper (%s -> %s)", newPath, selfPath)
if err := spawnDetached(helper, "update", newPath, selfPath, service); err != nil {
return fmt.Errorf("failed to launch updater helper: %w", err)
}
return nil
}
func (p *windowsPlatformOps) SelfRestart() {
stopAgent()
}
// wireGuardExe resolves the WireGuard client executable: explicit config path,
// PATH lookup, or the default install location.
func (p *windowsPlatformOps) wireGuardExe() string {
if p.wgExe != "" {
return p.wgExe
}
const defaultInstall = `C:\Program Files\WireGuard\wireguard.exe`
if _, err := os.Stat(defaultInstall); err == nil {
return defaultInstall
}
return "wireguard.exe"
}
// ApplyWireGuard persists the peer config and installs it as a WireGuard
// service via the official client (wireguard.exe /installtunnelservice).
func (p *windowsPlatformOps) ApplyWireGuard(conf string) error {
if err := os.MkdirAll(filepath.Dir(p.confPath), 0700); err != nil {
return fmt.Errorf("wireguard: create config dir: %w", err)
}
if err := os.WriteFile(p.confPath, []byte(conf), 0600); err != nil {
return fmt.Errorf("wireguard: persist config: %w", err)
}
out, err := p.exec.Execute(p.wireGuardExe(), "/installtunnelservice", p.tunnelName, p.confPath)
if err != nil {
return fmt.Errorf("wireguard: installtunnelservice %s: %v: %s", p.tunnelName, err, out)
}
return nil
}
func (p *windowsPlatformOps) RemoveWireGuard() error {
out, err := p.exec.Execute(p.wireGuardExe(), "/uninstalltunnelservice", p.tunnelName)
if err != nil {
return fmt.Errorf("wireguard: uninstalltunnelservice %s: %v: %s", p.tunnelName, err, out)
}
return nil
}
// WireGuardState reports whether the WireGuardTunnel$<name> service is running.
func (p *windowsPlatformOps) WireGuardState() bool {
out, err := p.exec.Execute("sc.exe", "query", "WireGuardTunnel$"+p.tunnelName)
if err != nil {
return false
}
return strings.Contains(strings.ToUpper(string(out)), "RUNNING")
}
// ConnectWireGuard brings the persisted config up unless already active.
func (p *windowsPlatformOps) ConnectWireGuard() error {
if p.WireGuardState() {
return nil
}
conf, err := os.ReadFile(p.confPath)
if err != nil {
return fmt.Errorf("wireguard: no persisted config at %s: %w", p.confPath, err)
}
return p.ApplyWireGuard(string(conf))
}
func (p *windowsPlatformOps) DisconnectWireGuard() error {
if !p.WireGuardState() {
return nil
}
return p.RemoveWireGuard()
}
// ApplyIAM maps node identity onto local Windows security (DESIGN-WINDOWS.md
// §4): local groups for allowed_login_groups, per-user authorized_keys for
// OpenSSH, and session logoff via the helper for revocation.
func (p *windowsPlatformOps) ApplyIAM(payload IAMPayload) error {
ac := payload.AccessControl
for _, g := range ac.AllowedLoginGroups {
if g == "" {
continue
}
if _, err := p.exec.Execute("net", "localgroup", g, "/add"); err != nil {
log.Printf("[iam] net localgroup %s /add: %v", g, err)
}
}
if len(ac.SSHKeys) > 0 {
if err := applyWindowsSSHKeys(ac.SSHKeys); err != nil {
log.Printf("[iam] ssh keys: %v", err)
}
}
for _, u := range ac.RevokeUsers {
if u == "" {
continue
}
if p.helperPath != "" {
if _, err := p.exec.Execute(p.helperPath, "logout", u); err != nil {
log.Printf("[iam] revoke %s: %v", u, err)
}
} else {
log.Printf("[iam] revoke %s: desktop_helper not configured; no sessions logged off", u)
}
}
if len(ac.SudoRules) > 0 {
log.Println("[iam] sudo_rules have no direct Windows equivalent; mapped to local group membership (UAC elevation policy)")
}
return nil
}
// spawnDetached launches exe as a background process that survives this one.
func spawnDetached(exe string, args ...string) error {
cmd := execCommand(exe, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Start()
}
// execCommand is a thin wrapper so tests that build the windows ops can stub it.
func execCommand(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
}
// moveFile renames src onto dst, falling back to a copy when the source is on a
// different volume than the destination (os.Rename fails across volumes).
func moveFile(src, dst string) error {
if err := os.Rename(src, dst); err == nil {
return nil
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst)
return err
}
if err := out.Close(); err != nil {
os.Remove(dst)
return err
}
return os.Remove(src)
}
+294
View File
@@ -0,0 +1,294 @@
<#
.SYNOPSIS
Idempotent setup of the Theta Agent Windows dev/build environment.
.DESCRIPTION
Ensures everything needed to build and package the Windows agent, tray,
session helper, and the fully-offline Inno installer is present:
* Go toolchain (pinned version, user-space, no admin required)
* Inno Setup compiler (pinned version, per-user install, no admin required)
* vendor assets (WireGuard MSI, VC++ redist, OpenCredential CP),
fetched into installer/windows/vendor/ and verified against the pinned
sha256 in installer/windows/vendor-manifest.json
Safe to run repeatedly: each component is skipped when already installed
and valid, so `powershell -File scripts/setup-build-env.ps1` is a no-op on a
ready machine. Pass -Build to also compile the binaries and the installer.
.PARAMETER ToolDir
Where to install toolchains. Default: %LOCALAPPDATA%\Theta42\buildtools
.PARAMETER RepoRoot
Repository root. Default: the parent of this script's directory.
.PARAMETER SkipGo
Do not install/verify the Go toolchain.
.PARAMETER SkipInno
Do not install/verify Inno Setup.
.PARAMETER SkipVendor
Do not fetch/verify vendor assets.
.PARAMETER Build
After setup, build the agent/tray/helper for windows (amd64+arm64), run
`go test ./...`, and compile the installer with ISCC.
.PARAMETER CI
Non-interactive/CI mode: exit non-zero on any failure. (Used by the
GitHub Actions workflow so the runner fails loudly on a broken env.)
.EXAMPLE
powershell -ExecutionPolicy Bypass -File scripts\setup-build-env.ps1 -Build
#>
[CmdletBinding()]
param(
[string]$ToolDir = (Join-Path $env:LOCALAPPDATA 'Theta42\buildtools'),
[string]$RepoRoot = '',
[switch]$SkipGo,
[switch]$SkipInno,
[switch]$SkipVendor,
[switch]$Build,
[switch]$CI
)
$ErrorActionPreference = 'Stop'
$script:anyFailed = $false
# $PSScriptRoot is not populated inside the param() defaults on PowerShell 5.1.
if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
}
function Write-Step($msg) { Write-Host "== $msg" -ForegroundColor Cyan }
function Write-OK($msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
function Write-Skip($msg) { Write-Host " [skip] $msg" -ForegroundColor DarkGray }
function Write-Fail($msg) { Write-Host " [FAIL] $msg" -ForegroundColor Red; $script:anyFailed = $true }
function Write-Info($msg) { Write-Host " [info] $msg" -ForegroundColor Gray }
# ---------------------------------------------------------------- manifest ----
$manifestPath = Join-Path $RepoRoot 'installer\windows\vendor-manifest.json'
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
$vendorDir = Join-Path $RepoRoot 'installer\windows\vendor'
# -------------------------------------------------------------- Go toolchain --
function Get-GoVersion {
$v = (go version 2>$null)
if ($v -match 'go([0-9]+\.[0-9]+)') { return $matches[1] }
return ''
}
function Install-Go {
$needGo = $manifest.toolchain.go.version
$minGo = ($needGo -split '\.')[0] + '.' + ($needGo -split '\.')[1] # e.g. 1.22
$have = Get-GoVersion
if ($have -ne '' -and ([version]$have -ge [version]$minGo)) {
Write-OK "Go $have already available ($minGo+ required)"
return
}
$url = $manifest.toolchain.go.url
$goRoot = Join-Path $ToolDir "go$needGo"
# The official zip contains a top-level go/ folder -> goRoot\go\bin\go.exe.
$goBin = Join-Path $goRoot 'go\bin'
if (Test-Path (Join-Path $goBin 'go.exe')) {
Write-OK "Go $needGo found at $goRoot"
} else {
Write-Step "Installing Go $needGo (no admin required, zip extract)"
$zip = Join-Path $env:TEMP "go$needGo.windows-amd64.zip"
if (-not (Test-Path $zip)) {
Write-Info "Downloading $url"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
}
$staging = Join-Path $goRoot 'staging'
New-Item -ItemType Directory -Force -Path $staging | Out-Null
Expand-Archive -Path $zip -DestinationPath $staging -Force
# staging\go -> goRoot\go
Move-Item -Force (Join-Path $staging 'go') $goRoot
Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue
if (-not (Test-Path (Join-Path $goBin 'go.exe'))) {
Write-Fail "Go extract produced no bin\go.exe at $goBin"
return
}
Write-OK "Go $needGo installed at $goRoot"
}
Add-ToUserPath $goBin
$env:Path = "$goBin;$env:Path"
}
# --------------------------------------------------------------- Inno Setup ---
function Find-Iscc {
$candidates = @(
(Join-Path $ToolDir 'InnoSetup7\ISCC.exe'),
"$env:ProgramFiles\Inno Setup 7\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 7\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
)
foreach ($c in $candidates) {
if ($c -and (Test-Path $c)) { return $c }
}
return ''
}
function Install-Inno {
$iscc = Find-Iscc
if ($iscc) {
Write-OK "Inno Setup ISCC found at $iscc"
return $iscc
}
$inno = $manifest.toolchain.inno
$exe = Join-Path $env:TEMP "innosetup-$($inno.version)-x64.exe"
if (-not (Test-Path $exe)) {
Write-Step "Downloading Inno Setup $($inno.version)"
Invoke-WebRequest -Uri $inno.url -OutFile $exe -UseBasicParsing
$h = (Get-FileHash $exe -Algorithm SHA256).Hash.ToUpper()
if ($h -ne $inno.sha256) {
Remove-Item $exe -Force
Write-Fail "Inno Setup installer sha256 mismatch: $h"
return ''
}
Write-OK "Downloaded and verified Inno Setup installer"
}
$dest = Join-Path $ToolDir 'InnoSetup7'
Write-Step "Installing Inno Setup per-user to $dest"
# /CURRENTUSER installs without admin; /DIR is honored in that mode.
$p = Start-Process -FilePath $exe -ArgumentList @(
'/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/CURRENTUSER',"/DIR=$dest"
) -Wait -PassThru
if ($p.ExitCode -ne 0 -or -not (Test-Path (Join-Path $dest 'ISCC.exe'))) {
Write-Fail "Inno Setup install failed (exit $($p.ExitCode)); ISCC not found at $dest"
return ''
}
Write-OK "Inno Setup installed; ISCC at $(Join-Path $dest 'ISCC.exe')"
Add-ToUserPath $dest
return (Join-Path $dest 'ISCC.exe')
}
# ------------------------------------------------- PATH (idempotent) ----------
function Add-ToUserPath($dir) {
if (-not $dir -or -not (Test-Path $dir)) { return }
$userPath = [Environment]::GetEnvironmentVariable('Path','User')
if ($userPath -and ($userPath -split ';' -contains $dir)) {
Write-Skip "$dir already on user PATH"
return
}
$newPath = if ($userPath) { "$userPath;$dir" } else { $dir }
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-OK "Added $dir to user PATH"
}
# ------------------------------------------------ Vendor assets (idempotent) --
function Fetch-Asset($asset) {
$dest = Join-Path $vendorDir $asset.name
$ok = $false
if (Test-Path $dest) {
$h = (Get-FileHash $dest -Algorithm SHA256).Hash.ToUpper()
if ($h -eq $asset.sha256) {
$ok = $true
Write-Skip "$($asset.name) present and checksum verified"
} else {
Write-Info "$($asset.name): stale or corrupt (got $h), re-downloading"
}
}
if (-not $ok) {
Write-Info "Downloading $($asset.name)"
Invoke-WebRequest -Uri $asset.url -OutFile $dest -UseBasicParsing
$h = (Get-FileHash $dest -Algorithm SHA256).Hash.ToUpper()
if ($h -ne $asset.sha256) {
Remove-Item $dest -Force
Write-Fail "$($asset.name): sha256 mismatch ($h) - expected $($asset.sha256)"
return
}
Write-OK "$($asset.name) downloaded and checksum verified"
}
Set-Content -Path "$dest.sha256" -Value $asset.sha256 -NoNewline
}
function Ensure-Vendor {
Write-Step "Vendor assets (pinned in vendor-manifest.json)"
New-Item -ItemType Directory -Force -Path $vendorDir | Out-Null
foreach ($a in $manifest.assets) {
Fetch-Asset $a
}
}
# ----------------------------------------------------------------- Verify -----
function Assert-BuildReady {
Write-Step "Verification"
$go = Get-GoVersion
if ($go) { Write-OK "go $go" } elseif (-not $SkipGo) { Write-Fail 'go not found' }
$iscc = Find-Iscc
if ($iscc) { Write-OK "ISCC $iscc" } elseif (-not $SkipInno) { Write-Fail 'ISCC not found' }
foreach ($a in $manifest.assets) {
$dest = Join-Path $vendorDir $a.name
if (Test-Path $dest) {
$h = (Get-FileHash $dest -Algorithm SHA256).Hash.ToUpper()
if ($h -eq $a.sha256) { Write-OK "$($a.name) verified" }
else { Write-Fail "$($a.name) checksum mismatch" }
} elseif (-not $SkipVendor) {
Write-Fail "$($a.name) missing"
}
}
if ($script:anyFailed) {
if ($CI) { throw 'Build environment setup failed' }
exit 1
}
Write-Host "Build environment ready." -ForegroundColor Green
}
# ------------------------------------------------------------------- Build ----
function Invoke-Build {
Write-Step "Building agent, tray, helper (windows amd64+arm64)"
$dist = Join-Path $RepoRoot 'dist'
New-Item -ItemType Directory -Force -Path $dist | Out-Null
$flags = '-s -w'
# The tray and helper are GUI-subsystem binaries: no console window pops up
# when the installer starts the tray or the service spawns the helper.
$guiFlags = '-s -w -H=windowsgui'
foreach ($arch in @('amd64','arm64')) {
$env:GOOS='windows'; $env:GOARCH=$arch; $env:CGO_ENABLED='0'
go build "-ldflags=$flags" -o (Join-Path $dist "theta-agent-windows-$arch.exe") $RepoRoot
if ($LASTEXITCODE -ne 0) { Write-Fail "build agent windows/$arch failed"; return }
go build "-ldflags=$guiFlags" -o (Join-Path $dist "theta-agent-tray-windows-$arch.exe") (Join-Path $RepoRoot 'cmd\theta-agent-tray')
if ($LASTEXITCODE -ne 0) { Write-Fail "build tray windows/$arch failed"; return }
go build "-ldflags=$guiFlags" -o (Join-Path $dist "theta-agent-helper-windows-$arch.exe") (Join-Path $RepoRoot 'cmd\theta-agent-helper')
if ($LASTEXITCODE -ne 0) { Write-Fail "build helper windows/$arch failed"; return }
}
Remove-Item Env:GOOS,Env:GOARCH,Env:CGO_ENABLED -ErrorAction SilentlyContinue
Write-Step "Running go test ./..."
Push-Location $RepoRoot
go test ./...
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Fail 'go test failed'; return }
Pop-Location
$iscc = Find-Iscc
if (-not $iscc) { Write-Fail 'ISCC not found; cannot build installer'; return }
Write-Step "Compiling installer with ISCC"
& $iscc (Join-Path $RepoRoot 'installer\windows\installer.iss')
if ($LASTEXITCODE -ne 0) { Write-Fail 'ISCC compile failed' }
}
# ------------------------------------------------------------------- Main -----
New-Item -ItemType Directory -Force -Path $ToolDir | Out-Null
if (-not $SkipGo) { Install-Go }
if (-not $SkipInno) { $null = Install-Inno }
if (-not $SkipVendor) { Ensure-Vendor }
Assert-BuildReady
if ($Build) { Invoke-Build }
if ($script:anyFailed) {
if ($CI) { throw 'Setup failed' }
exit 1
}
+9 -4
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
)
@@ -68,10 +69,14 @@ func TestRenderSecrets(t *testing.T) {
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
}
// The target should be 0600 (holds secrets).
info, _ := os.Stat(target)
if info.Mode().Perm() != 0600 {
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
// The target should be 0600 (holds secrets). Windows has no POSIX modes and
// reports 0666 regardless; the intent there is covered by the ACLs the
// installer sets on the target directory.
if runtime.GOOS != "windows" {
info, _ := os.Stat(target)
if info.Mode().Perm() != 0600 {
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
}
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package main
// handleServiceCommand is a no-op outside Windows: the agent is managed by
// systemd/init there.
func handleServiceCommand(args []string) {}
+94
View File
@@ -0,0 +1,94 @@
//go:build windows
package main
// Service management CLI (theta-agent install-service / remove-service). The
// Inno installer also drives this rather than hand-rolling `sc create`.
import (
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/windows/svc/mgr"
)
func handleServiceCommand(args []string) {
action := "install"
if len(args) > 0 {
action = args[0]
}
switch action {
case "install":
installService()
case "remove":
removeService()
default:
fmt.Fprintf(os.Stderr, "usage: theta-agent %s [install|remove]\n", action)
os.Exit(1)
}
}
func installService() {
exe, err := os.Executable()
if err != nil {
logFatal("cannot resolve agent path: %v", err)
}
m, err := mgr.Connect()
if err != nil {
logFatal("cannot connect to service manager: %v", err)
}
defer m.Disconnect()
s, err := m.OpenService("theta-agent")
if err == nil {
s.Close()
fmt.Println("[+] theta-agent service already exists")
return
}
cfg := mgr.Config{
DisplayName: "Theta Agent",
Description: "Theta42 unified endpoint management agent (telemetry, remote ops, LDAP logon, WireGuard mesh)",
ServiceStartName: "LocalSystem",
StartType: mgr.StartAutomatic,
}
s, err = m.CreateService("theta-agent", exe, cfg, "is", "auto")
if err != nil {
logFatal("cannot create service: %v", err)
}
defer s.Close()
// Start it immediately so the daemon binds the tray IPC socket and the
// credential provider is live without waiting for a reboot.
if err := s.Start(); err != nil {
logFatal("cannot start service: %v", err)
}
fmt.Printf("[+] Registered and started theta-agent service (%s)\n", filepath.Base(exe))
}
func removeService() {
m, err := mgr.Connect()
if err != nil {
logFatal("cannot connect to service manager: %v", err)
}
defer m.Disconnect()
s, err := m.OpenService("theta-agent")
if err != nil {
fmt.Println("[!] theta-agent service not found")
return
}
defer s.Close()
if err := s.Delete(); err != nil {
logFatal("cannot delete service: %v", err)
}
fmt.Println("[+] Removed theta-agent service")
}
func logFatal(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "[!] "+format+"\n", args...)
os.Exit(1)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package main
// maybeRunAsService is a no-op on non-Windows hosts: the agent runs in the
// foreground under systemd / init and is driven by SIGTERM.
func maybeRunAsService() bool {
return false
}
+65
View File
@@ -0,0 +1,65 @@
//go:build windows
package main
// Windows service wrapper. The installer registers theta-agent as a SYSTEM
// auto-start service; when the SCM launches the process this file takes over.
// The service is up before any user session, which is what allows the
// credential provider to validate LDAP logins at Ctrl+Alt+Del and what makes
// the tray IPC socket path (shared data dir) correct.
import (
"log"
"os"
"golang.org/x/sys/windows/svc"
)
// maybeRunAsService returns true (and runs the service loop) when launched by
// the Service Control Manager; false when run in a console.
func maybeRunAsService() bool {
isSvc, err := svc.IsWindowsService()
if err != nil || !isSvc {
return false
}
if err := svc.Run("theta-agent", &agentService{}); err != nil {
log.Printf("service: %v", err)
os.Exit(1)
}
return true
}
type agentService struct{}
func (s *agentService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
changes <- svc.Status{State: svc.StartPending}
done := make(chan struct{})
go func() {
defer close(done)
runAgent()
}()
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
for {
select {
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
changes <- svc.Status{State: svc.StopPending}
stopAgent()
<-done
changes <- svc.Status{State: svc.Stopped}
return false, 0
}
case <-done:
changes <- svc.Status{State: svc.Stopped}
return false, 0
}
}
}
+466 -54
View File
@@ -7,6 +7,9 @@ import (
"log"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
@@ -17,6 +20,56 @@ import (
"github.com/shirou/gopsutil/v3/mem"
)
type CPUDetails struct {
Model string `json:"model"`
Cores int `json:"cores"`
Threads int `json:"threads"`
MHz float64 `json:"mhz"`
}
type RAMDetails struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
BuffersCacheBytes uint64 `json:"buffers_cache_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedPercent float64 `json:"used_percent"`
BuffersCachePercent float64 `json:"buffers_cache_percent"`
FreePercent float64 `json:"free_percent"`
}
type DiskItem struct {
Mountpoint string `json:"mountpoint"`
Device string `json:"device"`
FSType string `json:"fstype"`
DriveType string `json:"drivetype"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsagePercent float64 `json:"usage_percent"`
}
type LoggedUser struct {
User string `json:"user"`
Terminal string `json:"terminal"`
Host string `json:"host"`
Started int64 `json:"started"`
}
type HostDetails struct {
StaticHostname string `json:"static_hostname"`
IconName string `json:"icon_name"`
Chassis string `json:"chassis"`
MachineID string `json:"machine_id"`
BootID string `json:"boot_id"`
OS string `json:"os"`
Kernel string `json:"kernel"`
Arch string `json:"arch"`
HardwareVendor string `json:"hardware_vendor"`
HardwareModel string `json:"hardware_model"`
FirmwareVersion string `json:"firmware_version"`
FirmwareDate string `json:"firmware_date"`
}
type DiscoveryData struct {
Hostname string `json:"hostname"`
IPs []string `json:"ip_addresses"`
@@ -24,19 +77,31 @@ type DiscoveryData struct {
OS string `json:"os"`
Kernel string `json:"kernel"`
CPUModel string `json:"cpu"`
CPUDetails CPUDetails `json:"cpu_details"`
RAMTotalGB float64 `json:"ram_total_gb"`
RAMDetails RAMDetails `json:"ram_details"`
DiskTotalGB float64 `json:"disk_total_gb"`
Disks []DiskItem `json:"disks"`
LoggedUsers []LoggedUser `json:"logged_users"`
HostDetails HostDetails `json:"host_details"`
Version string `json:"version"`
Location string `json:"location"`
Capabilities map[string]interface{} `json:"capabilities"`
}
type TelemetryData struct {
CPUUsagePercent float64 `json:"cpu_usage_percent"`
RAMUsagePercent float64 `json:"ram_usage_percent"`
DiskUsagePercent float64 `json:"disk_usage_percent"`
ZFSHealth string `json:"zfs_health,omitempty"`
GPUUsage float64 `json:"gpu_usage_percent,omitempty"`
Timestamp string `json:"timestamp"`
CPUUsagePercent float64 `json:"cpu_usage_percent"`
CPUDetails CPUDetails `json:"cpu_details"`
RAMUsagePercent float64 `json:"ram_usage_percent"`
RAMDetails RAMDetails `json:"ram_details"`
DiskUsagePercent float64 `json:"disk_usage_percent"`
Disks []DiskItem `json:"disks"`
LoggedUsers []LoggedUser `json:"logged_users"`
HostDetails HostDetails `json:"host_details"`
Version string `json:"version"`
ZFSHealth string `json:"zfs_health,omitempty"`
GPUUsage float64 `json:"gpu_usage_percent,omitempty"`
Timestamp string `json:"timestamp"`
}
func getPublicIP() string {
@@ -62,6 +127,306 @@ func getPublicIP() string {
return ""
}
func collectCPUDetails() CPUDetails {
cpuInfo, _ := cpu.Info()
model := "Unknown"
cores := 0
mhz := 0.0
if len(cpuInfo) > 0 {
model = cpuInfo[0].ModelName
if model == "" || strings.TrimSpace(model) == "154" || len(model) < 4 {
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "model name") {
parts := strings.Split(line, ":")
if len(parts) > 1 {
model = strings.TrimSpace(parts[1])
break
}
}
}
}
}
if model == "" {
model = cpuInfo[0].Model
}
cores = int(cpuInfo[0].Cores)
mhz = cpuInfo[0].Mhz
}
threads := runtime.NumCPU()
if t, err := cpu.Counts(true); err == nil && t > 0 {
threads = t
}
if cores <= 0 {
if c, err := cpu.Counts(false); err == nil && c > 0 {
cores = c
} else {
cores = threads
}
}
return CPUDetails{
Model: model,
Cores: cores,
Threads: threads,
MHz: mhz,
}
}
func collectRAMDetails() RAMDetails {
vm, err := mem.VirtualMemory()
if err != nil || vm == nil {
return RAMDetails{}
}
bufCache := vm.Buffers + vm.Cached
total := float64(vm.Total)
usedPct := 0.0
bufPct := 0.0
freePct := 0.0
if total > 0 {
usedPct = (float64(vm.Used) / total) * 100.0
bufPct = (float64(bufCache) / total) * 100.0
freePct = (float64(vm.Free) / total) * 100.0
}
return RAMDetails{
TotalBytes: vm.Total,
UsedBytes: vm.Used,
BuffersCacheBytes: bufCache,
FreeBytes: vm.Free,
UsedPercent: usedPct,
BuffersCachePercent: bufPct,
FreePercent: freePct,
}
}
func getDriveType(device string) string {
devName := filepath.Base(device)
devName = strings.TrimRight(devName, "0123456789p")
if strings.HasPrefix(devName, "nvme") {
return "NVMe"
}
rotPath := filepath.Join("/sys/block", devName, "queue/rotational")
data, err := os.ReadFile(rotPath)
if err == nil {
val := strings.TrimSpace(string(data))
if val == "0" {
return "SSD"
} else if val == "1" {
return "HDD"
}
}
return "SSD/HDD"
}
func collectLoggedUsers() []LoggedUser {
var list []LoggedUser
seen := make(map[string]bool)
// 1. Try loginctl list-sessions --no-legend (systemd logind)
exec := SystemExecutor{}
if out, err := exec.Execute("loginctl", "list-sessions", "--no-legend"); err == nil && len(out) > 0 {
lines := strings.Split(string(out), "\n")
for _, line := range lines {
fields := strings.Fields(line)
// Format: SESSION UID USER SEAT TTY STATE IDLE SINCE
// e.g. c2 1000 william seat0 tty7 active no -
if len(fields) >= 3 {
user := fields[2]
term := ""
if len(fields) >= 5 && fields[4] != "-" {
term = fields[4]
}
key := fmt.Sprintf("%s@%s", user, term)
if !seen[key] && user != "" {
seen[key] = true
list = append(list, LoggedUser{
User: user,
Terminal: term,
Host: "localhost",
Started: time.Now().Unix(),
})
}
}
}
}
// 2. Fallback to gopsutil / who if loginctl returned nothing
if len(list) == 0 {
users, err := host.Users()
if err == nil {
for _, u := range users {
key := fmt.Sprintf("%s@%s:%s", u.User, u.Terminal, u.Host)
if !seen[key] {
seen[key] = true
list = append(list, LoggedUser{
User: u.User,
Terminal: u.Terminal,
Host: u.Host,
Started: int64(u.Started),
})
}
}
}
if len(list) == 0 {
out, err := exec.Execute("who")
if err == nil && len(out) > 0 {
lines := strings.Split(string(out), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 2 {
user := fields[0]
term := fields[1]
hostStr := ""
if len(fields) >= 5 {
hostStr = strings.Trim(fields[4], "()")
}
key := fmt.Sprintf("%s@%s:%s", user, term, hostStr)
if !seen[key] {
seen[key] = true
list = append(list, LoggedUser{
User: user,
Terminal: term,
Host: hostStr,
Started: time.Now().Unix(),
})
}
}
}
}
}
}
return list
}
func collectDiskItems() []DiskItem {
var items []DiskItem
partitions, err := disk.Partitions(true)
if err != nil || len(partitions) == 0 {
d, err2 := disk.Usage("/")
if err2 == nil {
items = append(items, DiskItem{
Mountpoint: "/",
Device: d.Path,
FSType: d.Fstype,
DriveType: getDriveType(d.Path),
TotalBytes: d.Total,
UsedBytes: d.Used,
FreeBytes: d.Free,
UsagePercent: d.UsedPercent,
})
}
return items
}
seen := make(map[string]bool)
for _, p := range partitions {
if !strings.HasPrefix(p.Device, "/dev/") || strings.HasPrefix(p.Device, "/dev/loop") {
continue
}
if seen[p.Mountpoint] {
continue
}
seen[p.Mountpoint] = true
u, err := disk.Usage(p.Mountpoint)
if err != nil || u.Total == 0 {
continue
}
fstype := p.Fstype
if fstype == "" {
fstype = u.Fstype
}
items = append(items, DiskItem{
Mountpoint: p.Mountpoint,
Device: p.Device,
FSType: fstype,
DriveType: getDriveType(p.Device),
TotalBytes: u.Total,
UsedBytes: u.Used,
FreeBytes: u.Free,
UsagePercent: u.UsedPercent,
})
}
if len(items) == 0 {
d, err2 := disk.Usage("/")
if err2 == nil {
items = append(items, DiskItem{
Mountpoint: "/",
Device: d.Path,
FSType: d.Fstype,
DriveType: getDriveType(d.Path),
TotalBytes: d.Total,
UsedBytes: d.Used,
FreeBytes: d.Free,
UsagePercent: d.UsedPercent,
})
}
}
return items
}
func collectHostDetails() HostDetails {
details := HostDetails{}
exec := SystemExecutor{}
out, err := exec.Execute("hostnamectl")
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
switch key {
case "Static hostname":
details.StaticHostname = val
case "Icon name":
details.IconName = val
case "Chassis":
details.Chassis = val
case "Machine ID":
details.MachineID = val
case "Boot ID":
details.BootID = val
case "Operating System":
details.OS = val
case "Kernel":
details.Kernel = val
case "Architecture":
details.Arch = val
case "Hardware Vendor":
details.HardwareVendor = val
case "Hardware Model":
details.HardwareModel = val
case "Firmware Version":
details.FirmwareVersion = val
case "Firmware Date":
details.FirmwareDate = val
}
}
}
}
if details.HardwareVendor == "" {
if d, err := os.ReadFile("/sys/class/dmi/id/sys_vendor"); err == nil {
details.HardwareVendor = strings.TrimSpace(string(d))
}
}
if details.HardwareModel == "" {
if d, err := os.ReadFile("/sys/class/dmi/id/product_name"); err == nil {
details.HardwareModel = strings.TrimSpace(string(d))
}
}
if details.FirmwareVersion == "" {
if d, err := os.ReadFile("/sys/class/dmi/id/bios_version"); err == nil {
details.FirmwareVersion = strings.TrimSpace(string(d))
}
}
if details.FirmwareDate == "" {
if d, err := os.ReadFile("/sys/class/dmi/id/bios_date"); err == nil {
details.FirmwareDate = strings.TrimSpace(string(d))
}
}
return details
}
const AgentVersion = "v2.1.2"
// CollectDiscoveryData gathers static host information.
func CollectDiscoveryData(cfg *Config) DiscoveryData {
h, _ := host.Info()
@@ -76,36 +441,56 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
}
}
vm, _ := mem.VirtualMemory()
d, _ := disk.Usage("/")
vm := collectRAMDetails()
disks := collectDiskItems()
cpuDet := collectCPUDetails()
loggedUsers := collectLoggedUsers()
cpuInfo, _ := cpu.Info()
cpuModel := "Unknown"
if len(cpuInfo) > 0 {
cpuModel = cpuInfo[0].Model
pubIP := ""
if cfg.DetectPublicIP() {
pubIP = getPublicIP()
}
pubIP := getPublicIP()
diskTotalGB := 0.0
for _, d := range disks {
if d.Mountpoint == "/" {
diskTotalGB = float64(d.TotalBytes) / (1024 * 1024 * 1024)
break
}
}
if diskTotalGB == 0 && len(disks) > 0 {
diskTotalGB = float64(disks[0].TotalBytes) / (1024 * 1024 * 1024)
}
hostDet := collectHostDetails()
return DiscoveryData{
Hostname: h.Hostname,
IPs: ips,
PublicIP: pubIP,
OS: fmt.Sprintf("%s %s", h.OS, h.Platform),
Kernel: h.KernelVersion,
CPUModel: cpuModel,
RAMTotalGB: float64(vm.Total) / (1024 * 1024 * 1024),
DiskTotalGB: float64(d.Total) / (1024 * 1024 * 1024),
Location: cfg.Location,
Hostname: h.Hostname,
IPs: ips,
PublicIP: pubIP,
OS: fmt.Sprintf("%s %s", h.OS, h.Platform),
Kernel: h.KernelVersion,
CPUModel: cpuDet.Model,
CPUDetails: cpuDet,
RAMTotalGB: float64(vm.TotalBytes) / (1024 * 1024 * 1024),
RAMDetails: vm,
DiskTotalGB: diskTotalGB,
Disks: disks,
LoggedUsers: loggedUsers,
HostDetails: hostDet,
Version: AgentVersion,
Location: cfg.Location,
Capabilities: map[string]interface{}{
"telemetry": cfg.Capabilities.Telemetry,
"configure_ldap": cfg.Capabilities.ConfigureLDAP,
"ldap_tunnel": cfg.Capabilities.LdapTunnel,
"secrets": cfg.Capabilities.Secrets,
"iam": cfg.Capabilities.IAM,
"reboot": cfg.Capabilities.Reboot,
"service_control": cfg.Capabilities.ServiceControl,
"arbitrary_bash": cfg.Capabilities.ArbitraryBash,
"telemetry": cfg.Capabilities.Telemetry,
"configure_ldap": cfg.Capabilities.ConfigureLDAP,
"ldap_tunnel": cfg.Capabilities.LdapTunnel,
"secrets": cfg.Capabilities.Secrets,
"iam": cfg.Capabilities.IAM,
"reboot": cfg.Capabilities.Reboot,
"shutdown": true,
"desktop_controls": true,
"service_control": cfg.Capabilities.ServiceControl,
"arbitrary_bash": cfg.Capabilities.ArbitraryBash,
},
}
}
@@ -113,21 +498,41 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
// CollectTelemetryData gathers real-time performance metrics including ZFS and GPU.
func CollectTelemetryData(exec Executor) TelemetryData {
cpuPerc, _ := cpu.Percent(time.Second, false)
vm, _ := mem.VirtualMemory()
d, _ := disk.Usage("/")
vm := collectRAMDetails()
disks := collectDiskItems()
cpuDet := collectCPUDetails()
loggedUsers := collectLoggedUsers()
hostDet := collectHostDetails()
cpuVal := 0.0
if len(cpuPerc) > 0 {
cpuVal = cpuPerc[0]
}
diskVal := 0.0
for _, d := range disks {
if d.Mountpoint == "/" {
diskVal = d.UsagePercent
break
}
}
if diskVal == 0 && len(disks) > 0 {
diskVal = disks[0].UsagePercent
}
return TelemetryData{
CPUUsagePercent: cpuVal,
CPUDetails: cpuDet,
RAMUsagePercent: vm.UsedPercent,
DiskUsagePercent: d.UsedPercent,
ZFSHealth: collectZFSHealth(exec),
GPUUsage: collectGPUUsage(exec),
Timestamp: time.Now().Format(time.RFC3339),
RAMDetails: vm,
DiskUsagePercent: diskVal,
Disks: disks,
LoggedUsers: loggedUsers,
HostDetails: hostDet,
Version: AgentVersion,
ZFSHealth: collectZFSHealth(exec),
GPUUsage: collectGPUUsage(exec),
Timestamp: time.Now().Format(time.RFC3339),
}
}
@@ -157,8 +562,9 @@ func collectGPUUsage(exec Executor) float64 {
func StartTelemetryLoop(c MessageWriter, cm *ConfigManager, exec Executor, stopCh <-chan struct{}) {
cfg := cm.Get()
// 1. Immediate Discovery Push
// 1. Immediate Discovery Push & Initial Telemetry Frame
pushDiscovery(c, cfg)
pushTelemetry(c, exec)
// If telemetry capability is disabled in agent.yml, return early after discovery
if !cfg.Capabilities.Telemetry {
@@ -189,27 +595,33 @@ func StartTelemetryLoop(c MessageWriter, cm *ConfigManager, exec Executor, stopC
lastIPs = currentIPs
}
telemetry := CollectTelemetryData(exec)
payload, _ := json.Marshal(WSMessage{
Type: "telemetry",
Payload: map[string]interface{}{
"cpu_usage_percent": telemetry.CPUUsagePercent,
"ram_usage_percent": telemetry.RAMUsagePercent,
"disk_usage_percent": telemetry.DiskUsagePercent,
"zfs_health": telemetry.ZFSHealth,
"gpu_usage_percent": telemetry.GPUUsage,
"timestamp": telemetry.Timestamp,
},
})
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
log.Printf("Failed to stream telemetry: %v", err)
return
}
pushTelemetry(c, exec)
}
}
}()
}
func pushTelemetry(c MessageWriter, exec Executor) {
telemetry := CollectTelemetryData(exec)
payload, _ := json.Marshal(WSMessage{
Type: "telemetry",
Payload: map[string]interface{}{
"cpu_usage_percent": telemetry.CPUUsagePercent,
"cpu_details": telemetry.CPUDetails,
"ram_usage_percent": telemetry.RAMUsagePercent,
"ram_details": telemetry.RAMDetails,
"disk_usage_percent": telemetry.DiskUsagePercent,
"disks": telemetry.Disks,
"logged_users": telemetry.LoggedUsers,
"host_details": telemetry.HostDetails,
"zfs_health": telemetry.ZFSHealth,
"gpu_usage_percent": telemetry.GPUUsage,
"timestamp": telemetry.Timestamp,
},
})
_ = c.WriteMessage(websocket.TextMessage, payload)
}
func collectIPs() []string {
var ips []string
addrs, _ := net.InterfaceAddrs()
@@ -251,6 +663,6 @@ func pushDiscovery(c MessageWriter, cfg *Config) {
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
log.Printf("Failed to send discovery data: %v", err)
} else {
log.Println("Discovery data pushed to SSO Manager.")
log.Println("Discovery data pushed to Theta Directory.")
}
}
BIN
View File
Binary file not shown.
Binary file not shown.
+15
View File
File diff suppressed because one or more lines are too long
+86
View File
@@ -0,0 +1,86 @@
package main
// IPC protocol between the root daemon (theta-agent) and the desktop tray
// (theta-agent-tray). Both processes communicate via a Unix domain socket at
// /run/theta/tray.sock (created by the daemon; the tray connects to it).
//
// Protocol: newline-delimited JSON. The daemon streams TrayStatus messages to
// any connected tray client. The tray sends TrayCommand messages to the daemon.
import (
"encoding/json"
"runtime"
)
// TraySocketPaths are the paths the daemon tries to bind, in order.
//
// Windows has no /run or /tmp. The daemon runs as the SYSTEM service while the
// tray runs as the logged-in user, so the socket lives in the shared data dir
// (%ProgramData%\Theta42, created by the installer with a Users-writable ACL)
// rather than a per-user temp dir — the two processes must agree on one path.
// Linux keeps the original /run/theta path with a /tmp fallback.
var TraySocketPaths = func() []string {
if runtime.GOOS == "windows" {
return []string{windowsTraySocketPath()}
}
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
}()
// TraySocket is the canonical socket path the tray dials. It matches the last
// entry in TraySocketPaths on each platform.
var TraySocket = func() string {
if runtime.GOOS == "windows" {
return windowsTraySocketPath()
}
return "/tmp/theta-tray.sock"
}()
// TrayColor represents the icon color state.
type TrayColor string
const (
ColorRed TrayColor = "red" // not connected to directory
ColorYellow TrayColor = "yellow" // connected, but not home
ColorGreen TrayColor = "green" // connected, on home LAN (public IP matches)
ColorBlue TrayColor = "blue" // connected + WireGuard tunnel active to home site
)
// TrayStatus is sent from the daemon to the tray on every state change.
type TrayStatus struct {
Color TrayColor `json:"color"`
Connected bool `json:"connected"` // directory WebSocket is up
IsHome bool `json:"is_home"` // public IP matches home site
VPNActive bool `json:"vpn_active"` // WireGuard tunnel is up
AutoVPN bool `json:"auto_vpn"` // auto-connect preference
SiteName string `json:"site_name"` // configured site name
AgentPublicIP string `json:"agent_public_ip"` // this agent's detected public IP
HomePublicIP string `json:"home_public_ip"` // home site's public IP (from directory)
StatusText string `json:"status_text"` // one-line human description
}
// TrayCommand is sent from the tray to the daemon.
type TrayCommand struct {
Command string `json:"command"` // "set_auto_vpn", "vpn_connect", "vpn_disconnect"
Value bool `json:"value"` // used by set_auto_vpn
}
func encodeTrayStatus(s TrayStatus) ([]byte, error) {
b, err := json.Marshal(s)
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
func decodeTrayCommand(data []byte) (TrayCommand, error) {
var cmd TrayCommand
err := json.Unmarshal(data, &cmd)
return cmd, err
}
func decodeTrayStatus(data []byte) (TrayStatus, error) {
var s TrayStatus
err := json.Unmarshal(data, &s)
return s, err
}
+242
View File
@@ -0,0 +1,242 @@
package main
// Tray IPC server — runs inside the root daemon.
//
// Listens on /run/theta/tray.sock. Whenever the tray connects, it immediately
// gets the current status and then receives a push on every state change.
// Commands from the tray (auto-VPN toggle, connect/disconnect) come back over
// the same connection.
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
)
type trayServer struct {
mu sync.RWMutex
status TrayStatus
clients map[net.Conn]struct{}
}
var globalTrayServer = &trayServer{
clients: make(map[net.Conn]struct{}),
}
// Start begins listening on the tray socket. Call from main() as a goroutine.
func (ts *trayServer) Start() {
var l net.Listener
var boundPath string
var err error
for _, p := range TraySocketPaths {
os.Remove(p)
os.MkdirAll(filepath.Dir(p), 0755) //nolint:errcheck
l, err = net.Listen("unix", p)
if err == nil {
boundPath = p
os.Chmod(p, 0666) //nolint:errcheck
break
}
}
if err != nil || boundPath == "" {
log.Printf("[tray-ipc] cannot listen on tray socket: %v (tray icon disabled)", err)
return
}
log.Printf("[tray-ipc] listening on %s", boundPath)
for {
conn, err := l.Accept()
if err != nil {
log.Printf("[tray-ipc] accept error: %v", err)
return
}
go ts.handleConn(conn)
}
}
func (ts *trayServer) handleConn(conn net.Conn) {
ts.mu.Lock()
ts.clients[conn] = struct{}{}
// Send current status immediately on connect.
b, _ := encodeTrayStatus(ts.status)
conn.Write(b) //nolint:errcheck
ts.mu.Unlock()
defer func() {
ts.mu.Lock()
delete(ts.clients, conn)
ts.mu.Unlock()
conn.Close()
}()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Bytes()
cmd, err := decodeTrayCommand(line)
if err != nil {
continue
}
ts.handleCommand(cmd)
}
}
func (ts *trayServer) handleCommand(cmd TrayCommand) {
switch cmd.Command {
case "set_auto_vpn":
ts.mu.Lock()
ts.status.AutoVPN = cmd.Value
ts.mu.Unlock()
SetAutoVPN(cmd.Value)
if currentCM != nil {
if err := currentCM.PersistAutoVPN(cmd.Value); err != nil {
log.Printf("[tray-ipc] could not persist auto_vpn: %v", err)
}
}
log.Printf("[tray-ipc] auto_vpn set to %v", cmd.Value)
case "vpn_connect":
log.Printf("[tray-ipc] VPN connect requested")
if err := defaultPlatformOps.ConnectWireGuard(); err != nil {
log.Printf("[tray-ipc] VPN connect failed: %v", err)
}
case "vpn_disconnect":
log.Printf("[tray-ipc] VPN disconnect requested")
if err := defaultPlatformOps.DisconnectWireGuard(); err != nil {
log.Printf("[tray-ipc] VPN disconnect failed: %v", err)
}
case "reinit":
log.Printf("[tray-ipc] clearing enrollment (re-enroll requested)")
if currentCM != nil {
if err := currentCM.ClearEnrollment(); err != nil {
log.Printf("[tray-ipc] could not clear enrollment: %v", err)
}
}
case "open_config":
log.Printf("[tray-ipc] opening config %s", defaultConfigPath())
openInDefaultViewer(defaultConfigPath())
default:
log.Printf("[tray-ipc] unknown command: %q", cmd.Command)
}
}
// openInDefaultViewer opens a file/folder with the platform default handler.
func openInDefaultViewer(path string) {
if runtime.GOOS == "windows" {
// explorer /select,<path> opens the parent folder with the file
// selected. explorer is a GUI app, so no console window appears.
_ = exec.Command("explorer", "/select,"+path).Start()
return
}
_ = exec.Command("xdg-open", path).Start()
}
// Push broadcasts an updated status to all connected tray clients.
func (ts *trayServer) Push(status TrayStatus) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.status = status
if len(ts.clients) == 0 {
return
}
b, err := encodeTrayStatus(status)
if err != nil {
return
}
for conn := range ts.clients {
_, err := conn.Write(b)
if err != nil {
// Dead connection; handleConn will clean it up.
conn.Close()
}
}
}
// UpdateTrayStatus computes the current TrayColor from the known state
// and pushes it to all connected tray clients.
func UpdateTrayStatus(connected bool, agentPublicIP, homePublicIP string, vpnActive, autoVPN bool, siteName, serverURL string) {
color := ColorRed
statusText := "Not connected to directory"
isHome := false
if connected {
// Server URL is local (localhost, 127.0.0.1, LAN IP, or .local)
isLocalServer := strings.Contains(serverURL, "localhost") ||
strings.Contains(serverURL, "127.0.0.1") ||
strings.Contains(serverURL, ".local") ||
strings.Contains(serverURL, "192.168.") ||
strings.Contains(serverURL, "10.")
// On Home LAN if:
// 1) Both agent & home public IPs are known and match, OR
// 2) Connecting to a local/LAN SSO server, OR
// 3) homePublicIP is not yet set by directory (default to local home)
if (homePublicIP != "" && agentPublicIP != "" && agentPublicIP == homePublicIP) || isLocalServer || homePublicIP == "" {
isHome = true
}
if vpnActive {
color = ColorBlue
statusText = fmt.Sprintf("VPN active → %s", siteName)
} else if isHome {
color = ColorGreen
statusText = fmt.Sprintf("Home — %s", siteName)
} else {
color = ColorYellow
statusText = "Connected (away from home)"
}
}
globalTrayServer.Push(TrayStatus{
Color: color,
Connected: connected,
IsHome: isHome,
VPNActive: vpnActive,
AutoVPN: autoVPN,
SiteName: siteName,
AgentPublicIP: agentPublicIP,
HomePublicIP: homePublicIP,
StatusText: statusText,
})
}
// sendTrayCommand sends a single JSON command to the daemon from the tray process.
func sendTrayCommand(cmd TrayCommand) error {
conn, err := net.Dial("unix", TraySocket)
if err != nil {
return fmt.Errorf("cannot connect to daemon IPC socket: %w", err)
}
defer conn.Close()
return json.NewEncoder(conn).Encode(cmd)
}
// receiveTrayStatus opens a persistent connection and calls cb on every status
// update. Blocks until the connection is lost. Call in a goroutine.
func receiveTrayStatus(cb func(TrayStatus)) error {
conn, err := net.Dial("unix", TraySocket)
if err != nil {
return fmt.Errorf("cannot connect to daemon IPC socket: %w", err)
}
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
s, err := decodeTrayStatus(scanner.Bytes())
if err == nil {
cb(s)
}
}
if err := scanner.Err(); err != nil && err != io.EOF {
return err
}
return nil
}
+153 -101
View File
@@ -12,7 +12,6 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
@@ -147,7 +146,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
// credential every 5s just floods the SSO and its audit log
// forever, so back off hard and say plainly what is wrong.
if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the SSO Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the Theta Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
@@ -156,7 +155,8 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
continue
}
log.Println("Successfully connected to SSO Manager.")
log.Println("Successfully connected to Theta Directory.")
wsConnected.Store(true)
stopCh := make(chan struct{})
@@ -173,7 +173,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
socketPath := cfg.LdapSocket
if socketPath == "" {
socketPath = "/run/theta/ldap.sock"
socketPath = defaultLdapSocketPath()
}
go tunnel.start(socketPath, stopCh)
}
@@ -213,7 +213,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
// than at Dial.
if websocket.IsCloseError(err, closeUnauthorized, closeRevoked, closeTokenRotated) {
authRejected = true
log.Printf("Server closed the connection: %v. This agent's token is not valid for that SSO — re-enroll it and update agent.yml.", err)
log.Printf("Server closed the connection: %v. This agent's token is not valid for that Theta Directory — re-enroll it and update agent.yml.", err)
} else {
log.Println("WebSocket read error:", err)
}
@@ -230,6 +230,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
}
// Cleanup on disconnect
wsConnected.Store(false)
close(stopCh)
c.Close()
@@ -295,7 +296,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
}
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
out, err := exec.Execute("journalctl", "-u", serviceName, "-n", fmt.Sprintf("%d", linesCount), "--no-pager")
out, err := defaultPlatformOps.FetchLogs(serviceName, linesCount)
if err != nil {
log.Printf("Log fetch failed: %v", err)
sendResponse("error", "failed to fetch logs")
@@ -327,20 +328,16 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
}
log.Printf("Updating binary from %s...", urlStr)
if err := downloadAndUpdateBinary(urlStr, checksum); err != nil {
if err := defaultPlatformOps.ApplyUpdate(urlStr, checksum); err != nil {
log.Printf("Update failed: %v", err)
sendResponse("error", fmt.Sprintf("update failed: %v", err))
return
}
sendResponse("ok", "update applied successfully; restarting agent...")
os.Exit(0)
defaultPlatformOps.SelfRestart()
case "config":
// A config frame carrying credentials means the server accepted our
// join key and enrolled this host. Persist what it issued -- our own
// per-agent token and the public key to pin -- so the next connection
// authenticates as this agent rather than re-enrolling, and so signed
// commands can be verified. This is what lets an install ship with only
// a join key and still end up fully configured.
// join key and enrolled this host.
if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled {
token, _ := msg.Payload["auth_token"].(string)
pubKey, _ := msg.Payload["public_key"].(string)
@@ -348,11 +345,17 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
log.Printf("Enrolled, but could not persist credentials: %v", err)
log.Printf("This agent will re-enroll on every reconnect until %s is writable.", cm.configPath)
} else {
log.Printf("Enrolled with the SSO. Credentials written to %s; the join key is no longer needed.", cm.configPath)
log.Printf("Enrolled with Theta Directory. Credentials written to %s; the join key is no longer needed.", cm.configPath)
}
sendResponse("ok", "enrollment stored")
return
}
// Extract the home site's public IP if the server pushes it, so the
// tray icon can determine whether we are on the home LAN.
if sitePublicIP, ok := msg.Payload["site_public_ip"].(string); ok && sitePublicIP != "" {
SetHomePublicIP(sitePublicIP)
log.Printf("[home-detect] home site public IP: %s", sitePublicIP)
}
log.Printf("Received config payload: %v", msg.Payload)
sendResponse("ok", "Configuration received")
case "reboot":
@@ -366,12 +369,88 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return
}
log.Printf("Executing reboot...")
if _, err := exec.Execute("reboot"); err != nil {
if _, err := defaultPlatformOps.Reboot(); err != nil {
log.Printf("Reboot failed: %v", err)
sendResponse("error", "reboot failed")
return
}
sendResponse("ok", "system rebooting")
case "shutdown":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
if !cfg.Capabilities.Reboot {
log.Println("Shutdown rejected: capability disabled in agent.yml")
sendResponse("error", "shutdown capability disabled")
return
}
log.Printf("Executing shutdown...")
sendResponse("ok", "system shutting down")
if _, err := defaultPlatformOps.Shutdown(); err != nil {
log.Printf("Shutdown failed: %v", err)
}
return
case "desktop_control", "lock_session", "logout_user", "display_off", "sleep_host":
subAction, _ := msg.Payload["subAction"].(string)
if subAction == "" {
subAction = msg.Type
}
targetUser, _ := msg.Payload["user"].(string)
log.Printf("Executing desktop control action '%s' for user '%s'...", subAction, targetUser)
switch subAction {
case "lock_session", "lock", "logout_user", "logout", "display_off", "sleep_host", "sleep":
default:
sendResponse("error", fmt.Sprintf("unknown desktop action '%s'", subAction))
return
}
out, err := defaultPlatformOps.DesktopControl(subAction, targetUser)
errMsg := ""
if err != nil {
errMsg = err.Error()
}
respMap := map[string]interface{}{
"status": "ok",
"subAction": subAction,
"output": string(out),
"error": errMsg,
}
respPayload, _ := json.Marshal(respMap)
c.WriteMessage(websocket.TextMessage, respPayload)
return
case "systemd_action":
serviceName, _ := msg.Payload["service"].(string)
action, _ := msg.Payload["action"].(string)
if serviceName == "" {
sendResponse("error", "service name required")
return
}
if action == "" {
action = "status"
}
if action != "status" && !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
log.Printf("Executing systemctl %s %s...", action, serviceName)
out, err := defaultPlatformOps.ServiceControl(serviceName, action)
errMsg := ""
if err != nil {
errMsg = err.Error()
}
respMap := map[string]interface{}{
"status": "ok",
"service": serviceName,
"action": action,
"output": string(out),
"error": errMsg,
}
respPayload, _ := json.Marshal(respMap)
c.WriteMessage(websocket.TextMessage, respPayload)
return
case "service_restart":
serviceName, ok := msg.Payload["service"].(string)
if !ok || !cfg.Capabilities.CanManageService(serviceName) {
@@ -380,7 +459,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return
}
log.Printf("Restarting service %s...", serviceName)
if _, err := exec.Execute("systemctl", "restart", serviceName); err != nil {
if _, err := defaultPlatformOps.ServiceControl(serviceName, "restart"); err != nil {
log.Printf("Service restart failed: %v", err)
sendResponse("error", "restart failed")
return
@@ -404,70 +483,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return
}
log.Println("Pushing updated SSSD configuration...")
_ = os.MkdirAll("/etc/sssd", 0755)
if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
log.Printf("Failed to write SSSD config: %v", err)
sendResponse("error", "failed to write config")
if err := defaultPlatformOps.ConfigureLDAP(configData); err != nil {
log.Printf("LDAP configuration failed: %v", err)
sendResponse("error", err.Error())
return
}
// Ensure /etc/nsswitch.conf enables sss for passwd, group, shadow, sudoers
if nssBytes, err := os.ReadFile("/etc/nsswitch.conf"); err == nil {
nssContent := string(nssBytes)
updatedNss := false
lines := strings.Split(nssContent, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if (strings.HasPrefix(trimmed, "passwd:") || strings.HasPrefix(trimmed, "group:") || strings.HasPrefix(trimmed, "shadow:") || strings.HasPrefix(trimmed, "sudoers:")) && !strings.Contains(trimmed, "sss") {
lines[i] = line + " sss"
updatedNss = true
}
}
if updatedNss {
_ = os.WriteFile("/etc/nsswitch.conf", []byte(strings.Join(lines, "\n")), 0644)
}
}
log.Println("Restarting SSSD service...")
if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil {
log.Printf("SSSD restart failed (%v), attempting auto-install of missing packages...", err)
if _, err2 := exec.Execute("sh", "-c", "DEBIAN_FRONTEND=noninteractive apt-get update -y -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || dnf install -y sssd sssd-ldap sssd-tools || yum install -y sssd sssd-ldap sssd-tools"); err2 == nil {
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
if _, err3 := exec.Execute("systemctl", "restart", "sssd"); err3 == nil {
// Configure SSH AuthorizedKeysCommand
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
_ = os.WriteFile(sshConfPath, []byte(sshConfContent), 0644)
_, _ = exec.Execute("systemctl", "reload", "sshd")
sendResponse("ok", "LDAP configuration updated")
return
}
}
sendResponse("error", "failed to restart sssd")
return
}
// Ensure /etc/ssh/sshd_config.d/theta-sssd.conf is created for SSH AuthorizedKeysCommand
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
if err := os.WriteFile(sshConfPath, []byte(sshConfContent), 0644); err == nil {
_, _ = exec.Execute("systemctl", "reload", "sshd")
}
if sshdBytes, err2 := os.ReadFile("/etc/ssh/sshd_config"); err2 == nil {
sshdStr := string(sshdBytes)
if !strings.Contains(sshdStr, "sss_ssh_authorizedkeys") {
sshdStr += "\nAuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
_ = os.WriteFile("/etc/ssh/sshd_config", []byte(sshdStr), 0644)
_, _ = exec.Execute("systemctl", "reload", "sshd")
}
}
// Ensure PAM mkhomedir is enabled
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
sendResponse("ok", "LDAP configuration updated")
case "render_secrets":
if !verifySignature(cfg, msg) {
@@ -503,12 +524,53 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return
}
log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID)
if err := applyIAM(payload, exec); err != nil {
if err := defaultPlatformOps.ApplyIAM(payload); err != nil {
log.Printf("IAM apply failed: %v", err)
sendResponse("error", fmt.Sprintf("iam apply failed: %v", err))
return
}
sendResponse("ok", "iam applied")
case "wireguard_apply":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
if !cfg.Capabilities.WireGuard {
log.Println("WireGuard apply rejected: capability disabled in agent.yml")
sendResponse("error", "wireguard capability disabled")
return
}
conf, _ := msg.Payload["config"].(string)
if conf == "" {
sendResponse("error", "missing wireguard config")
return
}
log.Printf("Applying WireGuard peer config...")
if err := defaultPlatformOps.ApplyWireGuard(conf); err != nil {
log.Printf("WireGuard apply failed: %v", err)
sendResponse("error", fmt.Sprintf("wireguard apply failed: %v", err))
return
}
SetVPNActive(true)
sendResponse("ok", "wireguard applied")
case "wireguard_remove":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
if !cfg.Capabilities.WireGuard {
log.Println("WireGuard remove rejected: capability disabled in agent.yml")
sendResponse("error", "wireguard capability disabled")
return
}
log.Printf("Removing WireGuard tunnel...")
if err := defaultPlatformOps.RemoveWireGuard(); err != nil {
log.Printf("WireGuard remove failed: %v", err)
sendResponse("error", fmt.Sprintf("wireguard remove failed: %v", err))
return
}
SetVPNActive(false)
sendResponse("ok", "wireguard removed")
case "arbitrary_bash":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
@@ -528,7 +590,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
}
log.Printf("Executing remote script: %s", script)
out, err := exec.Execute("bash", "-c", script)
out, err := defaultPlatformOps.RunScript(script)
if err != nil {
log.Printf("Script execution failed: %v", err)
sendResponse("error", fmt.Sprintf("execution failed: %v", err))
@@ -555,20 +617,24 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
}
}
func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
// downloadBinary fetches the new binary, verifies its SHA-256, and returns the
// path of a temp file holding it. The platform's ApplyUpdate decides how to
// install it (Linux renames over the running exe; Windows stages a `.new` and
// swaps via the helper once the service stops).
func downloadBinary(downloadURL string, expectedSHA256 string) (string, error) {
resp, err := http.Get(downloadURL)
if err != nil {
return fmt.Errorf("http fetch failed: %w", err)
return "", fmt.Errorf("http fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected http status: %s", resp.Status)
return "", fmt.Errorf("unexpected http status: %s", resp.Status)
}
tmpFile, err := os.CreateTemp("", "theta-agent-update-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
return "", fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
@@ -578,32 +644,18 @@ func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
if _, err := io.Copy(writer, resp.Body); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to save binary: %w", err)
return "", fmt.Errorf("failed to save binary: %w", err)
}
tmpFile.Close()
actualSHA256 := fmt.Sprintf("%x", hasher.Sum(nil))
if !strings.EqualFold(actualSHA256, strings.TrimSpace(expectedSHA256)) {
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, actualSHA256)
return "", fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, actualSHA256)
}
if err := os.Chmod(tmpPath, 0755); err != nil {
return fmt.Errorf("failed to set executable permissions: %w", err)
return "", fmt.Errorf("failed to set executable permissions: %w", err)
}
selfPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to resolve current binary path: %w", err)
}
resolvedPath, err := filepath.EvalSymlinks(selfPath)
if err == nil {
selfPath = resolvedPath
}
if err := os.Rename(tmpPath, selfPath); err != nil {
return fmt.Errorf("failed to replace binary: %w", err)
}
return nil
return tmpPath, nil
}
+55
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"testing"
)
@@ -218,6 +219,47 @@ func TestHandleCommand(t *testing.T) {
expectedStatus: "error",
expectedCmd: nil,
},
{
name: "wireguard_apply allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{WireGuard: true},
},
msg: WSMessage{
Type: "wireguard_apply",
Payload: map[string]interface{}{
"config": "[Interface]\nAddress = 10.0.0.2/32\n",
},
},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"wg-quick", "up", "theta-mesh"},
},
{
name: "wireguard_apply denied",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{WireGuard: false},
},
msg: WSMessage{
Type: "wireguard_apply",
Payload: map[string]interface{}{"config": "[Interface]\n"},
},
signed: true,
expectedStatus: "error",
expectedCmd: nil,
},
{
name: "wireguard_remove allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{WireGuard: true},
},
msg: WSMessage{Type: "wireguard_remove"},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"wg-quick", "down", "theta-mesh"},
},
{
name: "heartbeat_ack is silently ignored",
cfg: &Config{
@@ -245,6 +287,19 @@ func TestHandleCommand(t *testing.T) {
mockConn := &MockConn{}
mockExec := &MockExecutor{}
cm := &ConfigManager{current: tc.cfg}
// The dispatch tests assert the exact command lines the Linux
// executor produces; pin the platform ops so they behave the same
// on any CI host (Windows included). A temp dir holds the WireGuard
// config so wireguard_apply's persistence step is writable.
prevOps := defaultPlatformOps
defaultPlatformOps = &linuxPlatformOps{
exec: mockExec,
tunnelName: "theta-mesh",
confPath: filepath.Join(t.TempDir(), "theta-mesh.conf"),
}
defer func() { defaultPlatformOps = prevOps }()
msg := tc.msg
if tc.signed {
msg.Payload = sign(t, msg.Payload)