Compare commits

...

37 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
wmantly aa1f85a9d5 feat: release v1.6.0 with get-secret CLI, Zero-Trust LDAP tunnel, auto-updates and service restarts 2026-08-07 23:26:13 -04:00
wmantly 8f0158eb9f Add LDAP byte-pump tunnel, secrets rendering, and IAM engine
See CHANGELOG.md for the full breakdown. Summary:

- ldap_tunnel.go: serves a local unix socket for SSSD/PAM and relays raw
  bytes to the SSO over the existing WSS channel (ldap_tunnel messages);
  the agent never parses LDAP (DESIGN.md §4). Adds safeWriter to
  serialize WebSocket writes now that telemetry, heartbeat, the LDAP
  tunnel, and command responses all share one connection.
- secrets.go: renders local templates ({{ bao "path#key" }} placeholders)
  by fetching node-scoped values from the SSO and writing the target
  atomically at 0600, on a signed render_secrets command (DESIGN.md §5).
  demo/ has minimal bash + Node consumers of the rendered file.
- iam.go: applies signed node IAM pushes -- sudoers.d rules (visudo -c
  validated), SSH AuthorizedKeysCommand keys, /etc/security/access.conf,
  and revocation via sss_cache -E + pkill -u (DESIGN.md §6).
- Capability reporting: the agent's enabled capabilities ride along in
  its discovery frame so the SSO can show them in the Directory.
- DESIGN.md: the v2 protocol design this implements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 17:12:58 -04:00
wmantly d128807431 Merge pull request #6 from theta42/fix/rebuild-agent-binary
fix: rebuild the prebuilt linux/amd64 binary for join keys (v1.5.1)
2026-08-06 10:44:41 -04:00
wmantly 7d36318885 fix: rebuild the prebuilt linux/amd64 binary for join keys (v1.5.1)
theta-suite's setup.sh installs the committed theta-agent-linux-amd64
rather than building from source, so a stale binary means the fix in this
repo never reaches the host.

The v1.5.0 binary predated join-key support: setup.sh would write a
join_key into agent.yml that the running agent did not understand, and it
would have looped on "close 4001: Unauthorized" -- the same trap the
v1.3.0 heartbeat fix hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:43:51 -04:00
wmantly 877e6caffa Merge pull request #5 from theta42/feat/join-key-enrollment
feat: join-key enrollment (v1.5.0, protocol v1.2.0 §1.1)
2026-08-06 10:40:05 -04:00
wmantly 0a5011cd42 feat: join-key enrollment (v1.5.0, protocol v1.2.0 1.1)
Installing the agent with one key is now all it takes to add a host.

New `join_key` config field, presented while auth_token is empty. The SSO
exchanges it for this agent's own token and the public key it must pin,
both delivered in the config frame; the agent persists them and blanks
the join key. Nothing has to be copied between two machines by hand.

PersistEnrollment rewrites only the credential lines -- line-based rather
than a YAML round-trip -- so operator comments, the capability matrix and
formatting survive. It re-reads afterwards so the credential is live
without a restart, and keeps the file 0600.

The connect URL carries ?hostname= so a self-enrolling host is named
after itself, and the agent refuses to connect at all (with a long
back-off) when it has no credential rather than presenting an empty one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:34:06 -04:00
wmantly dc14274edc Merge pull request #4 from theta42/fix/agent-enrollment-auth
sec: fail-closed verification + server-issued enrollment (v1.4.0)
2026-08-05 18:55:12 -04:00
wmantly d47d08ecab sec: fail-closed verification + server-issued enrollment (v1.4.0)
Implements protocol v1.2.0.

verifySignature() returned true when no public_key was configured,
logging "skipping signature verification". The SSO installer never wrote
a public_key, so a default install executed reboot, service_restart,
configure_ldap, arbitrary_bash and update_binary UNVERIFIED from anything
that could reach its socket. An agent that cannot verify now refuses.

Canonicalization also disagreed with the server. Go's encoding/json
escapes <, > and & by default; JSON.stringify does not. Any payload
containing them hashed differently on each side and failed verification
-- for arbitrary_bash that is most real scripts (`>` redirection, `&&`).
Now uses json.Encoder with SetEscapeHTML(false), trailing newline
trimmed.

The SSO now rejects tokens it did not issue. Handles its close codes
(4001/4002/4003/4004) and backs off 5 minutes on an enrollment failure
instead of retrying every 5s forever. The connect log no longer prints
the URL, which carried ?token=.

install.sh gains --public-key and warns when none is configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:43:26 -04:00
wmantly 51750d01ec fix: ship the rebuilt binary with heartbeat_ack fix + test (v1.3.1) (#3)
The prebuilt theta-agent-linux-amd64 was built before the v1.3.0 heartbeat_ack
fix, so the installed agent still logged 'Unknown command type: heartbeat_ack'.
Rebuild it with the fix; add a test asserting heartbeat_ack is silently ignored
(no response, no command, no log).
2026-08-05 02:28:58 -04:00
57 changed files with 7259 additions and 170 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/
+160
View File
@@ -5,6 +5,166 @@ 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
- **On-demand CLI Secret Fetching (`theta-agent get-secret <key>`).** Fetch raw secret values directly over TLS without writing plaintext files to disk. Supports `theta-agent get-secrets --env` (formatted for Systemd `EnvironmentFile`) and `theta-agent get-secrets --json`.
- **CLI Self-Update and Re-enrollment Commands.** Added `theta-agent update` and `theta-agent reinitialize [--join-key <key>]` CLI options with automated service restarts (`sssd`, `sshd`).
- **Zero-Trust LDAP WebSocket Tunnel (`ldap_tunnel`).** Auto-starts local `/run/theta/ldap.sock` and `127.0.0.1:3890` loopback listeners.
- **Dynamic Site Matching.** Auto-detects WAN IP for public site matching and discovery.
### Fixed
- **SSSD Socket Activation Exit Code 17.** Removed legacy `services` key in generated `sssd.conf` to satisfy modern systemd socket activation requirements.
## [Unreleased] - LDAP byte-pump tunnel (DESIGN.md §4)
The agent now serves a local LDAP socket for SSSD/PAM. It is a **pure byte
pump**: it forwards raw LDAP bytes to the SSO over the WSS channel, and the SSO
relays them into its real OpenLDAP and pipes the response back. The agent never
parses LDAP.
### Added
- **`ldap_tunnel` capability + `ldap_socket` config.** When enabled, the agent
binds a unix socket (default `/run/theta/ldap.sock`, root:theta `0660`) and
relays bytes bidirectionally as `ldap_tunnel` messages over the existing WSS
channel. Point SSSD at it with `ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock`.
- **`safeWriter`** — serializes WebSocket writes. Gorilla allows only one
concurrent writer, but telemetry, heartbeat, the LDAP tunnel and command
responses all write to the same socket; without this, concurrent writes
corrupt the stream.
- **Offline behavior:** when the WSS is down the agent cannot forward bytes, so
it closes local socket connections; SSSD sees a connection failure and falls
back to its local cache.
### Added — secrets engine (DESIGN.md §5)
- **`secrets` capability + `secrets` config.** The agent renders local templates
that embed OpenBao secrets (`{{ bao "secret/data/nodes/<id>/<name>#<key>" }}`).
It parses the placeholders, fetches the values from the SSO (which holds the
OpenBao access; the agent never holds a Vault token), renders each target
atomically at `0600`, and runs the configured reload. Triggered by a signed
`render_secrets` command.
### Added — capability reporting
- **The agent reports its enabled capabilities in its `discovery` frame.** The
SSO stores them and the Directory UI shows them as badges on the host's Metrics
tab, so an operator can see at a glance what an agent is allowed to do
(telemetry, LDAP tunnel, secrets, IAM, reboot, bash, service control).
### Added — IAM engine (DESIGN.md §6)
- **`iam` capability.** The SSO pushes node-scoped identity config as a signed
`iam_apply` command; the agent verifies the Ed25519 signature (fail-closed) and
applies it locally:
- **Sudo rules** — writes `/etc/sudoers.d/theta-iam-<node_id>`, validates with
`visudo -c`, atomic swap.
- **SSH keys** — stores per-user keys and installs the `AuthorizedKeysCommand`
script (`/usr/local/bin/theta-authorized-keys`) that sshd calls per login.
- **Access control** — writes `/etc/security/access.conf` with allowed login
groups.
- **Revocation** — flushes the SSSD cache (`sss_cache -E`) and drops active
sessions (`pkill -u`) for revoked users.
## [v1.5.1] - 2026-08-06
### Fixed
- **Rebuilt the prebuilt `theta-agent-linux-amd64`.** theta-suite's `setup.sh` installs that committed binary rather than building from source, so a stale one means the fix in this repo never reaches the host. The v1.5.0 binary predated join-key support: an install would have written a `join_key` into `agent.yml` that the running agent did not understand, and it would have looped on `close 4001: Unauthorized`. (Same trap as the v1.3.0 heartbeat fix.)
## [v1.5.0] - 2026-08-06
Join-key enrollment (protocol v1.2.0 §1.1). Installing the agent with one key is now all it takes to add a host.
### Added
- **`join_key` config field.** Presented while `auth_token` is empty. The SSO exchanges it for this agent's own token and the public key it must pin, both delivered in the `config` frame; the agent writes them into `agent.yml` and blanks the join key. No value has to be copied between two machines by hand any more.
- `ConfigManager.PersistEnrollment` rewrites only the credential lines, line-based rather than a YAML round-trip, so operator comments, the capability matrix and formatting survive. Re-reads the file afterwards, so the new credential is live without a restart, and keeps the file at `0600`.
- `Config.Credential()` — the agent's own token when it has one, otherwise the join key.
- The connect URL carries `?hostname=`, so a self-enrolling host is named after itself instead of a generated placeholder.
- `install.sh --join-key`.
### Fixed
- The agent now refuses to connect (with a clear message and a long back-off) when it has neither an `auth_token` nor a `join_key`, rather than repeatedly presenting an empty credential.
## [v1.4.0] - 2026-08-05
Implements **Protocol v1.2.0**. See `PROTOCOL.md` §1.1, §5.15.3.
### Security
- **Fail-closed signature verification.** `verifySignature` returned `true` when no `public_key` was configured, logging "skipping signature verification". Combined with an installer that never wrote a `public_key`, that meant a default install would execute `reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash` and `update_binary` **unverified** from anything that could reach its socket. An agent that cannot verify a high-risk command now refuses it.
- **The token must be issued by the server.** The SSO now rejects tokens it did not mint (close code `4001`). Agents carrying a token generated by the old browser-side installer will not connect until re-enrolled.
### Fixed
- **Canonicalization mismatch broke signatures for most real scripts.** Go's `encoding/json` escapes `<`, `>` and `&` by default; the server's `JSON.stringify` does not. Any payload containing them — an `arbitrary_bash` script using `>` redirection or `&&`, which is most of them — hashed differently on each side and failed verification. `canonicalize()` now uses `json.Encoder` with `SetEscapeHTML(false)` and trims the encoder's trailing newline.
- **Auth failures no longer hot-loop.** A rejected credential was retried every 5 seconds forever, flooding the SSO and its audit log. Close codes `4001`/`4003`/`4004` now back off for 5 minutes and log what to do about it.
- **The auth token no longer appears in logs.** The connect line logged the full URL, including `?token=...`. It now logs only host + path, and the token is URL-escaped.
### Added
- Close-code handling for the SSO's enrollment signals: `4001` unauthorized, `4002` superseded, `4003` revoked, `4004` token rotated.
- `install.sh --public-key <base64>`, written into the generated `agent.yml`. The installer warns loudly when no public key is configured, since such an agent can report telemetry but will refuse every high-risk command.
- Tests: fail-closed with no key, wrong key, payload tampered after signing, shell metacharacters (`>`, `&&`, `<`) round-tripping, and canonical-form equality with the server. `interop_check_test.go` verifies a signature produced by the live SSO against the agent's own verifier (skipped unless `INTEROP_FIXTURE` is set).
### Changed
- Existing tests no longer rely on verification being skipped; high-risk cases now sign with a real test key.
- `agent.yml.example`, `README.md`, `INSTALL.md`: enrollment is a prerequisite, and `public_key` is the base64 of the **raw 32-byte** Ed25519 key — not a PEM body. The previous documented example (`MCowBQYDK2VwAyEA...`) decodes to 44 bytes and would have been rejected.
## [v1.3.0] - 2026-08-04
### Fixed
+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.
+211
View File
@@ -0,0 +1,211 @@
# theta-agent v2 — Simplified Architecture (LDAP-over-HTTPS)
**Status:** Draft for review · **Supersedes:** the WebRTC/SCTP spec (WebRTC dropped)
This document defines the v2 architecture for `theta-agent`. It replaces the
earlier WebRTC/SCTP design with a strictly simpler model: **one agent per node,
one outbound WSS channel, and the agent provides local LDAP + secrets + IAM.**
The core problem it solves is the original ask — *LDAP binds are painful across
hostnames, networks, and TLS cert chains* — by making the client stop speaking
LDAP and instead do an HTTPS call to the SSO, where the directory is reachable.
---
## 1. Overview
```
APP (no agent) SSO
HTTPS POST /ldap/bind ──────────────► [LDAP-over-HTTPS API] ──► OpenLDAP
HTTPS POST /ldap/search ───────────► ▲
│ raw bytes
NODE (with theta-agent) │
SSSD ──► /run/theta/ldap.sock ──► agent ──► WSS (ldap_tunnel) ─┘
secrets: /etc/theta/templates/*.tpl ──► agent ──► HTTPS ──► OpenBao
IAM: sudoers.d / authorized_keys ──► agent ◄── WSS ◄── IAM engine
```
Two ways to reach the directory, both simple:
- **Apps (no agent):** call the HTTPS LDAP API directly — one bind/search
contract, no LDAP protocol.
- **Nodes (with agent):** the agent is a **pure byte pump** — it forwards raw
LDAP bytes from a local socket to the SSO, which relays them into OpenLDAP.
The agent never parses LDAP.
---
## 2. Transport
- **Single transport: persistent outbound WebSocket (WSS) over TCP 443.** No
WebRTC, no SCTP, no UDP, no ICE, no fallback logic. The agent dials out; the
SSO never needs an inbound port.
- **Authentication:** the existing token / join-key enrollment model carries
over unchanged (see `PROTOCOL.md` §1.1). The agent presents its per-agent
token; the SSO rejects anything it did not issue.
- **Message framing:** the existing `WSMessage` JSON envelope (`type` + `payload`)
carries all traffic. Message types are extended for LDAP, secrets, and IAM
(below). No binary framing, no stream multiplexing. The LDAP tunnel carries
raw bytes as base64 in `ldap_tunnel` messages (see §4).
---
## 3. LDAP-over-HTTPS API (SSO side)
A small service (or routes in the existing proxy/sso) that performs the real
LDAP operation against OpenLDAP, which is reachable from the SSO network. This
is the pinepain/ldap-auth-proxy model: the client never speaks LDAP.
| Endpoint | Request | Response |
| :--- | :--- | :--- |
| `POST /api/v1/ldap/bind` | `{username, password}` | `200 {dn, attributes}` or `401` |
| `POST /api/v1/ldap/search` | `{base_dn, scope, filter, attributes}` | `200 {entries: [...]}` |
- **bind** performs a real LDAP bind server-side and returns the bound DN and
identity attributes.
- **search** runs a real LDAP search server-side and returns entries.
- **Authorization:** the API authorizes the *caller* (an app, or an agent acting
for a node). OpenLDAP enforces the actual directory ACLs. This resolves the
original spec's contradiction — we do not parse BER to enforce per-operation
policy; the directory does.
### 3.1 Consumers
- **Apps/services:** call the API directly over HTTPS. No LDAP hostname, no
LDAPS cert chain, no cross-network LDAP firewall rule.
- **theta-agent:** does **not** use this API. It tunnels raw LDAP bytes to the
SSO's OpenLDAP over the WSS channel instead (see §4) — a byte pump, not a
translation. The HTTPS API is for apps that have no agent on their network.
---
## 4. Agent local LDAP socket — a pure byte pump (for SSSD/PAM)
The agent provides a local LDAP endpoint so SSSD/PAM on the node can
authenticate without direct LDAP connectivity. **The agent does not speak LDAP
at all.** It is a dumb byte pump: whatever bytes land on the local socket are
forwarded to the SSO, which relays them into its real OpenLDAP and pipes the
response back.
```
SSSD ──► /run/theta/ldap.sock ──► agent ──► WSS (ldap_tunnel) ──► SSO ──► OpenLDAP
◄───────────────────────────────────────────────────────────────────────◄
```
- **Socket:** a **Unix domain socket** at `/run/theta/ldap.sock`, owned by root,
mode `0660` (root + theta group). A unix socket is preferred over
`127.0.0.1:389` because filesystem permissions restrict *which local processes*
can connect — any process can reach a TCP port, only root/theta can reach the
socket.
- **Tunnel framing:** each local connection gets a `conn_id`. Bytes are carried
over the existing WSS channel as `ldap_tunnel` messages:
`{type:"ldap_tunnel", payload:{conn_id, data:<base64>, close:bool}}`. The
agent reads the socket and sends chunks up; the SSO relays them into OpenLDAP
and sends OpenLDAP's response chunks back down; the agent writes them to the
socket. `close:true` ends a connection.
- **SSSD config:** `ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock`, StartTLS off
(the transport to the SSO is already TLS/WSS; the local hop is plaintext on a
root-owned socket).
### 4.1 Offline boot handling (validated)
If the agent cannot reach the SSO (WSS down), it cannot forward bytes, so it
closes local socket connections. SSSD sees a connection failure and falls back
to its local cache. **This has been validated against real SSSD behavior:** any
user/group seen in the last N days can log in offline — a laptop user can log in
on the road, and an admin can still reach a broken service host to fix it.
### 4.2 Open question — `ldapi://` support
SSSD's `ldap_uri` accepts `ldapi://` unix-socket URIs on modern versions, but
this must be confirmed on the target SSSD build. If unsupported, fall back to
`127.0.0.1:389` with a local firewall rule restricting the port to loopback.
---
## 5. Secrets engine
The agent renders OpenBao secrets to local files, reusing the existing
`@simpleworkjs/bao-conf` patterns rather than inventing a parallel mechanism.
- **Templates:** `/etc/theta/templates/*.tpl` declare the secrets a service
needs, e.g. `DB_PASS="{{ bao "secret/data/nodes/node-42/db#password" }}"`.
- **Flow:** the agent requests the secret paths over the WSS channel → the SSO
fetches from OpenBao (node-scoped to `/secret/data/nodes/${NODE_ID}/*`) →
the agent renders the file **atomically** (write temp + rename) with mode
`0600` → runs the configured post-render action (`systemctl reload <svc>`).
- **Rotation:** on a rotation/invalidation event pushed down the channel, the
agent re-fetches, re-renders, and reloads.
- **Note:** OpenBao KV v2 secrets have no leases; "renewal" is a re-read on
invalidation, not a lease renewal. (Dynamic secrets, if used, are a separate
path.)
---
## 6. IAM engine
The SSO pushes node-scoped identity config down the WSS channel; the agent
applies it locally.
- **Sudo rules:** write `/etc/sudoers.d/theta-iam-<node_id>`, run `visudo -c`,
and atomically swap on success.
- **SSH keys:** sync user public keys via `AuthorizedKeysCommand` (the agent
implements the command the SSH daemon calls per login) — *not* a non-standard
`/etc/ssh/authorized_keys.d/` directory.
- **Access control:** configure SSSD / `/etc/security/access.conf` for allowed
login groups.
- **Revocation:** on account disable / group drop, flush local SSSD caches and
drop active sessions for the affected user (mechanism TBD — `loginctl` vs
`pkill -u`; high-risk, needs a defined trigger/event model).
### 6.1 Security — signed IAM payloads
Sudo rules and SSH keys grant root-equivalent access, so **every IAM push is
Ed25519-signed** using the existing signature model (`PROTOCOL.md` §5). The agent
verifies the signature against its pinned `public_key` before applying anything.
Unsigned or invalid IAM payloads are rejected fail-closed.
---
## 7. Security model
- **Fail-closed, capability-matrix philosophy carries over** from v1: the agent
only applies what its local config permits; the SSO cannot override local
settings.
- **Local socket auth:** only root/theta can reach `/run/theta/ldap.sock`.
- **Signed high-risk operations:** IAM pushes (and any new high-risk command)
require the Ed25519 signature.
- **Node-scoped secrets:** OpenBao access is restricted to the node's own path
prefix.
- **Blast radius:** the agent runs as root; the unix socket + signature model +
node-scoped secrets contain the damage if the agent is compromised.
---
## 8. What this drops from the original spec
- WebRTC / SCTP / DTLS / UDP / ICE — **gone**, WSS only.
- Three SCTP streams — **replaced** by one WSS channel with message types.
- The "node-scoped authorization" contradiction — **resolved**: the API
authorizes the caller, OpenLDAP enforces ACLs.
- LDAP parsing in the agent — **gone**. The agent is a byte pump; it never
parses LDAP. The SSO relays raw bytes into its real OpenLDAP.
---
## 9. Open questions / verification items
1. **SSSD `ldapi://` unix-socket support** on the target build (§4.2).
2. **Revocation mechanism** — implemented as `sss_cache -E` + `pkill -u <user>`
(§6). The event model (what triggers a push) is still to be wired into the
SSO UI/engine.
3. **`AuthorizedKeysCommand`** — implemented: the agent installs
`/usr/local/bin/theta-authorized-keys` which cats the user's key file
(`/etc/theta/authorized_keys/<user>`). sshd must be configured with
`AuthorizedKeysCommand /usr/local/bin/theta-authorized-keys %u` (§6).
4. **Versioning/migration** — is v2 a replacement for v1, or a parallel mode?
The existing `/api/agent/ws` vs the new `/api/v1/ldap/*` paths need a story.
5. **SSO relay target** — the SSO relays tunnel bytes into its local OpenLDAP
(slapd). The target address comes from `conf.ldap.url`; confirm it is a
plaintext LDAP port reachable from the SSO process (§4).
6. **Secrets node scope** — the agent's node scope is its agent id
(`secret/data/nodes/<agent-id>/*`). Confirm this matches how node secrets are
provisioned in OpenBao (§5).
+14 -1
View File
@@ -2,6 +2,18 @@
Theta Agent is designed for rapid deployment across the fleet. The recommended method is via the "One-Liner" install, which marries the agent to a specific SSO Manager instance.
## Prerequisite: enroll the host
The agent's token is issued by the SSO, not chosen by you. In the SSO open
**Directory → Install Agent**, name the host, bind it to a host resource, and
press **Enroll & issue token**. You get:
- the **agent token** — shown once; only its hash is stored
- the **SSO public key** — pinned by the agent to verify high-risk commands
The modal builds the install command below with both already filled in. A token
the SSO did not issue is rejected at connect time with close code `4001`.
## Quick Start (The One-Liner)
The SSO Manager provides a pre-generated installation command. Copy and paste it into your terminal as root:
@@ -15,7 +27,8 @@ curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- "
### Option B: Minimal Setup
Use this for rapid deployment with basic telemetry:
```bash
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- --url "https://sso.example.com" --token "your-host-token"
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- \
--url "https://sso.example.com" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
```
### What this does:
+126 -4
View File
@@ -1,4 +1,4 @@
# Theta Agent Protocol Specification (v1.1.0)
# Theta Agent Protocol Specification (v1.2.0)
This document defines the communication protocol between the `theta-agent` (Client) and the `sso-manager` (Server).
@@ -7,8 +7,55 @@ This document defines the communication protocol between the `theta-agent` (Clie
The agent establishes a persistent outbound WebSocket connection.
- **Endpoint**: `wss://<manager-url>/api/agent/ws`
- **Authentication**: The agent must provide a unique host token as a query parameter:
- `wss://<manager-url>/api/agent/ws?token=<HOST_TOKEN>`
- **Authentication**: The agent must provide its enrollment token as a query parameter:
- `wss://<manager-url>/api/agent/ws?token=<AGENT_TOKEN>`
### 1.1 Enrollment (changed in v1.2.0)
Two credentials can appear in `agent.yml`. The agent presents `auth_token` when
it has one, otherwise `join_key`:
| Field | Meaning |
| :--- | :--- |
| `auth_token` | This agent's own token, issued by the server. Long-term identity. |
| `join_key` | Bootstrap credential (`tjk_…`), exchanged for an `auth_token` on first connect. |
**Join-key flow.** The agent connects presenting a join key and
`?hostname=<its hostname>`. The server enrolls the host and answers with a
`config` frame carrying `enrolled: true`, `auth_token` and `public_key`. The
agent writes both into `agent.yml`, blanks `join_key`, and uses its own token
from then on. This is what makes "install the agent with a key" sufficient to
add a host — no value has to be copied between two machines by hand.
The public key is accepted on first connect (trust on first use) over the same
channel that issued the token. Pre-register the host instead if you need the
trust anchor pinned out of band.
The token **must be issued by the server**. An administrator enrolls the agent in
the SSO (Directory → Agents, or `POST /api/agent/enroll`), which mints the token,
stores only its SHA-256, and displays the raw value once. That value goes into
`auth_token` in `agent.yml`.
Up to v1.1.0 the token was generated in the browser and never recorded
server-side, so the server accepted *any* string: anyone who could reach
`/api/agent/ws` could register as a node, publish discovery/telemetry, and
receive commands addressed to a token they guessed. Tokens the server did not
issue are now rejected.
The server accepts the WebSocket upgrade before authenticating, so an
authentication failure arrives as a **close frame**, not an HTTP status:
| Code | Meaning | Agent behaviour |
| :--- | :--- | :--- |
| `4001` | Credential unknown — neither an issued token nor a valid join key | Back off (5 min); the credential will not fix itself |
| `4002` | Superseded — another connection authenticated as this agent | Normal reconnect |
| `4003` | Enrollment revoked or deleted by an administrator | Back off (5 min) |
| `4004` | Token rotated — `agent.yml` holds the superseded value | Back off (5 min); re-copy the token |
Revocation and rotation both drop any live socket immediately, so they take
effect without waiting for the agent to reconnect.
## 2. Message Format
@@ -23,6 +70,24 @@ All messages are exchanged as JSON objects following the `WSMessage` structure.
}
```
### 2.1 `ldap_tunnel` — the LDAP byte pump (DESIGN.md §4)
The agent serves a local LDAP socket for SSSD/PAM. It is a **pure byte pump**:
the agent forwards raw LDAP bytes to the SSO, which relays them into its real
OpenLDAP and pipes the response back. Neither side parses LDAP.
- **Type**: `ldap_tunnel` (bidirectional — sent by both agent and SSO)
- **Payload**:
- `conn_id`: (string) correlates one local LDAP connection.
- `data`: (string, optional) base64-encoded raw LDAP bytes.
- `close`: (bool, optional) ends the connection.
The agent reads its local socket and sends `data` chunks up; the SSO relays them
into OpenLDAP and sends OpenLDAP's response chunks back down; the agent writes
them to the socket. `close:true` ends a connection. When the WSS is down the
agent cannot forward bytes, so it closes local socket connections and SSSD falls
back to its local cache.
## 3. Client $\rightarrow$ Server Messages
### 3.1 Discovery (One-time & On-Change)
@@ -71,6 +136,20 @@ Sent in response to any command received from the server.
## 4. Server $\rightarrow$ Client Messages
### 4.0 `config`
Sent immediately on a successful connection.
- **Type**: `config`
- **Payload**:
- `message`: (string) human-readable greeting.
- `protocol_version`: (string) the server's protocol version.
- `agent_id`: (string) this agent's id in the SSO.
- `enrolled`: (bool, optional) present and `true` only when this connection
just enrolled via a join key.
- `auth_token`: (string, optional) the issued per-agent token — **persist it**.
- `public_key`: (string, optional) the key to pin — **persist it**.
### 4.1 Standard Commands
These commands are executed if the corresponding capability is enabled in `agent.yml`.
@@ -92,14 +171,57 @@ These commands **require** an Ed25519 signature in the payload. The agent verifi
| `configure_ldap` | `{ "config": "...", "signature": "..." }` | Writes `/etc/sssd/sssd.conf` and restarts `sssd`. |
| `arbitrary_bash` | `{ "script": "...", "signature": "..." }` | Executes raw bash script. |
| `update_binary` | `{ "url": "...", "sha256": "...", "signature": "..." }` | Downloads, verifies, and replaces the agent binary. |
| `render_secrets` | `{ "signature": "..." }` | Renders the configured secret templates to their targets (DESIGN.md §5). |
| `iam_apply` | `{ "node_id", "revision", "access_control", "signature" }` | Applies node IAM: sudo rules, SSH keys, access control, revocation (DESIGN.md §6). |
## 5. Cryptographic Verification Process
To send a high-risk command:
1. Create the payload (e.g., `{"script": "uptime"}`).
2. Canonicalize the JSON (sort keys alphabetically, remove whitespace).
2. Canonicalize the JSON (see 5.1).
3. Sign the canonical bytes using the private Ed25519 key.
4. Add the base64 signature to the payload: `{"script": "uptime", "signature": "..."}`.
5. Send as a `WSMessage`.
The agent performs the reverse process to verify authenticity before execution.
### 5.1 Canonical form
Both sides must produce **byte-identical** input to sign/verify:
- keys sorted alphabetically
- no insignificant whitespace
- the `signature` key omitted
- **no HTML escaping** — `<`, `>` and `&` are emitted literally
- no trailing newline
The escaping rule is load-bearing. Go's `encoding/json` escapes those three
characters by default while JavaScript's `JSON.stringify` does not, so a payload
containing any of them hashed differently on each side and verification failed.
For `arbitrary_bash` that is most real scripts (`>` redirection, `&&`). The Go
client uses `json.Encoder` with `SetEscapeHTML(false)`.
Example — payload `{"script": "echo a > b && c", "comment": "x&y"}` canonicalizes to:
```
{"comment":"x&y","script":"echo a > b && c"}
```
### 5.2 The server signing key (changed in v1.2.0)
The server's Ed25519 key pair is **persistent**, stored in OpenBao at
`secret/agent/signing-key`. `public_key` in `agent.yml` is the base64-encoded raw
32-byte public key, available from the enrollment response or
`GET /api/agent/nodes`.
Previously the pair was generated in memory at process start, so it changed on
every restart and no agent could meaningfully pin it. If the server cannot load
or persist a key it now **refuses to send high-risk commands** rather than
signing with a key no agent has seen.
### 5.3 Agent-side verification is fail-closed (changed in v1.2.0)
An agent with no `public_key` configured **rejects** every high-risk command.
Until v1.1.0 it logged "skipping signature verification" and executed them,
which meant an agent installed without a key would run `reboot`,
`configure_ldap` and `arbitrary_bash` from anything that reached its socket.
+42 -4
View File
@@ -1,8 +1,19 @@
# 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
Install the agent on a node and it becomes a managed member of the directory — over a **single outbound connection**, with no inbound ports, no LDAP hostname/firewall/TLS setup, and no manual secret copying.
- **Directory logins (LDAP byte pump).** SSSD/PAM on the node authenticates through the agent's local socket, which forwards raw LDAP bytes to the SSO's OpenLDAP. OS logins work across any network — laptops, CGNAT, cloud VMs — and fall back to the local SSSD cache when offline.
- **Secrets delivered on-demand.** Services, scripts, and Docker containers fetch secrets dynamically via `theta-agent get-secret DB_PASSWORD` or `theta-agent get-secrets --env`. Zero plaintext secrets on disk! Multi-level secret inheritance (Global Site -> Host -> Service) is resolved automatically.
- **IAM managed centrally.** Sudo rules, SSH keys, and login access are pushed from the SSO to the node. Add a user to a group and their access appears on the right hosts; revoke them and their sessions are dropped.
- **Telemetry & remote operations.** Host discovery, live metrics, and signed remote commands (reboot, service control, config, self-update) — the original C2 capabilities.
Everything is gated by a strict, local-first capability matrix and high-risk operations are Ed25519-signed (see below).
## Core Functionality
@@ -39,12 +50,26 @@ The agent will **only** execute commands that are explicitly enabled in its loca
### Cryptographic Hardening
All high-risk commands require an Ed25519 signature. The agent verifies the signature against the `public_key` provided in the local config. If the signature is missing or invalid, the command is rejected regardless of the capability matrix.
Verification is **fail-closed**: an agent with no `public_key` configured rejects
every high-risk command. (Before protocol v1.2.0 it logged "skipping signature
verification" and executed them, so an agent installed without a key would run
`reboot`, `configure_ldap` and `arbitrary_bash` unverified.)
### Enrollment
The agent's token must be **issued by the SSO**. The server stores only its
SHA-256 and rejects anything else at the WebSocket handshake, so a token cannot
be minted client-side, and an enrollment can be revoked or rotated centrally —
either drops the agent's live connection immediately. See `PROTOCOL.md` §1.1.
### Capability Matrix
| Capability | Risk Level | Description | Impact |
|------------|------------|-------------|---------|
| `telemetry` | Safe | Read-only metrics. | Pushes system health to SSO Manager. |
| `configure_ldap` | Moderate | Configures SSSD. | Updates `/etc/sssd/sssd.conf` and restarts `sssd`. |
| `ldap_tunnel` | Moderate | Local LDAP byte-pump socket. | Forwards raw LDAP bytes to the SSO for SSSD/PAM (DESIGN.md §4). |
| `secrets` | Moderate | Renders OpenBao secrets. | Renders `/etc/theta/templates/*.tpl` to targets, atomic + reload (DESIGN.md §5). |
| `iam` | High | Applies node IAM. | Writes sudo rules, SSH keys, access control; revokes sessions (DESIGN.md §6). |
| `reboot` | High | System reboot. | Triggers an immediate host reboot. |
| `service_control` | High | Service management. | Restarts services listed in the allowed list. |
| `arbitrary_bash` | CRITICAL | Raw bash execution. | Executes any script sent by the manager as root. |
@@ -56,7 +81,7 @@ Configuration is stored in YAML format at `/etc/theta42/agent.yml`.
### Example `agent.yml`
```yaml
server_url: "wss://sso.theta42.local"
auth_token: "your-unique-host-token"
auth_token: "issued-by-the-sso-at-enrollment"
public_key: "base64-encoded-ed25519-public-key"
location: "dc-01-rack-12"
capabilities:
@@ -71,7 +96,12 @@ capabilities:
1. **Build**: Compile for your target architecture (see CI/CD artifacts).
2. **Deploy**: Place the binary in `/usr/local/bin/theta-agent`.
3. **Configure**: Create `/etc/theta42/agent.yml` with the required token and capabilities.
3. **Enroll**: In the SSO, open **Directory → Install Agent**, name the host, bind
it to a host resource, and press **Enroll & issue token**. The SSO mints the
token (shown once) and gives you its public key. Tokens the server did not
issue are rejected.
4. **Configure**: Create `/etc/theta42/agent.yml` with the issued `auth_token`,
the SSO's `public_key`, and your capabilities.
4. **Service**: Set up as a systemd unit (example: `/etc/systemd/system/theta-agent.service`).
For the fastest deployment, use the installation script:
@@ -79,6 +109,14 @@ For the fastest deployment, use the installation script:
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- "BASE64_ENCODED_CONFIG"
```
The **Install Agent** modal generates that command for you after enrollment,
with the token and public key already embedded. The equivalent flag form is:
```bash
curl -fsSL https://sso.example.com/resources/theta-agent/install.sh | sh -s -- \
--url "https://sso.example.com" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
```
## Development & Testing
The agent uses a decoupled execution engine for safety and testability.
+80 -8
View File
@@ -1,26 +1,98 @@
# 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"
auth_token: "REPLACE_WITH_AGENT_TOKEN"
# This agent's own token. Leave EMPTY when installing with a join key -- the
# agent fills it in itself once the SSO enrolls it. The server records only a
# hash and rejects any token it did not issue, so a locally invented value will
# never connect.
auth_token: ""
# The one credential you need to add a host. Used only while auth_token is
# empty: the SSO exchanges it for this agent's own token + public key on first
# connect, and the agent then blanks this line. Get one from the SSO
# (Directory -> Install Agent, or POST /api/agent/join-keys).
join_key: ""
# Base64 of the SSO's RAW 32-byte Ed25519 public key (NOT a PEM body). Filled in
# automatically when enrolling with a join key; set it by hand only if you
# pre-registered this host.
#
# Required for any high-risk command. Without it the agent still reports
# telemetry, but REFUSES reboot / service_restart / configure_ldap /
# arbitrary_bash / update_binary, because it has no way to verify them.
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
# 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)
ldap_tunnel: true
# 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)
iam: true
# 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:
# - template: /etc/theta/templates/db.env.tpl
# target: /etc/theta/db.env
# reload: systemctl reload app
# ---------------------------------------------------------
# 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.
@@ -29,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"
+324
View File
@@ -0,0 +1,324 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"runtime"
"strings"
"time"
)
func handleCLI(args []string) bool {
if len(args) == 0 {
return false
}
arg := strings.ToLower(args[0])
switch arg {
case "get-secret", "secret-get":
runGetSecret(args[1:])
return true
case "get-secrets", "secret-list", "secrets":
runGetSecrets(args[1:])
return true
case "--update", "update":
runSelfUpdate(args[1:])
return true
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 " + AgentVersion)
return true
case "--help", "help", "-h":
printUsage()
return true
}
return false
}
func printUsage() {
fmt.Println("Theta Agent - Unified Endpoint Management CLI")
fmt.Println()
fmt.Println("Usage:")
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 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:")
fmt.Println(" --join-key <key> Supply new join key for re-enrollment")
fmt.Println()
}
func runSelfUpdate(args []string) {
configPath := defaultConfigPath()
cm, err := NewConfigManager(configPath)
if err != nil {
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
}
_ = cm.Get()
// 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}
resp, err := client.Get(downloadURL)
if err != nil || resp.StatusCode != 200 {
log.Fatalf("[!] Failed to download update binary from %s (HTTP %d): %v", downloadURL, resp.StatusCode, err)
}
defer resp.Body.Close()
binPath := "/usr/local/bin/theta-agent"
if selfPath, err := os.Executable(); err == nil && selfPath != "" {
binPath = selfPath
}
tmpPath := binPath + ".tmp"
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
log.Fatalf("[!] Cannot write binary to %s: %v", tmpPath, err)
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close()
log.Fatalf("[!] Error writing binary update: %v", err)
}
out.Close()
if err := os.Rename(tmpPath, binPath); err != nil {
log.Fatalf("[!] Cannot replace binary at %s: %v", binPath, err)
}
log.Printf("[+] Binary updated successfully at %s.", binPath)
exec := &SystemExecutor{}
restartAffectedServices(exec)
os.Exit(0)
}
func runReinitialize(args []string) {
configPath := defaultConfigPath()
joinKey := ""
for i := 0; i < len(args); i++ {
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
joinKey = args[i+1]
i++
}
}
raw, err := os.ReadFile(configPath)
if err != nil {
log.Fatalf("[!] Cannot read %s: %v", configPath, err)
}
content := string(raw)
// Clear auth_token
reToken := regexp.MustCompile(`(?m)^auth_token:.*$`)
content = reToken.ReplaceAllString(content, `auth_token: ""`)
if joinKey != "" {
reKey := regexp.MustCompile(`(?m)^join_key:.*$`)
if reKey.MatchString(content) {
content = reKey.ReplaceAllString(content, fmt.Sprintf(`join_key: "%s"`, joinKey))
} else {
content += fmt.Sprintf("\njoin_key: \"%s\"\n", joinKey)
}
}
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
log.Fatalf("[!] Failed to update %s: %v", configPath, err)
}
log.Printf("[+] Cleared token in %s and reset enrollment status.", configPath)
exec := &SystemExecutor{}
restartAffectedServices(exec)
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 {
log.Printf("[+] Restarting sssd service...")
_, _ = exec.Execute("systemctl", "restart", "sssd")
}
if _, err := exec.Execute("systemctl", "is-active", "sshd"); err == nil {
log.Printf("[+] Reloading sshd service...")
_, _ = exec.Execute("systemctl", "reload", "sshd")
} else if _, err := exec.Execute("systemctl", "is-active", "ssh"); err == nil {
log.Printf("[+] Reloading ssh service...")
_, _ = exec.Execute("systemctl", "reload", "ssh")
}
}
func runGetSecret(args []string) {
if len(args) < 1 {
fmt.Fprintf(os.Stderr, "[!] Error: secret key name required (e.g. theta-agent get-secret DB_PASSWORD)\n")
os.Exit(1)
}
key := args[0]
secrets, err := fetchAgentSecrets()
if err != nil {
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
os.Exit(1)
}
val, exists := secrets[key]
if !exists {
fmt.Fprintf(os.Stderr, "[!] Error: secret '%s' not found for this host/resource\n", key)
os.Exit(1)
}
// Print raw secret value to stdout without trailing newline
fmt.Print(val)
os.Exit(0)
}
func runGetSecrets(args []string) {
jsonMode := false
envMode := false
for _, arg := range args {
if arg == "--json" {
jsonMode = true
} else if arg == "--env" {
envMode = true
}
}
secrets, err := fetchAgentSecrets()
if err != nil {
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
os.Exit(1)
}
if jsonMode {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(secrets); err != nil {
fmt.Fprintf(os.Stderr, "[!] JSON encode error: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
if envMode {
for k, v := range secrets {
escaped := strings.ReplaceAll(v, `"`, `\"`)
fmt.Printf("%s=\"%s\"\n", k, escaped)
}
os.Exit(0)
}
if len(secrets) == 0 {
fmt.Println("No secrets configured for this host/resource.")
os.Exit(0)
}
fmt.Printf("%-30s %s\n", "SECRET KEY", "VALUE STATUS")
fmt.Println(strings.Repeat("-", 60))
for k, v := range secrets {
status := fmt.Sprintf("Configured (%d chars)", len(v))
fmt.Printf("%-30s %s\n", k, status)
}
os.Exit(0)
}
func fetchAgentSecrets() (map[string]string, error) {
configPath := defaultConfigPath()
cm, err := NewConfigManager(configPath)
if err != nil {
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
}
cfg := cm.Get()
serverURL := strings.TrimRight(cfg.ServerURL, "/")
if serverURL == "" {
return nil, fmt.Errorf("server_url is empty in %s", configPath)
}
token := cfg.AuthToken
if token == "" {
return nil, fmt.Errorf("agent is not enrolled (auth_token empty in %s)", configPath)
}
reqBody, _ := json.Marshal(map[string]interface{}{})
url := fmt.Sprintf("%s/api/v1/agent/secrets", serverURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bodyBytes))
}
var resData struct {
Status string `json:"status"`
Secrets map[string]map[string]interface{} `json:"secrets"`
}
if err := json.NewDecoder(resp.Body).Decode(&resData); err != nil {
return nil, fmt.Errorf("failed to decode JSON response: %w", err)
}
mergedSecrets := make(map[string]string)
for _, pathMap := range resData.Secrets {
for k, v := range pathMap {
if strV, ok := v.(string); ok {
mergedSecrets[k] = strV
} else if v != nil {
mergedSecrets[k] = fmt.Sprintf("%v", v)
}
}
}
return mergedSecrets, nil
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"testing"
)
func TestHandleCLIHelpAndVersion(t *testing.T) {
if !handleCLI([]string{"version"}) {
t.Errorf("expected handleCLI('version') to return true")
}
if !handleCLI([]string{"--help"}) {
t.Errorf("expected handleCLI('--help') to return true")
}
if handleCLI([]string{"unknown-command"}) {
t.Errorf("expected handleCLI('unknown-command') to return false")
}
}
+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")
}
}
}
+201 -7
View File
@@ -3,6 +3,8 @@ package main
import (
"fmt"
"os"
"regexp"
"strings"
"sync"
"gopkg.in/yaml.v3"
@@ -14,20 +16,95 @@ type Capabilities struct {
Reboot bool `yaml:"reboot"`
ServiceControl []string `yaml:"service_control"`
ArbitraryBash bool `yaml:"arbitrary_bash"`
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
// post-render reload command (DESIGN.md §5).
type SecretTarget struct {
Template string `yaml:"template"`
Target string `yaml:"target"`
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"`
Location string `yaml:"location"`
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
Capabilities Capabilities `yaml:"capabilities"`
ServerURL string `yaml:"server_url"`
AuthToken string `yaml:"auth_token"`
// A join key is the one credential an operator hands out. On first connect
// 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"`
// 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
// enrolled, otherwise the join key.
func (c *Config) Credential() string {
if c.AuthToken != "" {
return c.AuthToken
}
return c.JoinKey
}
// ConfigManager handles thread-safe access and reloading of the agent configuration.
type ConfigManager struct {
mu sync.RWMutex
current *Config
mu sync.RWMutex
current *Config
configPath string
}
@@ -61,6 +138,119 @@ func (cm *ConfigManager) Reload() error {
return nil
}
// PersistEnrollment writes the credentials the server issued during join-key
// enrollment back into agent.yml, then reloads. Only the auth_token and
// public_key lines are rewritten (added if absent); every other line, including
// operator comments and the capability matrix, is preserved -- this file is
// hand-edited, so a naive marshal-and-write would destroy it.
//
// The join key is blanked once we hold our own token: leaving a fleet-wide
// credential on every host after it has stopped being needed is exactly the
// blast radius the per-agent token exists to avoid.
func (cm *ConfigManager) PersistEnrollment(token, publicKey string) error {
if token == "" {
return fmt.Errorf("server reported enrollment but sent no token")
}
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", token)
if publicKey != "" {
out = setYamlScalar(out, "public_key", publicKey)
}
out = setYamlScalar(out, "join_key", "")
// Same permissions the installer sets: this file now holds a credential.
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: %w", err)
}
cm.current = cfg
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]*:.*$`)
if re.MatchString(doc) {
return re.ReplaceAllString(doc, line)
}
if !strings.HasSuffix(doc, "\n") {
doc += "\n"
}
return doc + line + "\n"
}
func LoadConfig(path string) (*Config, error) {
file, err := os.Open(path)
if err != nil {
@@ -74,6 +264,10 @@ func LoadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("failed to decode YAML config: %w", err)
}
if cfg.Capabilities.ConfigureLDAP {
cfg.Capabilities.LdapTunnel = true
}
return &cfg, nil
}
+162
View File
@@ -3,6 +3,8 @@ package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -96,3 +98,163 @@ func TestCanManageService(t *testing.T) {
})
}
}
// PersistEnrollment rewrites a hand-edited file, so it must replace exactly the
// credential lines and leave everything else -- comments, capabilities,
// formatting -- untouched.
func TestPersistEnrollmentPreservesFile(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
original := `# theta-agent configuration file
server_url: "https://sso.example.com"
# Bootstrap credential, exchanged on first connect.
join_key: "tjk_abc123"
public_key: ""
location: "rack-4"
capabilities:
telemetry: true
# keep this comment
reboot: false
service_control: ["nginx"]
`
if err := os.WriteFile(path, []byte(original), 0600); err != nil {
t.Fatal(err)
}
cm, err := NewConfigManager(path)
if err != nil {
t.Fatal(err)
}
if err := cm.PersistEnrollment("issued-token-xyz", "PUBKEYBASE64"); err != nil {
t.Fatalf("PersistEnrollment: %v", err)
}
out, _ := os.ReadFile(path)
got := string(out)
for _, want := range []string{
`auth_token: "issued-token-xyz"`,
`public_key: "PUBKEYBASE64"`,
`join_key: ""`, // blanked: a fleet-wide key must not linger once unneeded
"# theta-agent configuration file",
"# keep this comment",
`location: "rack-4"`,
`service_control: ["nginx"]`,
} {
if !strings.Contains(got, want) {
t.Errorf("expected %q in rewritten config, got:\n%s", want, got)
}
}
// and the in-memory config is live without a restart
if cm.Get().AuthToken != "issued-token-xyz" {
t.Errorf("config not reloaded: AuthToken = %q", cm.Get().AuthToken)
}
if cm.Get().Credential() != "issued-token-xyz" {
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential())
}
// 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())
}
}
}
func TestPersistEnrollmentAddsMissingKeys(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
// No auth_token or public_key lines at all.
if err := os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\njoin_key: \"tjk_x\"\n"), 0600); err != nil {
t.Fatal(err)
}
cm, err := NewConfigManager(path)
if err != nil {
t.Fatal(err)
}
if err := cm.PersistEnrollment("tok", "pk"); err != nil {
t.Fatalf("PersistEnrollment: %v", err)
}
if cm.Get().AuthToken != "tok" || cm.Get().PublicKey != "pk" {
out, _ := os.ReadFile(path)
t.Errorf("keys not appended; file:\n%s", out)
}
}
func TestCredentialPrefersAuthToken(t *testing.T) {
c := &Config{JoinKey: "tjk_x"}
if c.Credential() != "tjk_x" {
t.Errorf("unenrolled agent should present the join key, got %q", c.Credential())
}
c.AuthToken = "own-token"
if c.Credential() != "own-token" {
t.Errorf("enrolled agent must present its own token, not the join key, got %q", c.Credential())
}
}
func TestPersistEnrollmentRejectsEmptyToken(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
os.WriteFile(path, []byte("server_url: \"x\"\n"), 0600)
cm, _ := NewConfigManager(path)
if err := cm.PersistEnrollment("", "pk"); err == nil {
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
View File
@@ -0,0 +1,6 @@
FROM node:20-alpine
RUN apk add --no-cache bash
WORKDIR /demo
COPY get-secret.sh get-secret.js ./
RUN chmod +x get-secret.sh
CMD ["sh", "-c", "sh /demo/get-secret.sh && node /demo/get-secret.js"]
+17
View File
@@ -0,0 +1,17 @@
// Demo: a 3rd-party node app reads the secret the theta-agent rendered to disk.
const fs = require('fs');
const path = '/etc/theta/rendered/db.env';
console.log('=== node app reads the rendered secret ===');
if (fs.existsSync(path)) {
const env = fs.readFileSync(path, 'utf8');
const db = {};
for (const line of env.split('\n')) {
const m = /^(\w+)="(.*)"$/.exec(line.trim());
if (m) db[m[1]] = m[2];
}
console.log('DB_USER=' + db.DB_USER);
console.log('DB_PASS=' + db.DB_PASS);
} else {
console.error('rendered secret not found — run render_secrets first');
process.exit(1);
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Demo: a 3rd-party bash app reads the secret the theta-agent rendered to disk.
# The agent rendered /etc/theta/rendered/db.env from a template + OpenBao.
echo "=== bash app reads the rendered secret ==="
if [ -f /etc/theta/rendered/db.env ]; then
. /etc/theta/rendered/db.env
echo "DB_USER=$DB_USER"
echo "DB_PASS=$DB_PASS"
else
echo "rendered secret not found — run render_secrets first"
exit 1
fi
+12 -4
View File
@@ -2,16 +2,24 @@ 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/gorilla/websocket v1.5.3 // 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/shirou/gopsutil/v3 v3.24.5 // 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
gopkg.in/yaml.v3 v3.0.1 // indirect
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect
)
+31
View File
@@ -1,29 +1,60 @@
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=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
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/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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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")
}
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
// IAM engine (DESIGN.md §6). The SSO pushes node-scoped identity config down the
// WSS channel as a signed `iam_apply` command; the agent verifies the signature
// (fail-closed) and applies it locally: sudo rules, SSH keys, access control,
// and session revocation.
// Paths are package-level vars so tests can redirect them to temp dirs.
var (
iamSudoersDir = "/etc/sudoers.d"
iamKeysDir = "/etc/theta/authorized_keys"
iamKeysCommand = "/usr/local/bin/theta-authorized-keys"
iamAccessConf = "/etc/security/access.conf"
)
// IAMPayload is the signed body of an `iam_apply` command.
type IAMPayload struct {
NodeID string `json:"node_id"`
Revision int `json:"revision"`
AccessControl AccessControl `json:"access_control"`
}
type AccessControl struct {
AllowedLoginGroups []string `json:"allowed_login_groups"`
SudoRules []SudoRule `json:"sudo_rules"`
SSHKeys []SSHKey `json:"ssh_keys"`
RevokeUsers []string `json:"revoke_users"`
}
type SudoRule struct {
Group string `json:"group"`
RunAs string `json:"run_as"`
Commands []string `json:"commands"`
Nopasswd bool `json:"nopasswd"`
}
type SSHKey struct {
User string `json:"user"`
Keys []string `json:"keys"`
}
// applyIAM applies a verified IAM payload. The caller must have already verified
// the Ed25519 signature.
func applyIAM(payload IAMPayload, exec Executor) error {
if len(payload.AccessControl.SudoRules) > 0 {
if err := applySudoRules(payload.AccessControl.SudoRules, payload.NodeID, exec); err != nil {
return fmt.Errorf("sudo rules: %w", err)
}
}
if len(payload.AccessControl.SSHKeys) > 0 {
if err := applySSHKeys(payload.AccessControl.SSHKeys, exec); err != nil {
return fmt.Errorf("ssh keys: %w", err)
}
}
if len(payload.AccessControl.AllowedLoginGroups) > 0 {
if err := applyAccessControl(payload.AccessControl.AllowedLoginGroups, exec); err != nil {
return fmt.Errorf("access control: %w", err)
}
}
if len(payload.AccessControl.RevokeUsers) > 0 {
applyRevocation(payload.AccessControl.RevokeUsers, exec)
}
return nil
}
// applySudoRules writes /etc/sudoers.d/theta-iam-<node_id>, verifies with
// `visudo -c`, and atomically swaps it in on success.
func applySudoRules(rules []SudoRule, nodeID string, exec Executor) error {
var b strings.Builder
for _, r := range rules {
if r.Group == "" {
continue
}
runAs := r.RunAs
if runAs == "" {
runAs = "ALL"
}
cmds := strings.Join(r.Commands, ", ")
if cmds == "" {
cmds = "ALL"
}
prefix := ""
if r.Nopasswd {
prefix = "NOPASSWD:"
}
fmt.Fprintf(&b, "%%%s ALL=(%s) %s%s\n", r.Group, runAs, prefix, cmds)
}
content := b.String()
// Make sure the sudoers.d dir exists (a minimal host may not have it).
if err := os.MkdirAll(iamSudoersDir, 0755); err != nil {
return err
}
// Write to a temp file in the sudoers.d dir, verify, then rename.
tmp, err := os.CreateTemp(iamSudoersDir, ".theta-iam-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.WriteString(content); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
os.Chmod(tmpName, 0440)
// visudo -c -f <file> validates a single file without touching the rest.
if _, err := exec.Execute("visudo", "-c", "-f", tmpName); err != nil {
os.Remove(tmpName)
return fmt.Errorf("visudo rejected the rules: %w", err)
}
target := filepath.Join(iamSudoersDir, "theta-iam-"+nodeID)
if err := os.Rename(tmpName, target); err != nil {
os.Remove(tmpName)
return err
}
log.Printf("IAM: wrote %s", target)
return nil
}
// applySSHKeys stores per-user keys and installs the AuthorizedKeysCommand
// script that sshd calls per login.
func applySSHKeys(keys []SSHKey, exec Executor) error {
if err := os.MkdirAll(iamKeysDir, 0755); err != nil {
return err
}
for _, k := range keys {
if k.User == "" {
continue
}
path := filepath.Join(iamKeysDir, k.User)
content := strings.Join(k.Keys, "\n") + "\n"
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return err
}
}
// AuthorizedKeysCommand script: cat the user's key file.
script := "#!/bin/sh\ncat \"" + iamKeysDir + "/$1\" 2>/dev/null\n"
if err := os.WriteFile(iamKeysCommand, []byte(script), 0755); err != nil {
return err
}
log.Printf("IAM: wrote %d ssh key file(s) + %s", len(keys), iamKeysCommand)
return nil
}
// applyAccessControl writes /etc/security/access.conf with the allowed login
// groups (PAM access). Format: `+:group:ALL` for each allowed group, then deny
// everything else.
func applyAccessControl(groups []string, exec Executor) error {
var b strings.Builder
for _, g := range groups {
if g != "" {
fmt.Fprintf(&b, "+:%s:ALL\n", g)
}
}
b.WriteString("-:ALL:ALL\n")
if err := os.MkdirAll(filepath.Dir(iamAccessConf), 0755); err != nil {
return err
}
if err := os.WriteFile(iamAccessConf, []byte(b.String()), 0644); err != nil {
return err
}
log.Printf("IAM: wrote %s", iamAccessConf)
return nil
}
// applyRevocation flushes the SSSD cache and drops active sessions for the
// revoked users.
func applyRevocation(users []string, exec Executor) {
// Flush the whole SSSD cache once — a revoked user must not be resolvable
// from cache.
if _, err := exec.Execute("sss_cache", "-E"); err != nil {
log.Printf("IAM: sss_cache -E failed: %v", err)
}
for _, u := range users {
if u == "" {
continue
}
if _, err := exec.Execute("pkill", "-u", u); err != nil {
log.Printf("IAM: pkill -u %s failed (no sessions?): %v", u, err)
}
log.Printf("IAM: revoked %s", u)
}
}
// parseIAMPayload extracts an IAMPayload from a WSMessage payload map.
func parseIAMPayload(payload map[string]interface{}) (IAMPayload, error) {
raw, err := json.Marshal(payload)
if err != nil {
return IAMPayload{}, err
}
var p IAMPayload
if err := json.Unmarshal(raw, &p); err != nil {
return IAMPayload{}, err
}
return p, nil
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// TestApplyIAM verifies the agent applies sudo rules, SSH keys, access control,
// and revocation from a signed IAM payload.
func TestApplyIAM(t *testing.T) {
dir := t.TempDir()
iamSudoersDir = dir
iamKeysDir = filepath.Join(dir, "keys")
iamKeysCommand = filepath.Join(dir, "theta-authorized-keys")
iamAccessConf = filepath.Join(dir, "access.conf")
payload := IAMPayload{
NodeID: "node-42",
Revision: 82,
AccessControl: AccessControl{
AllowedLoginGroups: []string{"sysadmins", "node-operators"},
SudoRules: []SudoRule{
{Group: "sysadmins", RunAs: "ALL", Commands: []string{"ALL"}},
{Group: "node-operators", RunAs: "ALL", Commands: []string{"ALL"}, Nopasswd: true},
},
SSHKeys: []SSHKey{
{User: "admin", Keys: []string{"ssh-ed25519 AAAA admin@theta"}},
},
RevokeUsers: []string{"olduser"},
},
}
exec := &MockExecutor{}
if err := applyIAM(payload, exec); err != nil {
t.Fatalf("applyIAM: %v", err)
}
// Sudo rules file.
sudoers, err := os.ReadFile(filepath.Join(dir, "theta-iam-node-42"))
if err != nil {
t.Fatalf("read sudoers: %v", err)
}
wantSudoers := "%sysadmins ALL=(ALL) ALL\n%node-operators ALL=(ALL) NOPASSWD:ALL\n"
if string(sudoers) != wantSudoers {
t.Fatalf("sudoers mismatch:\n got: %q\nwant: %q", sudoers, wantSudoers)
}
// SSH key file + AuthorizedKeysCommand script.
keyFile, err := os.ReadFile(filepath.Join(iamKeysDir, "admin"))
if err != nil {
t.Fatalf("read key file: %v", err)
}
if string(keyFile) != "ssh-ed25519 AAAA admin@theta\n" {
t.Fatalf("key file mismatch: %q", keyFile)
}
script, err := os.ReadFile(iamKeysCommand)
if err != nil {
t.Fatalf("read keys command: %v", err)
}
if string(script) != "#!/bin/sh\ncat \""+iamKeysDir+"/$1\" 2>/dev/null\n" {
t.Fatalf("keys command mismatch: %q", script)
}
// Access control.
access, err := os.ReadFile(iamAccessConf)
if err != nil {
t.Fatalf("read access.conf: %v", err)
}
wantAccess := "+:sysadmins:ALL\n+:node-operators:ALL\n-:ALL:ALL\n"
if string(access) != wantAccess {
t.Fatalf("access.conf mismatch:\n got: %q\nwant: %q", access, wantAccess)
}
// Commands: visudo -c -f, sss_cache -E, pkill -u olduser.
ran := map[string]bool{}
for _, c := range exec.ExecutedCommands {
if c[0] == "visudo" {
ran["visudo"] = true
}
if c[0] == "sss_cache" {
ran["sss_cache"] = true
}
if c[0] == "pkill" && len(c) >= 3 && c[2] == "olduser" {
ran["pkill"] = true
}
}
for _, k := range []string{"visudo", "sss_cache", "pkill"} {
if !ran[k] {
t.Errorf("expected %s to run, got commands %v", k, exec.ExecutedCommands)
}
}
}
// TestParseIAMPayload verifies the payload parses from a WSMessage payload map.
func TestParseIAMPayload(t *testing.T) {
payload := map[string]interface{}{
"node_id": "node-42",
"revision": float64(82),
"access_control": map[string]interface{}{
"allowed_login_groups": []interface{}{"sysadmins"},
"sudo_rules": []interface{}{
map[string]interface{}{"group": "sysadmins", "run_as": "ALL", "commands": []interface{}{"ALL"}, "nopasswd": true},
},
"ssh_keys": []interface{}{
map[string]interface{}{"user": "admin", "keys": []interface{}{"ssh-ed25519 AAAA"}},
},
"revoke_users": []interface{}{"olduser"},
},
}
p, err := parseIAMPayload(payload)
if err != nil {
t.Fatalf("parseIAMPayload: %v", err)
}
if p.NodeID != "node-42" || p.Revision != 82 {
t.Fatalf("bad node/revision: %+v", p)
}
if len(p.AccessControl.SudoRules) != 1 || p.AccessControl.SudoRules[0].Group != "sysadmins" {
t.Fatalf("bad sudo rules: %+v", p.AccessControl.SudoRules)
}
if len(p.AccessControl.SSHKeys) != 1 || p.AccessControl.SSHKeys[0].User != "admin" {
t.Fatalf("bad ssh keys: %+v", p.AccessControl.SSHKeys)
}
if len(p.AccessControl.RevokeUsers) != 1 || p.AccessControl.RevokeUsers[0] != "olduser" {
t.Fatalf("bad revoke users: %+v", p.AccessControl.RevokeUsers)
}
}
+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")
}
+116 -20
View File
@@ -19,7 +19,7 @@ log() { echo -e "${GREEN}[+]${NC} $1"; }
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
# 1. Root check
if [ "$EUID" -ne 0 ]; then
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
error "This script must be run as root."
fi
@@ -29,9 +29,10 @@ install_sssd_deps() {
log "Installing SSSD and PAM integration dependencies..."
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo pam-auth-update || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
if command -v pam-auth-update >/dev/null 2>&1; then
pam-auth-update --enable mkhomedir || true
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
fi
elif command -v dnf >/dev/null 2>&1; then
dnf install -y sssd sssd-ldap sssd-tools || true
@@ -45,15 +46,19 @@ install_sssd_deps() {
else
log "SSSD is already installed."
fi
mkdir -p /etc/sssd
chmod 755 /etc/sssd
}
# 2. Argument Parsing
URL=""
TOKEN=""
JOIN_KEY=""
PUBLIC_KEY=""
B64_CONFIG=""
INSTALL_SSSD=0
while [[ $# -gt 0 ]]; do
while [ $# -gt 0 ]; do
case $1 in
--url)
URL="$2"
@@ -63,6 +68,21 @@ while [[ $# -gt 0 ]]; do
TOKEN="$2"
shift 2
;;
# Base64 of the SSO's raw Ed25519 public key. The agent verifies high-risk
# commands (reboot, configure_ldap, arbitrary_bash, update_binary) against
# it and REFUSES them when it is absent, so an install without this key can
# stream telemetry but cannot be acted on.
--public-key)
PUBLIC_KEY="$2"
shift 2
;;
# The one credential an operator hands out. The server exchanges it for a
# per-agent token on first connect, which the agent writes back into
# agent.yml -- so this is all you need to add a host.
--join-key)
JOIN_KEY="$2"
shift 2
;;
--install-sssd|--ldap)
INSTALL_SSSD=1
shift
@@ -74,21 +94,58 @@ while [[ $# -gt 0 ]]; do
esac
done
# Validation
if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then
error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token."
# Validation: require credentials ONLY if config file does not already exist
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
echo "Usage examples:"
echo " sh install.sh \"BASE64_CONFIG\""
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\" --install-sssd"
echo " sh install.sh --url \"https://sso.local\" --join-key \"tjk_...\" --install-sssd"
echo " sh install.sh --url \"https://sso.local\" --token \"ISSUED_TOKEN\" --public-key \"BASE64_KEY\""
echo ""
echo "--join-key is the normal path: the host enrolls itself on first connect"
echo "and the SSO issues it its own token + public key, which the agent writes"
echo "back into agent.yml. Get a key from Directory -> Install Agent."
exit 1
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" || error "Failed to download binary."
chmod +x "$BIN_PATH"
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"
# 4. Setup configuration
log "Preparing configuration directory $CONFIG_DIR..."
@@ -98,26 +155,67 @@ chmod 755 "$CONFIG_DIR"
if [ -n "$B64_CONFIG" ]; then
log "Decoding and writing configuration from base64..."
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
else
elif [ ! -f "$CONFIG_FILE" ]; then
log "Generating minimal configuration from arguments..."
# Create a minimal yaml with the provided URL and Token
cat <<EOF > "$CONFIG_FILE"
server_url: "$URL"
auth_token: "$TOKEN"
join_key: "$JOIN_KEY"
public_key: "$PUBLIC_KEY"
location: "unknown"
capabilities:
telemetry: true
configure_ldap: false
configure_ldap: true
ldap_tunnel: true
reboot: false
service_control: []
arbitrary_bash: false
EOF
else
log "Preserving existing configuration at $CONFIG_FILE"
fi
chmod 600 "$CONFIG_FILE"
# 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 -q -i "configure_ldap:\s*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
@@ -132,8 +230,6 @@ Type=simple
ExecStart=$BIN_PATH
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent
[Install]
+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"
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"encoding/json"
"os"
"testing"
)
// Cross-implementation check: a payload signed by the Node server (utils/
// agent_manager.js) must verify with the agent's own verifySignature. Skips
// unless the fixture is present, so it never breaks a normal `go test`.
func TestInteropWithServerSignature(t *testing.T) {
raw, err := os.ReadFile(os.Getenv("INTEROP_FIXTURE"))
if err != nil {
t.Skip("no INTEROP_FIXTURE provided")
}
var fx struct {
Pub string `json:"pub"`
Payload map[string]interface{} `json:"payload"`
Sig string `json:"sig"`
}
if err := json.Unmarshal(raw, &fx); err != nil {
t.Fatalf("bad fixture: %v", err)
}
payload := map[string]interface{}{}
for k, v := range fx.Payload {
payload[k] = v
}
payload["signature"] = fx.Sig
cfg := &Config{PublicKey: fx.Pub}
if !verifySignature(cfg, WSMessage{Type: "arbitrary_bash", Payload: payload}) {
t.Fatal("agent REJECTED a signature produced by the SSO server")
}
t.Log("agent accepted the server-produced signature")
}
+196
View File
@@ -0,0 +1,196 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/gorilla/websocket"
)
// ldapTunnel is the agent's local LDAP socket (DESIGN.md §4). It is a pure byte
// pump: bytes from a local LDAP client (SSSD) are forwarded to the SSO over the
// WSS channel as `ldap_tunnel` messages, and the SSO's responses are written
// back to the socket. The agent never parses LDAP — it does not know or care
// what the bytes mean.
//
// Each local connection gets a conn_id. The agent reads the socket and sends
// chunks up; the SSO relays them into its real OpenLDAP and sends the response
// chunks back down; the agent writes them to the socket. `close:true` ends a
// connection.
type ldapTunnel struct {
mu sync.Mutex
conns map[string]net.Conn
send func(WSMessage) error
}
func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
return &ldapTunnel{
conns: make(map[string]net.Conn),
send: send,
}
}
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
// 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)
}
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)
lnTcp, errTcp := net.Listen("tcp", "127.0.0.1:389")
if errTcp != nil {
lnTcp, errTcp = net.Listen("tcp", "127.0.0.1:3890")
}
if errTcp == nil {
log.Printf("LDAP tunnel: listening on tcp %s", lnTcp.Addr().String())
go t.acceptLoop(lnTcp, stopCh)
} else {
log.Printf("LDAP tunnel: cannot bind tcp loopback: %v", errTcp)
}
}
func (t *ldapTunnel) acceptLoop(ln net.Listener, stopCh <-chan struct{}) {
defer ln.Close()
go func() {
<-stopCh
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
return
}
go t.handleConn(conn)
}
}
// handleConn pumps one local LDAP connection up to the SSO.
func (t *ldapTunnel) handleConn(conn net.Conn) {
connID := newConnID()
t.mu.Lock()
t.conns[connID] = conn
t.mu.Unlock()
buf := make([]byte, 32*1024)
for {
n, err := conn.Read(buf)
if n > 0 {
msg := WSMessage{
Type: "ldap_tunnel",
Payload: map[string]interface{}{
"conn_id": connID,
"data": base64.StdEncoding.EncodeToString(buf[:n]),
},
}
if err := t.send(msg); err != nil {
break
}
}
if err != nil {
break
}
}
// Signal end of connection to the SSO so it can close its OpenLDAP relay.
t.send(WSMessage{
Type: "ldap_tunnel",
Payload: map[string]interface{}{
"conn_id": connID,
"close": true,
},
})
t.mu.Lock()
delete(t.conns, connID)
t.mu.Unlock()
conn.Close()
}
// handleMessage writes SSO→agent tunnel bytes to the matching local socket.
// Called from handleCommand when an `ldap_tunnel` message arrives.
func (t *ldapTunnel) handleMessage(payload map[string]interface{}) {
connID, _ := payload["conn_id"].(string)
if connID == "" {
return
}
if closeFlag, _ := payload["close"].(bool); closeFlag {
t.mu.Lock()
conn := t.conns[connID]
delete(t.conns, connID)
t.mu.Unlock()
if conn != nil {
conn.Close()
}
return
}
dataStr, _ := payload["data"].(string)
if dataStr == "" {
return
}
data, err := base64.StdEncoding.DecodeString(dataStr)
if err != nil {
return
}
t.mu.Lock()
conn := t.conns[connID]
t.mu.Unlock()
if conn != nil {
conn.Write(data)
}
}
var connCounter uint64
func newConnID() string {
connCounter++
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), connCounter)
}
// safeWriter serializes writes to the WebSocket. Gorilla allows only one
// concurrent writer, but the agent has several (telemetry, heartbeat, the LDAP
// tunnel, command responses) — without this, concurrent WriteMessage calls
// corrupt the stream.
type safeWriter struct {
mu sync.Mutex
c *websocket.Conn
}
func (w *safeWriter) WriteMessage(messageType int, data []byte) error {
w.mu.Lock()
defer w.mu.Unlock()
return w.c.WriteMessage(messageType, data)
}
// sendTunnelMessage marshals a WSMessage and writes it as a text frame.
func sendTunnelMessage(w MessageWriter, msg WSMessage) error {
payload, err := json.Marshal(msg)
if err != nil {
return err
}
return w.WriteMessage(websocket.TextMessage, payload)
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"encoding/base64"
"net"
"path/filepath"
"sync"
"testing"
"time"
)
// TestLdapTunnelBytePump verifies the agent is a pure byte pump: bytes written
// to the local socket are forwarded up as ldap_tunnel messages, and bytes sent
// back down are written to the socket. No LDAP parsing anywhere.
func TestLdapTunnelBytePump(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "ldap.sock")
var mu sync.Mutex
var sent []WSMessage
tunnel := newLdapTunnel(func(msg WSMessage) error {
mu.Lock()
sent = append(sent, msg)
mu.Unlock()
return nil
})
stopCh := make(chan struct{})
defer close(stopCh)
go tunnel.start(socketPath, stopCh)
// Wait for the socket to exist.
deadline := time.Now().Add(2 * time.Second)
for {
if _, err := net.Dial("unix", socketPath); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("socket never became reachable")
}
time.Sleep(10 * time.Millisecond)
}
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Write bytes up to the SSO.
if _, err := conn.Write([]byte("hello-ldap")); err != nil {
t.Fatalf("write: %v", err)
}
// The tunnel should forward them as a base64 ldap_tunnel message.
var upMsg WSMessage
deadline = time.Now().Add(2 * time.Second)
for {
mu.Lock()
if len(sent) > 0 {
upMsg = sent[0]
mu.Unlock()
break
}
mu.Unlock()
if time.Now().After(deadline) {
t.Fatal("no ldap_tunnel message was sent")
}
time.Sleep(10 * time.Millisecond)
}
if upMsg.Type != "ldap_tunnel" {
t.Fatalf("expected ldap_tunnel type, got %q", upMsg.Type)
}
connID, _ := upMsg.Payload["conn_id"].(string)
if connID == "" {
t.Fatal("missing conn_id")
}
dataStr, _ := upMsg.Payload["data"].(string)
decoded, err := base64.StdEncoding.DecodeString(dataStr)
if err != nil {
t.Fatalf("bad base64: %v", err)
}
if string(decoded) != "hello-ldap" {
t.Fatalf("expected 'hello-ldap', got %q", decoded)
}
// Send bytes back down from the SSO; the client should receive them.
tunnel.handleMessage(map[string]interface{}{
"conn_id": connID,
"data": base64.StdEncoding.EncodeToString([]byte("world-ldap")),
})
buf := make([]byte, 32)
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(buf[:n]) != "world-ldap" {
t.Fatalf("expected 'world-ldap', got %q", buf[:n])
}
// Closing the client should emit a close signal up to the SSO.
conn.Close()
deadline = time.Now().Add(2 * time.Second)
for {
mu.Lock()
closed := false
for _, m := range sent {
if m.Type == "ldap_tunnel" {
if c, _ := m.Payload["close"].(bool); c {
closed = true
}
}
}
mu.Unlock()
if closed {
break
}
if time.Now().After(deadline) {
t.Fatal("no close signal was sent")
}
time.Sleep(10 * time.Millisecond)
}
}
+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
}
+71 -9
View File
@@ -5,15 +5,55 @@ import (
"log"
"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"
if len(os.Args) > 1 {
configPath := defaultConfigPath()
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
configPath = os.Args[1]
}
@@ -23,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,
@@ -31,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
}
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
// Secrets engine (DESIGN.md §5). The agent renders local templates that embed
// OpenBao secrets, e.g.:
//
// # /etc/theta/templates/db.env.tpl
// DB_USER="{{ bao "secret/data/nodes/node-42/db#username" }}"
// DB_PASS="{{ bao "secret/data/nodes/node-42/db#password" }}"
//
// The agent parses the `{{ bao "path#key" }}` placeholders, fetches the secret
// values from the SSO (which holds the OpenBao access), renders each template to
// its target atomically (0600), and runs the configured reload. The agent never
// holds a Vault token.
var baoRe = regexp.MustCompile(`\{\{\s*bao\s+"([^"]+)"\s*\}\}`)
// renderSecrets renders every configured secret template. Called on a
// `render_secrets` command (signed) and on boot.
func renderSecrets(cfg *Config, exec Executor) error {
if len(cfg.Secrets) == 0 {
return nil
}
// Collect the unique secret paths referenced across all templates.
pathSet := map[string]bool{}
var paths []string
for _, t := range cfg.Secrets {
content, err := os.ReadFile(t.Template)
if err != nil {
log.Printf("Secrets: cannot read template %s: %v", t.Template, err)
continue
}
for _, m := range baoRe.FindAllStringSubmatch(string(content), -1) {
path := refPath(m[1])
if !pathSet[path] {
pathSet[path] = true
paths = append(paths, path)
}
}
}
if len(paths) == 0 {
return nil
}
secrets, err := fetchSecrets(cfg, paths)
if err != nil {
return err
}
for _, t := range cfg.Secrets {
if err := renderOne(t, secrets, exec); err != nil {
log.Printf("Secrets: render %s failed: %v", t.Template, err)
}
}
return nil
}
func renderOne(t SecretTarget, secrets map[string]map[string]interface{}, exec Executor) error {
content, err := os.ReadFile(t.Template)
if err != nil {
return err
}
out := baoRe.ReplaceAllStringFunc(string(content), func(match string) string {
ref := baoRe.FindStringSubmatch(match)[1]
path, key := refPath(ref), refKey(ref)
if v, ok := secrets[path][key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
})
// Atomic write: temp file in the target's directory, then rename. 0600 — the
// rendered file holds secrets.
tmp, err := os.CreateTemp(filepath.Dir(t.Target), ".theta-secret-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := io.WriteString(tmp, out); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
os.Chmod(tmpName, 0600)
if err := os.Rename(tmpName, t.Target); err != nil {
os.Remove(tmpName)
return err
}
if t.Reload != "" {
if _, err := exec.Execute("sh", "-c", t.Reload); err != nil {
log.Printf("Secrets: reload %q failed: %v", t.Reload, err)
}
}
return nil
}
// fetchSecrets asks the SSO for the given node-scoped secret paths.
func fetchSecrets(cfg *Config, paths []string) (map[string]map[string]interface{}, error) {
base := strings.Replace(cfg.ServerURL, "wss://", "https://", 1)
base = strings.Replace(base, "ws://", "http://", 1)
url := strings.TrimRight(base, "/") + "/api/v1/agent/secrets"
body, _ := json.Marshal(map[string]interface{}{"paths": paths})
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.Credential())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("secrets fetch failed: %s", resp.Status)
}
var out struct {
Secrets map[string]map[string]interface{} `json:"secrets"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out.Secrets, nil
}
// refPath returns the secret path from a `path#key` reference.
func refPath(ref string) string {
path := ref
if i := strings.Index(ref, "#"); i >= 0 {
path = ref[:i]
}
if strings.HasPrefix(path, "resource/") {
return "secret/data/resources/" + strings.TrimPrefix(path, "resource/") + "/conf"
}
if strings.HasPrefix(path, "resources/") {
return "secret/data/resources/" + strings.TrimPrefix(path, "resources/") + "/conf"
}
if !strings.HasPrefix(path, "secret/") {
return "secret/data/resources/" + path + "/conf"
}
return path
}
// refKey returns the key from a `path#key` reference.
func refKey(ref string) string {
if i := strings.Index(ref, "#"); i >= 0 {
return ref[i+1:]
}
return ""
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
)
// TestRenderSecrets verifies the agent parses `{{ bao "path#key" }}` placeholders,
// fetches the secrets from the SSO, renders the template to its target
// atomically, and runs the reload.
func TestRenderSecrets(t *testing.T) {
dir := t.TempDir()
tpl := filepath.Join(dir, "db.env.tpl")
target := filepath.Join(dir, "db.env")
os.WriteFile(tpl, []byte("DB_USER=\"{{ bao \"secret/data/nodes/n1/db#username\" }}\"\nDB_PASS=\"{{ bao \"secret/data/nodes/n1/db#password\" }}\"\n"), 0600)
// Fake SSO secrets endpoint.
var gotPaths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agent/secrets" {
w.WriteHeader(404)
return
}
var req struct{ Paths []string `json:"paths"` }
json.NewDecoder(r.Body).Decode(&req)
gotPaths = req.Paths
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"secrets": map[string]interface{}{
"secret/data/nodes/n1/db": map[string]interface{}{
"username": "alice",
"password": "s3cret",
},
},
})
}))
defer srv.Close()
cfg := &Config{
ServerURL: srv.URL,
AuthToken: "tok",
Secrets: []SecretTarget{
{Template: tpl, Target: target, Reload: ""},
},
}
exec := &MockExecutor{}
if err := renderSecrets(cfg, exec); err != nil {
t.Fatalf("renderSecrets: %v", err)
}
// The requested path should be the one in the template.
if len(gotPaths) != 1 || gotPaths[0] != "secret/data/nodes/n1/db" {
t.Fatalf("expected to request secret/data/nodes/n1/db, got %v", gotPaths)
}
// The target should be rendered with the secret values.
content, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read target: %v", err)
}
expected := "DB_USER=\"alice\"\nDB_PASS=\"s3cret\"\n"
if string(content) != expected {
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
}
// 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())
}
}
}
// TestRenderSecretsReload verifies the reload command runs after rendering.
func TestRenderSecretsReload(t *testing.T) {
dir := t.TempDir()
tpl := filepath.Join(dir, "app.tpl")
target := filepath.Join(dir, "app.conf")
os.WriteFile(tpl, []byte("KEY={{ bao \"secret/data/nodes/n1/app#key\" }}"), 0600)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"secrets": map[string]interface{}{
"secret/data/nodes/n1/app": map[string]interface{}{"key": "v"},
},
})
}))
defer srv.Close()
cfg := &Config{
ServerURL: srv.URL,
AuthToken: "tok",
Secrets: []SecretTarget{
{Template: tpl, Target: target, Reload: "systemctl reload app"},
},
}
exec := &MockExecutor{}
if err := renderSecrets(cfg, exec); err != nil {
t.Fatalf("renderSecrets: %v", err)
}
if len(exec.ExecutedCommands) != 1 {
t.Fatalf("expected 1 reload command, got %v", exec.ExecutedCommands)
}
cmd := exec.ExecutedCommands[0]
if len(cmd) != 3 || cmd[0] != "sh" || cmd[2] != "systemctl reload app" {
t.Fatalf("expected reload 'sh -c systemctl reload app', got %v", cmd)
}
}
+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
}
}
}
+504 -52
View File
@@ -3,8 +3,13 @@ package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
@@ -15,26 +20,413 @@ 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"`
OS string `json:"os"`
Kernel string `json:"kernel"`
CPUModel string `json:"cpu"`
RAMTotalGB float64 `json:"ram_total_gb"`
DiskTotalGB float64 `json:"disk_total_gb"`
Location string `json:"location"`
Hostname string `json:"hostname"`
IPs []string `json:"ip_addresses"`
PublicIP string `json:"public_ip"`
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 {
client := &http.Client{Timeout: 3 * time.Second}
endpoints := []string{
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
}
for _, ep := range endpoints {
resp, err := client.Get(ep)
if err == nil && resp.StatusCode == 200 {
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err == nil {
ip := strings.TrimSpace(string(body))
if net.ParseIP(ip) != nil {
return ip
}
}
}
}
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()
@@ -49,45 +441,98 @@ 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()
}
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,
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,
"shutdown": true,
"desktop_controls": true,
"service_control": cfg.Capabilities.ServiceControl,
"arbitrary_bash": cfg.Capabilities.ArbitraryBash,
},
}
}
// 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),
}
}
@@ -117,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 {
@@ -149,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()
@@ -211,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
}
+334 -51
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
@@ -11,7 +12,6 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
@@ -23,14 +23,55 @@ type WSMessage struct {
Payload map[string]interface{} `json:"payload"`
}
// Application close codes the SSO uses to say "your enrollment is the problem"
// (PROTOCOL.md §1.1). All three mean retrying quickly is pointless.
const (
closeUnauthorized = 4001 // token was never issued, or is unknown
closeSuperseded = 4002 // another connection took over this enrollment
closeRevoked = 4003 // enrollment revoked or deleted by an admin
closeTokenRotated = 4004 // token rotated; agent.yml holds the old one
)
// How long to wait before retrying after the server rejects our credential.
// Short enough that a re-enrollment is picked up without a restart, long enough
// that a decommissioned agent is not a permanent load on the SSO.
const authRetryInterval = 5 * time.Minute
type MessageWriter interface {
WriteMessage(messageType int, data []byte) error
}
// canonicalize produces the exact bytes the server signed (PROTOCOL.md §5):
// keys sorted alphabetically, no whitespace, `signature` omitted.
//
// encoding/json sorts map keys for us, but by default it also escapes <, > and
// & as <, > and & -- which Node's JSON.stringify on the server
// does not. Any payload containing those characters therefore hashed
// differently on each side and the signature failed. For arbitrary_bash that is
// most real scripts: `>` redirection and `&&` are everywhere. SetEscapeHTML
// (false) is what makes the two encoders agree.
//
// Encoder.Encode also appends a trailing newline, which must be trimmed or it
// is signed-over data the server never produced.
func canonicalize(payload map[string]interface{}) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(payload); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
func verifySignature(cfg *Config, msg WSMessage) bool {
// Fail CLOSED. This used to return true when no public key was configured,
// which meant an agent installed without a `public_key` would execute
// reboot / configure_ldap / arbitrary_bash from anything that could reach
// its socket, with no verification at all -- the exact commands the
// signature exists to protect. An agent that cannot verify must not act.
if cfg.PublicKey == "" {
log.Println("No public key configured; skipping signature verification")
return true
log.Println("Refusing high-risk command: no public_key configured in agent.yml")
return false
}
sigB64, ok := msg.Payload["signature"].(string)
@@ -52,7 +93,11 @@ func verifySignature(cfg *Config, msg WSMessage) bool {
payloadCopy[k] = v
}
}
canonicalPayload, _ := json.Marshal(payloadCopy)
canonicalPayload, err := canonicalize(payloadCopy)
if err != nil {
log.Printf("Could not canonicalize payload for verification: %v", err)
return false
}
pubKeyBytes, err := base64.StdEncoding.DecodeString(cfg.PublicKey)
if err != nil || len(pubKeyBytes) != ed25519.PublicKeySize {
@@ -75,23 +120,66 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
log.Fatalf("Invalid ServerURL: %v", err)
}
u.Path = "/api/agent/ws"
u.RawQuery = "token=" + cfg.AuthToken
// Our own token once enrolled, else the join key. The hostname lets the
// server name a self-enrolling host something meaningful instead of a
// generated placeholder.
q := url.Values{}
q.Set("token", cfg.Credential())
if hn, err := os.Hostname(); err == nil && hn != "" {
q.Set("hostname", hn)
}
u.RawQuery = q.Encode()
log.Printf("Connecting to %s", u.String())
if cfg.Credential() == "" {
log.Printf("No auth_token or join_key in %s -- nothing to authenticate with. Retrying in %s.", cm.configPath, authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
// Never log u.String(): RawQuery carries the auth token, and agent logs
// are routinely shipped around and pasted into issues.
log.Printf("Connecting to %s%s", u.Host, u.Path)
c, resp, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
// The server now rejects tokens it did not issue. Retrying a bad
// 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 Theta Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
log.Printf("Dial error: %v. Retrying in 5 seconds...", err)
time.Sleep(5 * time.Second)
continue
}
log.Println("Successfully connected to SSO Manager.")
log.Println("Successfully connected to Theta Directory.")
wsConnected.Store(true)
stopCh := make(chan struct{})
// All outbound writes go through the safe writer: gorilla allows only one
// concurrent writer, and telemetry, heartbeat, the LDAP tunnel and command
// responses all write to the same socket.
sw := &safeWriter{c: c}
// Local LDAP byte-pump tunnel (DESIGN.md §4). The agent never parses LDAP;
// it forwards raw bytes to the SSO and writes the responses back.
tunnel := newLdapTunnel(func(msg WSMessage) error {
return sendTunnelMessage(sw, msg)
})
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
socketPath := cfg.LdapSocket
if socketPath == "" {
socketPath = defaultLdapSocketPath()
}
go tunnel.start(socketPath, stopCh)
}
// Start telemetry and discovery with stopCh lifecycle control
StartTelemetryLoop(c, cm, exec, stopCh)
StartTelemetryLoop(sw, cm, exec, stopCh)
// Heartbeat loop
go func() {
@@ -104,18 +192,31 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
case <-ticker.C:
hb := WSMessage{Type: "heartbeat", Payload: map[string]interface{}{"timestamp": time.Now().Format(time.RFC3339)}}
payload, _ := json.Marshal(hb)
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
if err := sw.WriteMessage(websocket.TextMessage, payload); err != nil {
return
}
}
}
}()
// Set when the server closes us for an enrollment problem rather than a
// transient fault, so the reconnect below can back off instead of
// spinning on a credential that will not start working by itself.
authRejected := false
// Read loop
for {
_, message, err := c.ReadMessage()
if err != nil {
log.Println("WebSocket read error:", err)
// The SSO accepts the upgrade and only then closes with an
// application code, so an auth failure surfaces here rather
// 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 Theta Directory — re-enroll it and update agent.yml.", err)
} else {
log.Println("WebSocket read error:", err)
}
break // break read loop, reconnect
}
@@ -125,21 +226,34 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
continue
}
handleCommand(cm, msg, c, exec)
handleCommand(cm, msg, sw, exec, tunnel)
}
// Cleanup on disconnect
wsConnected.Store(false)
close(stopCh)
c.Close()
if authRejected {
log.Printf("Reconnecting in %s.", authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
log.Println("WebSocket disconnected. Reconnecting in 5 seconds...")
time.Sleep(5 * time.Second)
}
}
func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Executor) {
func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Executor, tunnel *ldapTunnel) {
cfg := cm.Get()
log.Printf("Received command: %s", msg.Type)
// Don't log the server's fire-and-forget heartbeat ack — it arrives every
// 60s and is not a command to act on; logging it is pure per-minute noise.
// The LDAP tunnel is high-frequency (every chunk of a bind/search), so it is
// not logged either.
if msg.Type != "heartbeat_ack" && msg.Type != "ldap_tunnel" {
log.Printf("Received command: %s", msg.Type)
}
sendResponse := func(status string, message string) {
resp, _ := json.Marshal(map[string]string{"status": status, "message": message})
@@ -147,6 +261,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
}
switch msg.Type {
case "ldap_tunnel":
// SSO→agent direction of the LDAP byte pump: write the response bytes to
// the matching local socket.
if tunnel != nil {
tunnel.handleMessage(msg.Payload)
}
case "reload_config":
if err := cm.Reload(); err != nil {
log.Printf("Reload failed: %v", err)
@@ -170,10 +290,13 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
linesCount := 100
if l, ok := msg.Payload["lines"].(float64); ok && l > 0 {
linesCount = int(l)
if linesCount > 2000 {
linesCount = 2000
}
}
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")
@@ -205,14 +328,34 @@ 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.
if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled {
token, _ := msg.Payload["auth_token"].(string)
pubKey, _ := msg.Payload["public_key"].(string)
if err := cm.PersistEnrollment(token, pubKey); err != nil {
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 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":
@@ -226,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) {
@@ -240,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
@@ -264,20 +483,94 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return
}
log.Println("Pushing updated SSSD configuration...")
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
}
log.Println("Restarting SSSD service...")
if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil {
log.Printf("SSSD restart failed: %v", err)
sendResponse("error", "failed to restart sssd")
sendResponse("ok", "LDAP configuration updated")
case "render_secrets":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
sendResponse("ok", "LDAP configuration updated")
if !cfg.Capabilities.Secrets {
log.Println("Secrets render rejected: capability disabled in agent.yml")
sendResponse("error", "secrets capability disabled")
return
}
log.Println("Rendering secret templates...")
if err := renderSecrets(cfg, exec); err != nil {
log.Printf("Secrets render failed: %v", err)
sendResponse("error", fmt.Sprintf("secrets render failed: %v", err))
return
}
sendResponse("ok", "secrets rendered")
case "iam_apply":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
if !cfg.Capabilities.IAM {
log.Println("IAM apply rejected: capability disabled in agent.yml")
sendResponse("error", "iam capability disabled")
return
}
payload, err := parseIAMPayload(msg.Payload)
if err != nil {
log.Printf("IAM apply: bad payload: %v", err)
sendResponse("error", "invalid IAM payload")
return
}
log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID)
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")
@@ -297,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))
@@ -324,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)
@@ -347,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
}
+196 -10
View File
@@ -1,11 +1,43 @@
package main
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"testing"
)
// A fixed key pair for the tests, standing in for the SSO's persisted signing
// key. High-risk commands must now be genuinely signed: the agent fails closed
// when no public_key is configured, so these tests sign the way the server
// does instead of relying on verification being skipped.
var testPubKey, testPrivKey, _ = ed25519.GenerateKey(nil)
func testPubKeyB64() string {
return base64.StdEncoding.EncodeToString(testPubKey)
}
// sign mirrors the server's canonicalization (sorted keys, no whitespace, no
// HTML escaping, `signature` omitted) and adds the signature to the payload.
func sign(t *testing.T, payload map[string]interface{}) map[string]interface{} {
t.Helper()
if payload == nil {
payload = map[string]interface{}{}
}
canonical, err := canonicalize(payload)
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
signed := make(map[string]interface{}, len(payload)+1)
for k, v := range payload {
signed[k] = v
}
signed["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(testPrivKey, canonical))
return signed
}
type MockConn struct {
Messages [][]byte
}
@@ -44,13 +76,19 @@ func (m *MockExecutor) ReadFile(path string) ([]byte, error) {
func TestHandleCommand(t *testing.T) {
tests := []struct {
name string
cfg *Config
msg WSMessage
expectedStatus string
expectedCmd []string
expectedFile string
name string
cfg *Config
msg WSMessage
expectedStatus string
expectedCmd []string
expectedFile string
expectedFileCont string
// sign the payload with the test key before dispatch, the way the SSO
// signs high-risk commands
signed bool
// heartbeat_ack (and any fire-and-forget ack) must be silently ignored —
// no response message, no command, no log noise.
expectedNoResponse bool
}{
{
name: "config command success",
@@ -66,11 +104,13 @@ func TestHandleCommand(t *testing.T) {
{
name: "reboot command allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{Reboot: true},
},
msg: WSMessage{
Type: "reboot",
},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"reboot"},
},
@@ -120,6 +160,7 @@ func TestHandleCommand(t *testing.T) {
{
name: "configure_ldap allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{ConfigureLDAP: true},
},
msg: WSMessage{
@@ -128,10 +169,11 @@ func TestHandleCommand(t *testing.T) {
"config": "domain = theta42.local\nserver = sso.local",
},
},
expectedStatus: "ok",
expectedFile: "/etc/sssd/sssd.conf",
signed: true,
expectedStatus: "ok",
expectedFile: "/etc/sssd/sssd.conf",
expectedFileCont: "domain = theta42.local\nserver = sso.local",
expectedCmd: []string{"systemctl", "restart", "sssd"},
expectedCmd: []string{"systemctl", "restart", "sssd"},
},
{
name: "configure_ldap denied",
@@ -150,6 +192,7 @@ func TestHandleCommand(t *testing.T) {
{
name: "arbitrary_bash allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{ArbitraryBash: true},
},
msg: WSMessage{
@@ -158,6 +201,7 @@ func TestHandleCommand(t *testing.T) {
"script": "uptime",
},
},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"bash", "-c", "uptime"},
},
@@ -175,6 +219,57 @@ 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{
Capabilities: Capabilities{},
},
msg: WSMessage{
Type: "heartbeat_ack",
},
expectedNoResponse: true,
},
{
name: "unknown command",
cfg: &Config{
@@ -192,7 +287,34 @@ func TestHandleCommand(t *testing.T) {
mockConn := &MockConn{}
mockExec := &MockExecutor{}
cm := &ConfigManager{current: tc.cfg}
handleCommand(cm, tc.msg, mockConn, mockExec)
// 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)
}
handleCommand(cm, msg, mockConn, mockExec, nil)
if tc.expectedNoResponse {
if len(mockConn.Messages) != 0 {
t.Fatalf("expected no response message, got %d: %v", len(mockConn.Messages), mockConn.Messages)
}
if len(mockExec.ExecutedCommands) > 0 {
t.Errorf("expected no commands to be executed, but got %v", mockExec.ExecutedCommands)
}
return
}
if len(mockConn.Messages) != 1 {
t.Fatalf("expected 1 response message, got %d", len(mockConn.Messages))
@@ -236,3 +358,67 @@ func TestHandleCommand(t *testing.T) {
})
}
}
// The agent must not execute a high-risk command it cannot verify. This used to
// return true when no public_key was configured, so an agent installed without
// one executed reboot / configure_ldap / arbitrary_bash unverified.
func TestVerifySignatureFailsClosedWithoutPublicKey(t *testing.T) {
cfg := &Config{} // no PublicKey
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
if verifySignature(cfg, msg) {
t.Fatal("verifySignature accepted a command with no public_key configured")
}
}
func TestVerifySignatureRejectsWrongKey(t *testing.T) {
otherPub, _, _ := ed25519.GenerateKey(nil)
cfg := &Config{PublicKey: base64.StdEncoding.EncodeToString(otherPub)}
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": "uptime"})}
if verifySignature(cfg, msg) {
t.Fatal("verifySignature accepted a signature from a different key")
}
}
func TestVerifySignatureRejectsTamperedPayload(t *testing.T) {
cfg := &Config{PublicKey: testPubKeyB64()}
payload := sign(t, map[string]interface{}{"script": "uptime"})
payload["script"] = "rm -rf /" // swap the script, keep the signature
if verifySignature(cfg, WSMessage{Type: "arbitrary_bash", Payload: payload}) {
t.Fatal("verifySignature accepted a payload modified after signing")
}
}
// Regression: encoding/json escapes <, > and & by default, but the server's
// JSON.stringify does not. Any script using redirection or && therefore
// canonicalized differently on each side and failed verification -- which is
// most real scripts.
func TestVerifySignatureAcceptsShellMetacharacters(t *testing.T) {
cfg := &Config{PublicKey: testPubKeyB64()}
for _, script := range []string{
"echo hi > /tmp//out.log",
"systemctl is-active nginx && systemctl reload nginx",
"grep -c . < /etc/passwd",
"a=1 && b=2 && echo \"$a<$b\" > /dev/null",
} {
msg := WSMessage{Type: "arbitrary_bash", Payload: sign(t, map[string]interface{}{"script": script})}
if !verifySignature(cfg, msg) {
t.Errorf("verifySignature rejected a correctly signed script: %q", script)
}
}
}
// The canonical form must be byte-identical to the server's: sorted keys, no
// whitespace, no HTML escaping, no trailing newline, signature omitted.
func TestCanonicalizeMatchesServerForm(t *testing.T) {
got, err := canonicalize(map[string]interface{}{
"script": "echo a > b && c",
"comment": "x&y",
})
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
want := `{"comment":"x&y","script":"echo a > b && c"}`
if string(got) != want {
t.Errorf("canonical form mismatch:\n got: %s\nwant: %s", got, want)
}
}