Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef9d4b004b | |||
| 4074f108c1 | |||
| 12d55a4454 | |||
| 1b332cacab | |||
| 52eba72613 | |||
| c7d599de85 | |||
| 230b7fb172 | |||
| b2ad8f4844 | |||
| a3eeed8112 | |||
| f76f50c704 | |||
| 1837d18da8 | |||
| 9fdbb8aab0 | |||
| d937f8dcba | |||
| 2c42960c61 | |||
| db6253e263 | |||
| 5013148ffe | |||
| da158b0adc | |||
| 98daa81a92 | |||
| d18de7d109 | |||
| 016a8fc6b6 | |||
| ba2a619db5 | |||
| e613874de5 | |||
| 4a619f7adc | |||
| 7a6eb84d36 | |||
| 565a171797 | |||
| e13aa6a15c | |||
| 775878437e | |||
| a3a21c3884 | |||
| a423fec835 | |||
| 37e7ffd25d | |||
| 16cab898ed | |||
| c676c658ed | |||
| 34f6f685fc | |||
| 2a4fb21440 | |||
| efae05686e | |||
| 57d5dfc174 | |||
| 4aa233f507 | |||
| 48248475c4 |
@@ -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
@@ -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/
|
||||||
@@ -5,6 +5,89 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **New tray icon set** (`cmd/icon-gen`, generated `cmd/theta-agent-tray/icons.go`) — the state badges (Red/Yellow/Green/Blue) are now a rounded-square badge with a subtle vertical gradient and a crisp white theta glyph, rendered at 256px with 4x4 supersampling instead of the old flat 48px circle. Windows gets a proper multi-size ICO (16/24/32/48/64/128/256) built with an exact box filter (was nearest-neighbour over three sizes), so the tray/taskbar icon is sharp at every DPI.
|
||||||
|
- **Start menu / installer icon** — `installer/windows/theta-agent.ico` (multi-size, Blue badge) is bundled by the installer and used for the Start menu "Theta Agent Tray" shortcut, the setup.exe's own icon (`SetupIconFile`), and the uninstaller's display icon.
|
||||||
|
- Removed the dead duplicate icon byte arrays in the root package's `tray_icons.go` (nothing referenced them; the tray binary carries its own copy).
|
||||||
|
|
||||||
|
## [v2.2.0] - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Windows mDNS local-discovery** (`hosts_override_windows.go`) — completes the Linux mechanism from v2.1.2 on Windows. The hosts override now runs on Windows: `%SystemRoot%\System32\drivers\etc\hosts` (reachable because the agent runs as a SYSTEM service), CRLF-aware read/write, and `ipconfig /flushdns` after every change so the override takes effect promptly despite the Windows DNS Client cache. Verified by the Windows CI leg, which now runs the real Windows hosts path against a temp file instead of skipping.
|
||||||
|
- **Local route pinning** (`local_route.go`, `local_route_windows.go`, `local_route_unix.go`) — the hosts override only fixes *name resolution*; the packet path is decided by the routing table. If the agent's WireGuard mesh tunnel is up with `AllowedIPs` covering the LAN subnet (or a full-tunnel `0.0.0.0/0`), the tunnel route would swallow the direct connection to the discovered LAN IP. Discovery now also pins a `/32` host route for the discovered IP via the owning local interface (`route.exe add ... metric 1` on Windows, `ip route replace` on Linux) and drops it again on revert. This closes a real gap in the shipped Linux path too.
|
||||||
|
- **Prompt reconnect on discovery change** — an apply/revert now signals the WebSocket loop, which reconnects immediately (skipping its 5s backoff) so the new resolution/routing is picked up right away.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `hosts_override.go` split into shared rewrite logic plus platform files; `hosts_override_test.go` no longer skips on non-Linux and covers the CRLF/Windows write path.
|
||||||
|
|
||||||
|
## [v2.1.3] - 2026-08-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **v2.1.2's release build failed on the Windows CI leg.** `TestApplyHostsOverride_*` call `applyHostsOverride()`, which correctly refuses unconditionally on non-Linux — but the tests didn't skip themselves there, so `go test ./...` failed on the Windows build job. v2.1.2's actual code (the mDNS local-discovery feature itself) was never broken; only that release's CI run was. Tests now skip cleanly on non-Linux with a clear reason.
|
||||||
|
|
||||||
|
## [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
|
## [v1.6.0] - 2026-08-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Theta Agent
|
# 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
|
## What you get
|
||||||
|
|
||||||
|
|||||||
+38
-10
@@ -1,5 +1,5 @@
|
|||||||
# theta-agent configuration file
|
# 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"
|
server_url: "https://sso.example.com"
|
||||||
|
|
||||||
@@ -26,33 +26,61 @@ public_key: ""
|
|||||||
|
|
||||||
location: "default" # Location identifier (e.g., site, datacenter) for naming
|
location: "default" # Location identifier (e.g., site, datacenter) for naming
|
||||||
|
|
||||||
# Local LDAP byte-pump socket (DESIGN.md §4). The agent forwards raw LDAP bytes
|
# Local LDAP byte-pump socket (DESIGN.md ??4). The agent forwards raw LDAP bytes
|
||||||
# from this socket to the SSO, which relays them into its OpenLDAP. The agent
|
# from this socket to the SSO, which relays them into its OpenLDAP. The agent
|
||||||
# never parses LDAP. Point SSSD at it with:
|
# never parses LDAP. Point SSSD at it with:
|
||||||
# ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock
|
# 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"
|
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:
|
capabilities:
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Basic Capabilities (Safe, read-only or infrastructure management)
|
# 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
|
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
|
configure_ldap: true
|
||||||
|
|
||||||
# Serve the local LDAP byte-pump socket for SSSD/PAM (DESIGN.md §4)
|
# Serve the local LDAP byte-pump socket for SSSD/PAM (DESIGN.md ??4)
|
||||||
ldap_tunnel: true
|
ldap_tunnel: true
|
||||||
|
|
||||||
# Render OpenBao secrets to local files from /etc/theta/templates (DESIGN.md §5)
|
# Render OpenBao secrets to local files from /etc/theta/templates (DESIGN.md ??5)
|
||||||
secrets: true
|
secrets: true
|
||||||
|
|
||||||
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md §6)
|
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md ??6)
|
||||||
iam: true
|
iam: true
|
||||||
|
|
||||||
# Secret templates to render (DESIGN.md §5). Each maps a local template to a
|
# Accept signed wireguard_apply/wireguard_remove commands (DESIGN-WINDOWS.md ??5)
|
||||||
|
wireguard: false
|
||||||
|
|
||||||
|
# Secret templates to render (DESIGN.md ??5). Each maps a local template to a
|
||||||
# target file and an optional post-render reload. The template embeds secrets as
|
# target file and an optional post-render reload. The template embeds secrets as
|
||||||
# {{ bao "secret/data/nodes/<node-id>/<name>#<key>" }}.
|
# {{ bao "secret/data/nodes/<node-id>/<name>#<key>" }}.
|
||||||
# secrets:
|
# secrets:
|
||||||
@@ -64,7 +92,7 @@ capabilities:
|
|||||||
# Advanced Capabilities (High risk, remote operations)
|
# Advanced Capabilities (High risk, remote operations)
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
# Allow remote system reboots via the SSO Manager
|
# Allow remote system reboots via Theta Directory
|
||||||
reboot: false
|
reboot: false
|
||||||
|
|
||||||
# Allow restarting, starting, or stopping specific systemd services.
|
# Allow restarting, starting, or stopping specific systemd services.
|
||||||
@@ -73,6 +101,6 @@ capabilities:
|
|||||||
# Setting to true or [] denies all.
|
# Setting to true or [] denies all.
|
||||||
service_control: []
|
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.
|
# Useful for GitOps deployments, but allows remote code execution.
|
||||||
arbitrary_bash: false
|
arbitrary_bash: false
|
||||||
|
|||||||
Executable
+60
@@ -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"
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -31,8 +32,14 @@ func handleCLI(args []string) bool {
|
|||||||
case "--reinitialize", "reinitialize", "--reinit", "reinit":
|
case "--reinitialize", "reinitialize", "--reinit", "reinit":
|
||||||
runReinitialize(args[1:])
|
runReinitialize(args[1:])
|
||||||
return true
|
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":
|
case "--version", "version", "-v":
|
||||||
fmt.Println("Theta Agent v1.2.0")
|
fmt.Println("Theta Agent " + AgentVersion)
|
||||||
return true
|
return true
|
||||||
case "--help", "help", "-h":
|
case "--help", "help", "-h":
|
||||||
printUsage()
|
printUsage()
|
||||||
@@ -48,8 +55,10 @@ func printUsage() {
|
|||||||
fmt.Println(" theta-agent Run agent daemon in foreground")
|
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-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 get-secrets [flags] Fetch all host/resource secrets (flags: --json, --env)")
|
||||||
fmt.Println(" theta-agent update Self-update binary from SSO Manager")
|
fmt.Println(" theta-agent update Self-update binary from Theta Directory")
|
||||||
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register")
|
fmt.Println(" theta-agent 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(" theta-agent version Show version info")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Reinitialize Flags:")
|
fmt.Println("Reinitialize Flags:")
|
||||||
@@ -58,18 +67,25 @@ func printUsage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSelfUpdate(args []string) {
|
func runSelfUpdate(args []string) {
|
||||||
configPath := "/etc/theta42/agent.yml"
|
configPath := defaultConfigPath()
|
||||||
cm, err := NewConfigManager(configPath)
|
cm, err := NewConfigManager(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
|
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
|
||||||
}
|
}
|
||||||
cfg := cm.Get()
|
_ = cm.Get()
|
||||||
serverURL := strings.TrimRight(cfg.ServerURL, "/")
|
|
||||||
if serverURL == "" {
|
|
||||||
log.Fatalf("[!] Update failed: server_url is empty in %s", configPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadURL := fmt.Sprintf("%s/resources/theta-agent/theta-agent-linux-amd64", serverURL)
|
// Binaries are GitHub release artifacts (DESIGN-WINDOWS.md §9); nothing
|
||||||
|
// binary is served from the SSO's /resources anymore.
|
||||||
|
arch := "amd64"
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
arch = "arm64"
|
||||||
|
}
|
||||||
|
ext := ""
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
ext = ".exe"
|
||||||
|
}
|
||||||
|
artifact := fmt.Sprintf("theta-agent-%s-%s%s", runtime.GOOS, arch, ext)
|
||||||
|
downloadURL := releaseAssetURL(artifact)
|
||||||
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
|
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
@@ -106,7 +122,7 @@ func runSelfUpdate(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runReinitialize(args []string) {
|
func runReinitialize(args []string) {
|
||||||
configPath := "/etc/theta42/agent.yml"
|
configPath := defaultConfigPath()
|
||||||
joinKey := ""
|
joinKey := ""
|
||||||
for i := 0; i < len(args); i++ {
|
for i := 0; i < len(args); i++ {
|
||||||
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
|
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
|
||||||
@@ -144,8 +160,21 @@ func runReinitialize(args []string) {
|
|||||||
os.Exit(0)
|
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) {
|
func restartAffectedServices(exec Executor) {
|
||||||
log.Printf("[+] Restarting theta-agent service...")
|
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")
|
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
|
||||||
|
|
||||||
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
|
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
|
||||||
@@ -235,7 +264,7 @@ func runGetSecrets(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchAgentSecrets() (map[string]string, error) {
|
func fetchAgentSecrets() (map[string]string, error) {
|
||||||
configPath := "/etc/theta42/agent.yml"
|
configPath := defaultConfigPath()
|
||||||
cm, err := NewConfigManager(configPath)
|
cm, err := NewConfigManager(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
|
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// Command icon-gen renders the theta-agent tray icon set (Red/Yellow/Green/
|
||||||
|
// Blue state badges) and writes:
|
||||||
|
//
|
||||||
|
// - <tray-dir>/icons.go — the Go source with the embedded 256x256 PNG byte
|
||||||
|
// arrays the tray binary compiles in (gofmt-formatted).
|
||||||
|
// - <tray-dir>/icon-*.png — a preview of each color, for eyeballing.
|
||||||
|
// - <ico-path> — the multi-size Windows .ico (16..256) of the Blue badge,
|
||||||
|
// used as the installer's own icon, the Start menu shortcut icon, and the
|
||||||
|
// uninstaller display icon.
|
||||||
|
//
|
||||||
|
// Design: a rounded-square badge in the state color with a subtle vertical
|
||||||
|
// gradient, and a white theta (ring + horizontal bar) glyph knocked out of it.
|
||||||
|
// Rendered at 256px with 4x4 supersampling so the edges are crisp at every
|
||||||
|
// downscaled size. Pure stdlib; run with:
|
||||||
|
//
|
||||||
|
// go run ./cmd/icon-gen cmd/theta-agent-tray installer/windows/theta-agent.ico
|
||||||
|
//
|
||||||
|
// The generated icons.go and theta-agent.ico must not be edited by hand.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"go/format"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const size = 256
|
||||||
|
|
||||||
|
type badgeColor struct {
|
||||||
|
name string
|
||||||
|
base color.RGBA
|
||||||
|
}
|
||||||
|
|
||||||
|
var palette = []badgeColor{
|
||||||
|
{"Red", color.RGBA{0xEF, 0x44, 0x44, 0xFF}},
|
||||||
|
{"Yellow", color.RGBA{0xEA, 0xB3, 0x08, 0xFF}},
|
||||||
|
{"Green", color.RGBA{0x22, 0xC5, 0x5E, 0xFF}},
|
||||||
|
{"Blue", color.RGBA{0x3B, 0x82, 0xF6, 0xFF}},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) != 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: go run ./cmd/icon-gen <tray-package-dir> <ico-output-path>")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
dir := os.Args[1]
|
||||||
|
icoPath := os.Args[2]
|
||||||
|
|
||||||
|
// The Blue badge doubles as the product/app icon (Start menu shortcut,
|
||||||
|
// installer, uninstaller).
|
||||||
|
var blue *image.RGBA
|
||||||
|
|
||||||
|
var src strings.Builder
|
||||||
|
src.WriteString("// Code generated by cmd/icon-gen; DO NOT EDIT.\n")
|
||||||
|
src.WriteString("\npackage main\n\n")
|
||||||
|
src.WriteString("// Theta Agent tray state badges, 256x256 PNG.\n")
|
||||||
|
src.WriteString("var (\n")
|
||||||
|
for _, c := range palette {
|
||||||
|
img := renderBadge(c.base)
|
||||||
|
preview := filepath.Join(dir, "icon-"+strings.ToLower(c.name)+".png")
|
||||||
|
writePNG(img, preview)
|
||||||
|
if c.name == "Blue" {
|
||||||
|
blue = img
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "encode:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
src.WriteString(fmt.Sprintf("\ticon%s = %s\n", c.name, goBytes(buf.Bytes())))
|
||||||
|
}
|
||||||
|
src.WriteString(")\n")
|
||||||
|
|
||||||
|
out := filepath.Join(dir, "icons.go")
|
||||||
|
formatted, err := format.Source([]byte(src.String()))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "format:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(out, formatted, 0644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "write:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("wrote", out)
|
||||||
|
|
||||||
|
if err := writeICO(blue, icoPath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "ico:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("wrote", icoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBadge draws the state badge at `size` with 4x4 supersampling.
|
||||||
|
func renderBadge(base color.RGBA) *image.RGBA {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||||
|
white := color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}
|
||||||
|
top := mix(base, white, 0.16)
|
||||||
|
bottom := mix(base, color.RGBA{0, 0, 0, 0xFF}, 0.20)
|
||||||
|
|
||||||
|
const margin = 8.0
|
||||||
|
const corner = 52.0
|
||||||
|
cx, cy := size/2.0, size/2.0
|
||||||
|
half := (float64(size) - 2*margin) / 2
|
||||||
|
|
||||||
|
// Theta glyph geometry (ring + horizontal bar, bar extends past the ring).
|
||||||
|
const ringOuter = 80.0
|
||||||
|
const ringInner = 56.0
|
||||||
|
const barHalf = 12.0
|
||||||
|
const barLen = 86.0
|
||||||
|
|
||||||
|
const ss = 4 // supersample factor
|
||||||
|
for py := 0; py < size; py++ {
|
||||||
|
for px := 0; px < size; px++ {
|
||||||
|
var accR, accG, accB, accA float64
|
||||||
|
for sy := 0; sy < ss; sy++ {
|
||||||
|
for sx := 0; sx < ss; sx++ {
|
||||||
|
x := float64(px) + (float64(sx)+0.5)/ss
|
||||||
|
y := float64(py) + (float64(sy)+0.5)/ss
|
||||||
|
if !inRoundedRect(x, y, cx, cy, half, corner) {
|
||||||
|
continue // transparent outside the badge
|
||||||
|
}
|
||||||
|
t := clamp01((y - (cy - half)) / (2 * half))
|
||||||
|
col := lerp(top, bottom, t)
|
||||||
|
if inTheta(x, y, cx, cy, ringOuter, ringInner, barHalf, barLen) {
|
||||||
|
col = white
|
||||||
|
}
|
||||||
|
accR += float64(col.R)
|
||||||
|
accG += float64(col.G)
|
||||||
|
accB += float64(col.B)
|
||||||
|
accA += 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := float64(ss * ss)
|
||||||
|
img.SetRGBA(px, py, color.RGBA{
|
||||||
|
R: uint8(accR / n),
|
||||||
|
G: uint8(accG / n),
|
||||||
|
B: uint8(accB / n),
|
||||||
|
A: uint8(accA / n),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func inRoundedRect(x, y, cx, cy, half, r float64) bool {
|
||||||
|
dx := math.Abs(x-cx) - (half - r)
|
||||||
|
dy := math.Abs(y-cy) - (half - r)
|
||||||
|
if dx <= 0 && dy <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return dx*dx+dy*dy <= r*r
|
||||||
|
}
|
||||||
|
|
||||||
|
func inTheta(x, y, cx, cy, ringOuter, ringInner, barHalf, barLen float64) bool {
|
||||||
|
d := math.Hypot(x-cx, y-cy)
|
||||||
|
inRing := d >= ringInner && d <= ringOuter
|
||||||
|
inBar := math.Abs(y-cy) <= barHalf && math.Abs(x-cx) <= barLen
|
||||||
|
return inRing || inBar
|
||||||
|
}
|
||||||
|
|
||||||
|
func mix(a, b color.RGBA, t float64) color.RGBA {
|
||||||
|
return color.RGBA{
|
||||||
|
R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t),
|
||||||
|
G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t),
|
||||||
|
B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t),
|
||||||
|
A: 0xFF,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lerp(a, b color.RGBA, t float64) color.RGBA {
|
||||||
|
return mix(a, b, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp01(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePNG(img *image.RGBA, path string) {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "create:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := png.Encode(f, img); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "encode:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// icoSizes are the entries embedded in the .ico. The 256px source is an
|
||||||
|
// integer multiple of each, so every entry is an exact box-filter downscale.
|
||||||
|
var icoSizes = []int{16, 24, 32, 48, 64, 128, 256}
|
||||||
|
|
||||||
|
// writeICO writes a multi-size Windows .ico (classic BMP XOR+AND entries, the
|
||||||
|
// format LoadImage has always supported) from the 256px source image.
|
||||||
|
func writeICO(src *image.RGBA, path string) error {
|
||||||
|
sizes := icoSizes
|
||||||
|
var dir bytes.Buffer
|
||||||
|
var payload bytes.Buffer
|
||||||
|
offset := 6 + len(sizes)*16
|
||||||
|
|
||||||
|
dir.Write([]byte{0, 0, 1, 0, byte(len(sizes)), 0}) // ICONDIR
|
||||||
|
|
||||||
|
for _, s := range sizes {
|
||||||
|
bmp := toDIB(scaleBox(src, s))
|
||||||
|
w, h := byte(s), byte(s)
|
||||||
|
if s >= 256 {
|
||||||
|
w, h = 0, 0
|
||||||
|
}
|
||||||
|
dir.Write([]byte{w, h, 0, 0, 1, 0, 32, 0})
|
||||||
|
dir.Write(u32le(len(bmp)))
|
||||||
|
dir.Write(u32le(offset + payload.Len()))
|
||||||
|
payload.Write(bmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.Write(dir.Bytes()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(payload.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleBox resizes src to w x w with an exact box (area-average) filter. Only
|
||||||
|
// correct when src's dimensions are an integer multiple of w — 256 is for
|
||||||
|
// every icoSizes entry — so no interpolation blur is introduced.
|
||||||
|
func scaleBox(src *image.RGBA, w int) *image.RGBA {
|
||||||
|
b := src.Bounds()
|
||||||
|
sw, sh := b.Dx(), b.Dy()
|
||||||
|
fx := sw / w
|
||||||
|
fy := sh / w
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, w, w))
|
||||||
|
for y := 0; y < w; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
var r, g, bl, a int64
|
||||||
|
for sy := 0; sy < fy; sy++ {
|
||||||
|
yy := b.Min.Y + y*fy + sy
|
||||||
|
for sx := 0; sx < fx; sx++ {
|
||||||
|
xx := b.Min.X + x*fx + sx
|
||||||
|
cr, cg, cb, ca := src.At(xx, yy).RGBA()
|
||||||
|
r += int64(cr >> 8)
|
||||||
|
g += int64(cg >> 8)
|
||||||
|
bl += int64(cb >> 8)
|
||||||
|
a += int64(ca >> 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := int64(fx * fy)
|
||||||
|
dst.SetRGBA(x, y, color.RGBA{
|
||||||
|
R: uint8(r / n),
|
||||||
|
G: uint8(g / n),
|
||||||
|
B: uint8(bl / n),
|
||||||
|
A: uint8(a / n),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDIB encodes an image as a 32-bit bottom-up DIB with an all-transparent
|
||||||
|
// AND mask — the classic icon bitmap entry.
|
||||||
|
func toDIB(img *image.RGBA) []byte {
|
||||||
|
b := img.Bounds()
|
||||||
|
w, h := b.Dx(), b.Dy()
|
||||||
|
andRow := ((w + 31) / 32) * 4
|
||||||
|
|
||||||
|
dib := make([]byte, 0, 40+w*h*4+andRow*h)
|
||||||
|
dib = append(dib, u32le(40)...) // biSize
|
||||||
|
dib = append(dib, u32le(w)...) // biWidth
|
||||||
|
dib = append(dib, u32le(h*2)...) // biHeight (XOR + AND)
|
||||||
|
dib = append(dib, u16le(1)...) // biPlanes
|
||||||
|
dib = append(dib, u16le(32)...) // biBitCount
|
||||||
|
dib = append(dib, 0, 0, 0, 0) // biCompression = 0 (BI_RGB)
|
||||||
|
dib = append(dib, u32le(w*h*4+andRow*h)...)
|
||||||
|
dib = append(dib, make([]byte, 16)...) // remaining header fields
|
||||||
|
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
srcY := b.Min.Y + (h - 1 - y) // DIB rows are bottom-up
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
r, g, bl, a := img.At(b.Min.X+x, srcY).RGBA()
|
||||||
|
dib = append(dib, byte(bl>>8), byte(g>>8), byte(r>>8), byte(a>>8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dib = append(dib, make([]byte, andRow*h)...) // AND mask: all zero
|
||||||
|
return dib
|
||||||
|
}
|
||||||
|
|
||||||
|
func u16le(v int) []byte {
|
||||||
|
return []byte{byte(v), byte(v >> 8)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func u32le(v int) []byte {
|
||||||
|
var b [4]byte
|
||||||
|
binary.LittleEndian.PutUint32(b[:], uint32(v))
|
||||||
|
return b[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func goBytes(b []byte) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("[]byte{")
|
||||||
|
for i, x := range b {
|
||||||
|
if i%12 == 0 {
|
||||||
|
sb.WriteString("\n\t\t")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("0x%02x, ", x))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n\t}")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -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 it is too large
Load Diff
@@ -0,0 +1,393 @@
|
|||||||
|
//go:build !server
|
||||||
|
// +build !server
|
||||||
|
|
||||||
|
// theta-agent-tray — desktop system tray companion for the theta-agent daemon.
|
||||||
|
//
|
||||||
|
// Auto-detects a graphical session (DISPLAY or WAYLAND_DISPLAY) and exits
|
||||||
|
// silently if neither is present (so it's safe to install on all hosts and
|
||||||
|
// only activates on desktops).
|
||||||
|
//
|
||||||
|
// Communicates with the running theta-agent daemon via the Unix socket at
|
||||||
|
// /run/theta/tray.sock (set up by the daemon). The daemon streams JSON status
|
||||||
|
// updates; the tray sends JSON commands back.
|
||||||
|
//
|
||||||
|
// Build:
|
||||||
|
// go build -o dist/theta-agent-tray ./cmd/theta-agent-tray
|
||||||
|
//
|
||||||
|
// Linux: requires libappindicator or the GTK3 systray dbus protocol.
|
||||||
|
// The fyne.io/systray library handles the platform details.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fyne.io/systray"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── IPC types (duplicated from the main agent package; tray is its own binary) ──
|
||||||
|
|
||||||
|
// traySocketPaths returns the daemon IPC socket paths for this platform, in
|
||||||
|
// the same order the daemon tries to bind them. Windows has no /run or /tmp and
|
||||||
|
// the daemon runs as a SYSTEM service, so both sides use the shared
|
||||||
|
// %ProgramData%\Theta42\tray.sock (installer creates the dir with a
|
||||||
|
// Users-writable ACL); Linux keeps the original pair.
|
||||||
|
func traySocketPaths() []string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
pd := os.Getenv("ProgramData")
|
||||||
|
if pd == "" {
|
||||||
|
pd = `C:\ProgramData`
|
||||||
|
}
|
||||||
|
return []string{filepath.Join(pd, "Theta42", "tray.sock")}
|
||||||
|
}
|
||||||
|
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrayColor string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ColorRed TrayColor = "red"
|
||||||
|
ColorYellow TrayColor = "yellow"
|
||||||
|
ColorGreen TrayColor = "green"
|
||||||
|
ColorBlue TrayColor = "blue"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrayStatus struct {
|
||||||
|
Color TrayColor `json:"color"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
IsHome bool `json:"is_home"`
|
||||||
|
VPNActive bool `json:"vpn_active"`
|
||||||
|
AutoVPN bool `json:"auto_vpn"`
|
||||||
|
SiteName string `json:"site_name"`
|
||||||
|
AgentPublicIP string `json:"agent_public_ip"`
|
||||||
|
HomePublicIP string `json:"home_public_ip"`
|
||||||
|
StatusText string `json:"status_text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrayCommand struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Value bool `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Silent exit if no graphical session is available. Only meaningful on
|
||||||
|
// X11/Wayland: Windows never sets these variables and the tray is simply
|
||||||
|
// always a valid thing to run there.
|
||||||
|
if runtime.GOOS != "windows" && os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||||
|
log.Println("theta-agent-tray: no graphical session detected (DISPLAY/WAYLAND_DISPLAY not set), exiting")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
systray.Run(onReady, onExit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onExit() {
|
||||||
|
log.Println("theta-agent-tray: exiting")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Menu items ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
mStatus *systray.MenuItem
|
||||||
|
mAutoVPN *systray.MenuItem
|
||||||
|
mVPNToggle *systray.MenuItem
|
||||||
|
mSeparator *systray.MenuItem
|
||||||
|
mOpenConfig *systray.MenuItem
|
||||||
|
mReinit *systray.MenuItem
|
||||||
|
mQuit *systray.MenuItem
|
||||||
|
|
||||||
|
currentStatus TrayStatus
|
||||||
|
ipcConn net.Conn
|
||||||
|
)
|
||||||
|
|
||||||
|
func onReady() {
|
||||||
|
// Initial icon — red until we hear from the daemon.
|
||||||
|
systray.SetIcon(toWindowsIcon(iconRed))
|
||||||
|
systray.SetTitle("Theta Agent")
|
||||||
|
systray.SetTooltip("Theta Agent — connecting…")
|
||||||
|
|
||||||
|
// ── Menu ──
|
||||||
|
mStatus = systray.AddMenuItem("Connecting to directory…", "Current connection status")
|
||||||
|
mStatus.Disable()
|
||||||
|
systray.AddSeparator()
|
||||||
|
mAutoVPN = systray.AddMenuItemCheckbox("Auto-connect VPN when away", "Automatically connect to home via WireGuard when not on the home LAN", false)
|
||||||
|
mVPNToggle = systray.AddMenuItem("Connect VPN", "Manually connect or disconnect the WireGuard tunnel")
|
||||||
|
systray.AddSeparator()
|
||||||
|
mOpenConfig = systray.AddMenuItem("Open Config", "Open agent.yml in the default editor")
|
||||||
|
mReinit = systray.AddMenuItem("Clear enrollment…", "Blank auth_token/public_key so the agent re-enrolls on reconnect")
|
||||||
|
mQuit = systray.AddMenuItem("Quit Tray", "Exit the tray icon (daemon keeps running)")
|
||||||
|
|
||||||
|
// ── IPC loop — connect with retry ──
|
||||||
|
go connectWithRetry()
|
||||||
|
|
||||||
|
// ── Menu event loop ──
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mAutoVPN.ClickedCh:
|
||||||
|
newVal := !currentStatus.AutoVPN
|
||||||
|
currentStatus.AutoVPN = newVal
|
||||||
|
if newVal {
|
||||||
|
mAutoVPN.Check()
|
||||||
|
} else {
|
||||||
|
mAutoVPN.Uncheck()
|
||||||
|
}
|
||||||
|
sendCmd(TrayCommand{Command: "set_auto_vpn", Value: newVal})
|
||||||
|
|
||||||
|
case <-mVPNToggle.ClickedCh:
|
||||||
|
if currentStatus.VPNActive {
|
||||||
|
sendCmd(TrayCommand{Command: "vpn_disconnect"})
|
||||||
|
} else {
|
||||||
|
sendCmd(TrayCommand{Command: "vpn_connect"})
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-mOpenConfig.ClickedCh:
|
||||||
|
sendCmd(TrayCommand{Command: "open_config"})
|
||||||
|
|
||||||
|
case <-mReinit.ClickedCh:
|
||||||
|
sendCmd(TrayCommand{Command: "reinit"})
|
||||||
|
|
||||||
|
case <-mQuit.ClickedCh:
|
||||||
|
systray.Quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectWithRetry repeatedly tries to connect to the daemon socket, waiting 5s
|
||||||
|
// between attempts. On connect it streams status updates until the connection
|
||||||
|
// drops, then retries.
|
||||||
|
func connectWithRetry() {
|
||||||
|
socketPaths := traySocketPaths()
|
||||||
|
for {
|
||||||
|
var conn net.Conn
|
||||||
|
var err error
|
||||||
|
for _, p := range socketPaths {
|
||||||
|
conn, err = net.Dial("unix", p)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("theta-agent-tray: daemon socket not available (%v), retrying in 5s…", err)
|
||||||
|
updateUI(TrayStatus{Color: ColorRed, StatusText: "Daemon not running"})
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ipcConn = conn
|
||||||
|
log.Println("theta-agent-tray: connected to daemon IPC socket")
|
||||||
|
streamStatus(conn)
|
||||||
|
conn.Close()
|
||||||
|
ipcConn = nil
|
||||||
|
log.Println("theta-agent-tray: lost daemon connection, retrying in 5s…")
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamStatus(conn net.Conn) {
|
||||||
|
scanner := bufio.NewScanner(conn)
|
||||||
|
for scanner.Scan() {
|
||||||
|
var s TrayStatus
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &s); err == nil {
|
||||||
|
updateUI(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUI(s TrayStatus) {
|
||||||
|
currentStatus = s
|
||||||
|
|
||||||
|
// Icon color. fyne.io/systray needs .ico on Windows; the PNG icons are
|
||||||
|
// wrapped in an ICO container (Windows Vista+ supports PNG-in-ICO).
|
||||||
|
icon := iconRed
|
||||||
|
switch s.Color {
|
||||||
|
case ColorRed:
|
||||||
|
icon = iconRed
|
||||||
|
case ColorYellow:
|
||||||
|
icon = iconYellow
|
||||||
|
case ColorGreen:
|
||||||
|
icon = iconGreen
|
||||||
|
case ColorBlue:
|
||||||
|
icon = iconBlue
|
||||||
|
}
|
||||||
|
systray.SetIcon(toWindowsIcon(icon))
|
||||||
|
|
||||||
|
// Tooltip.
|
||||||
|
tooltip := s.StatusText
|
||||||
|
if s.AgentPublicIP != "" {
|
||||||
|
tooltip += fmt.Sprintf(" (IP: %s)", s.AgentPublicIP)
|
||||||
|
}
|
||||||
|
systray.SetTooltip("Theta Agent — " + tooltip)
|
||||||
|
|
||||||
|
// Status menu item.
|
||||||
|
mStatus.SetTitle(s.StatusText)
|
||||||
|
|
||||||
|
// Auto-VPN checkbox.
|
||||||
|
if s.AutoVPN {
|
||||||
|
mAutoVPN.Check()
|
||||||
|
} else {
|
||||||
|
mAutoVPN.Uncheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VPN toggle label.
|
||||||
|
if s.IsHome {
|
||||||
|
// On home LAN — hide VPN toggle (not needed).
|
||||||
|
mVPNToggle.Hide()
|
||||||
|
} else {
|
||||||
|
mVPNToggle.Show()
|
||||||
|
if s.VPNActive {
|
||||||
|
mVPNToggle.SetTitle("Disconnect VPN")
|
||||||
|
} else {
|
||||||
|
mVPNToggle.SetTitle("Connect VPN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCmd(cmd TrayCommand) {
|
||||||
|
if ipcConn == nil {
|
||||||
|
log.Println("theta-agent-tray: no daemon connection, cannot send command")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(cmd)
|
||||||
|
b = append(b, '\n')
|
||||||
|
_, err := ipcConn.Write(b)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("theta-agent-tray: send command error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toWindowsIcon converts PNG bytes into a Windows .ico for fyne.io/systray,
|
||||||
|
// which requires .ico content on Windows (LoadImage cannot read PNG-in-ICO).
|
||||||
|
// On non-Windows the PNG is returned untouched.
|
||||||
|
func toWindowsIcon(pngBytes []byte) []byte {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return pngBytes
|
||||||
|
}
|
||||||
|
return pngToIco(pngBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// iconSizes are the sizes embedded in the Windows ICO. The 256px source PNG
|
||||||
|
// is a multiple of every entry, so each is an exact box-filter downscale.
|
||||||
|
var iconSizes = []int{16, 24, 32, 48, 64, 128, 256}
|
||||||
|
|
||||||
|
// pngToIco decodes a PNG and re-encodes it as classic BMP (XOR + AND mask)
|
||||||
|
// entries at every icon size from 16 to 256 — the format LoadImage has always
|
||||||
|
// supported. The source PNG is rendered at 256px (a multiple of every target
|
||||||
|
// size), so scaleBox is an exact box filter: no nearest-neighbour jaggies.
|
||||||
|
func pngToIco(pngBytes []byte) []byte {
|
||||||
|
src, err := png.Decode(bytes.NewReader(pngBytes))
|
||||||
|
if err != nil {
|
||||||
|
return pngBytes // give systray the raw bytes; it will log and continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sizes := iconSizes
|
||||||
|
var dir []byte
|
||||||
|
var payload []byte
|
||||||
|
offset := 6 + len(sizes)*16 // ICONDIR + all ICONDIRENTRYs
|
||||||
|
|
||||||
|
// ICONDIR: reserved(2)=0 type(2)=1 count(2)
|
||||||
|
dir = append(dir, 0, 0, 1, 0, byte(len(sizes)), 0)
|
||||||
|
|
||||||
|
for _, s := range sizes {
|
||||||
|
bmp := rgbaToDIB(scaleBox(src, s))
|
||||||
|
dw, dh := byte(s), byte(s)
|
||||||
|
if s >= 256 {
|
||||||
|
dw, dh = 0, 0
|
||||||
|
}
|
||||||
|
entry := []byte{dw, dh, 0, 0, 1, 0, 32, 0}
|
||||||
|
entry = append(entry, putU32le(len(bmp))...)
|
||||||
|
entry = append(entry, putU32le(offset+len(payload))...)
|
||||||
|
dir = append(dir, entry...)
|
||||||
|
payload = append(payload, bmp...)
|
||||||
|
}
|
||||||
|
return append(dir, payload...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleBox resizes src to w x w with an exact box (area-average) filter. Only
|
||||||
|
// correct when src's dimensions are an integer multiple of w — which 256 is
|
||||||
|
// for every icon size above — so no interpolation blur is introduced.
|
||||||
|
func scaleBox(src image.Image, w int) *image.RGBA {
|
||||||
|
b := src.Bounds()
|
||||||
|
sw, sh := b.Dx(), b.Dy()
|
||||||
|
fx := sw / w
|
||||||
|
fy := sh / w
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, w, w))
|
||||||
|
for y := 0; y < w; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
var r, g, bl, a int64
|
||||||
|
for sy := 0; sy < fy; sy++ {
|
||||||
|
yy := b.Min.Y + y*fy + sy
|
||||||
|
for sx := 0; sx < fx; sx++ {
|
||||||
|
xx := b.Min.X + x*fx + sx
|
||||||
|
cr, cg, cb, ca := src.At(xx, yy).RGBA()
|
||||||
|
r += int64(cr >> 8)
|
||||||
|
g += int64(cg >> 8)
|
||||||
|
bl += int64(cb >> 8)
|
||||||
|
a += int64(ca >> 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := int64(fx * fy)
|
||||||
|
dst.SetRGBA(x, y, color.RGBA{
|
||||||
|
R: uint8(r / n),
|
||||||
|
G: uint8(g / n),
|
||||||
|
B: uint8(bl / n),
|
||||||
|
A: uint8(a / n),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// rgbaToDIB encodes an image as a 32-bit bottom-up DIB with an all-transparent
|
||||||
|
// AND mask — the classic icon bitmap LoadImage understands.
|
||||||
|
func rgbaToDIB(img image.Image) []byte {
|
||||||
|
b := img.Bounds()
|
||||||
|
w, h := b.Dx(), b.Dy()
|
||||||
|
|
||||||
|
hdr := make([]byte, 40)
|
||||||
|
copy(hdr, putU32le(40)) // biSize
|
||||||
|
copy(hdr[4:], putU32le(w)) // biWidth
|
||||||
|
copy(hdr[8:], putU32le(h*2)) // biHeight (XOR + AND)
|
||||||
|
copy(hdr[12:], putU16le(1)) // biPlanes
|
||||||
|
copy(hdr[14:], putU16le(32)) // biBitCount
|
||||||
|
andRow := ((w + 31) / 32) * 4 // AND mask row, padded to 32 bits
|
||||||
|
copy(hdr[20:], putU32le(w*h*4+andRow*h)) // biSizeImage
|
||||||
|
|
||||||
|
xor := make([]byte, w*h*4)
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
srcY := b.Min.Y + (h - 1 - y) // DIB rows are bottom-up
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
r, g, bl, a := img.At(b.Min.X+x, srcY).RGBA()
|
||||||
|
o := y*w*4 + x*4
|
||||||
|
xor[o+0] = byte(bl >> 8) // B
|
||||||
|
xor[o+1] = byte(g >> 8) // G
|
||||||
|
xor[o+2] = byte(r >> 8) // R
|
||||||
|
xor[o+3] = byte(a >> 8) // A
|
||||||
|
}
|
||||||
|
}
|
||||||
|
and := make([]byte, andRow*h) // all zeros: no transparency holes
|
||||||
|
return append(append(hdr, xor...), and...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func putU32le(v int) []byte {
|
||||||
|
return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func putU16le(v int) []byte {
|
||||||
|
return []byte{byte(v), byte(v >> 8)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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 != len(iconSizes) {
|
||||||
|
t.Fatalf("expected %d icon entries, got %d", len(iconSizes), 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
|
||||||
|
}
|
||||||
|
if w != iconSizes[i] || h != iconSizes[i] {
|
||||||
|
t.Errorf("entry %d: encoded size %dx%d, want %dx%d", i, w, h, iconSizes[i], iconSizes[i])
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ type Capabilities struct {
|
|||||||
LdapTunnel bool `yaml:"ldap_tunnel"`
|
LdapTunnel bool `yaml:"ldap_tunnel"`
|
||||||
Secrets bool `yaml:"secrets"`
|
Secrets bool `yaml:"secrets"`
|
||||||
IAM bool `yaml:"iam"`
|
IAM bool `yaml:"iam"`
|
||||||
|
WireGuard bool `yaml:"wireguard"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretTarget maps a local template to a rendered target file and an optional
|
// SecretTarget maps a local template to a rendered target file and an optional
|
||||||
@@ -29,6 +30,17 @@ type SecretTarget struct {
|
|||||||
Reload string `yaml:"reload"`
|
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 {
|
type Config struct {
|
||||||
ServerURL string `yaml:"server_url"`
|
ServerURL string `yaml:"server_url"`
|
||||||
AuthToken string `yaml:"auth_token"`
|
AuthToken string `yaml:"auth_token"`
|
||||||
@@ -36,12 +48,48 @@ type Config struct {
|
|||||||
// the server exchanges it for a per-agent AuthToken (written back to this
|
// 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
|
// file), so it is a bootstrap value, not a long-term credential. Used only
|
||||||
// when AuthToken is empty.
|
// when AuthToken is empty.
|
||||||
JoinKey string `yaml:"join_key"`
|
JoinKey string `yaml:"join_key"`
|
||||||
Location string `yaml:"location"`
|
Location string `yaml:"location"`
|
||||||
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
|
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
|
||||||
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
|
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
|
||||||
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
|
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
|
||||||
Capabilities Capabilities `yaml:"capabilities"`
|
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
|
// Credential returns the value to present when connecting: our own token once
|
||||||
@@ -131,12 +179,69 @@ func (cm *ConfigManager) PersistEnrollment(token, publicKey string) error {
|
|||||||
return nil
|
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
|
// 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
|
// the key when it is absent. Deliberately line-based rather than a YAML
|
||||||
// round-trip so comments and formatting survive.
|
// round-trip so comments and formatting survive.
|
||||||
func setYamlScalar(doc, key, value string) string {
|
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]*:.*$`)
|
re := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*:.*$`)
|
||||||
line := fmt.Sprintf("%s: %q", key, value)
|
|
||||||
if re.MatchString(doc) {
|
if re.MatchString(doc) {
|
||||||
return re.ReplaceAllString(doc, line)
|
return re.ReplaceAllString(doc, line)
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-4
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -155,10 +156,13 @@ capabilities:
|
|||||||
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential())
|
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential())
|
||||||
}
|
}
|
||||||
|
|
||||||
// file must stay 0600 -- it now holds a credential
|
// file must stay 0600 -- it now holds a credential. POSIX-only: Windows has
|
||||||
fi, _ := os.Stat(path)
|
// no mode bits (0666 is reported regardless) and relies on ACLs instead.
|
||||||
if fi.Mode().Perm() != 0600 {
|
if runtime.GOOS != "windows" {
|
||||||
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
|
fi, _ := os.Stat(path)
|
||||||
|
if fi.Mode().Perm() != 0600 {
|
||||||
|
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,3 +206,55 @@ func TestPersistEnrollmentRejectsEmptyToken(t *testing.T) {
|
|||||||
t.Error("expected an error when the server sends no token")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,18 +3,23 @@ module github.com/theta42/theta-agent
|
|||||||
go 1.22.2
|
go 1.22.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/systray v1.12.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/hashicorp/mdns v1.0.5
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
|
golang.org/x/sys v0.20.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||||
|
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
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.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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
@@ -27,14 +35,24 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
|
|||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
github.com/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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
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-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-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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.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 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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=
|
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
+183
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local-discovery hosts override (AGENT_LOCAL_DISCOVERY_SPEC.md):
|
||||||
|
// applyHostsOverride replaces the managed block in the platform hosts file
|
||||||
|
// 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 the hosts file with no discovery trace at all.
|
||||||
|
//
|
||||||
|
// Platform specifics live in hosts_override_windows.go / hosts_override_unix.go:
|
||||||
|
// the file path, line-ending convention, and any DNS-cache flush needed for a
|
||||||
|
// hosts edit to take effect promptly (ipconfig /flushdns on Windows).
|
||||||
|
//
|
||||||
|
// NOT write-tmp-then-rename: on a real host that's the safer, atomic way to
|
||||||
|
// update a file, but the hosts file is frequently a bind mount (every
|
||||||
|
// container runtime does this, Docker included) -- confirmed the hard way on
|
||||||
|
// Linux: 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. On Windows the same in-place write preserves the file's ACLs,
|
||||||
|
// which a rename onto the system hosts file would not.
|
||||||
|
|
||||||
|
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 the platform hosts file.
|
||||||
|
func applyHostsOverride(entries map[string]string) error {
|
||||||
|
hostsMu.Lock()
|
||||||
|
defer hostsMu.Unlock()
|
||||||
|
|
||||||
|
path := hostsFilePath()
|
||||||
|
eol := hostsEOL()
|
||||||
|
|
||||||
|
existing, err := readLines(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := make([]string, 0, len(existing))
|
||||||
|
inBlock := false
|
||||||
|
for _, line := range existing {
|
||||||
|
// Normalize CRLF away so marker comparison is platform-agnostic and
|
||||||
|
// a CRLF file written back out with hostsEOL() doesn't double up \r.
|
||||||
|
normalized := strings.TrimSuffix(line, "\r")
|
||||||
|
trimmed := strings.TrimSpace(normalized)
|
||||||
|
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, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
}
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
out.WriteString(strings.Join(kept, eol))
|
||||||
|
if len(entries) > 0 {
|
||||||
|
out.WriteString(eol + hostsBlockBegin + eol)
|
||||||
|
for host, ip := range entries {
|
||||||
|
out.WriteString(fmt.Sprintf("%s\t%s%s", ip, host, eol))
|
||||||
|
}
|
||||||
|
out.WriteString(hostsBlockEnd + eol)
|
||||||
|
} else {
|
||||||
|
out.WriteString(eol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(out.String()), 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushDNSOnHostsChange()
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// setTestHostsPath redirects the platform hosts path at this temp file and
|
||||||
|
// restores it on cleanup. Runs on every OS: Windows hosts tests use the
|
||||||
|
// real Windows write path (minus the ipconfig flush, which the injected
|
||||||
|
// path suppresses), so this is where the CRLF/Windows behavior is guarded.
|
||||||
|
restore := setTestHostsPath(path)
|
||||||
|
t.Cleanup(restore)
|
||||||
|
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 TestApplyHostsOverride_CRLFWindowsHostsFile(t *testing.T) {
|
||||||
|
// Windows hosts files use CRLF. The rewrite must (a) match the block
|
||||||
|
// markers on a CRLF file, (b) write back with the platform EOL, and (c)
|
||||||
|
// not double up \r\r\n from the read side.
|
||||||
|
path := withTempHostsFile(t, "127.0.0.1\tlocalhost\r\n192.168.1.5\tsomeotherhost\r\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{"sso.example.com": "10.0.0.9"}); err != nil {
|
||||||
|
t.Fatalf("reapply: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(path)
|
||||||
|
s := string(got)
|
||||||
|
if strings.Contains(s, "\r\r\n") {
|
||||||
|
t.Fatalf("doubled CR detected (CRLF handled wrong): %q", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "10.0.0.5") {
|
||||||
|
t.Errorf("stale override should be replaced on a CRLF file, got: %q", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "10.0.0.9\tsso.example.com") {
|
||||||
|
t.Errorf("override entry missing on CRLF file, got: %q", s)
|
||||||
|
}
|
||||||
|
if strings.Count(s, hostsBlockBegin) != 1 {
|
||||||
|
t.Errorf("expected exactly one managed block, got: %q", s)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"127.0.0.1\tlocalhost", "192.168.1.5\tsomeotherhost"} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Errorf("pre-existing content %q was clobbered, got: %q", want, 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// Unix hosts override (Linux today; macOS slots in here later with its own
|
||||||
|
// dscacheutil -flushcache flush -- see AGENT_LOCAL_DISCOVERY_SPEC.md).
|
||||||
|
|
||||||
|
// hostsFilePathUnix is a var, not a const, so tests can point it at a temp
|
||||||
|
// file instead of touching the real /etc/hosts.
|
||||||
|
var hostsFilePathUnix = "/etc/hosts"
|
||||||
|
|
||||||
|
func hostsFilePath() string { return hostsFilePathUnix }
|
||||||
|
|
||||||
|
func hostsEOL() string { return "\n" }
|
||||||
|
|
||||||
|
// flushDNSOnHostsChange is a no-op on Linux: resolvers read /etc/hosts per
|
||||||
|
// lookup, and nscd/systemd-resolved -- where present -- pick up hosts edits
|
||||||
|
// without an explicit flush. (macOS will need dscacheutil -flushcache here.)
|
||||||
|
func flushDNSOnHostsChange() {}
|
||||||
|
|
||||||
|
// setTestHostsPath points hostsFilePath() at a temp file for tests and
|
||||||
|
// returns a restore func. Exists in both platform files so the shared test
|
||||||
|
// code can compile everywhere.
|
||||||
|
func setTestHostsPath(path string) (restore func()) {
|
||||||
|
prev := hostsFilePathUnix
|
||||||
|
hostsFilePathUnix = path
|
||||||
|
return func() { hostsFilePathUnix = prev }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Windows hosts override (AGENT_LOCAL_DISCOVERY_SPEC.md):
|
||||||
|
// - The hosts file lives at %SystemRoot%\System32\drivers\etc\hosts. The
|
||||||
|
// theta-agent runs as a SYSTEM service (DESIGN-WINDOWS.md), so elevation
|
||||||
|
// is not a blocker here -- SYSTEM can write it directly.
|
||||||
|
// - Windows caches DNS in the DNS Client service. An edit to the hosts file
|
||||||
|
// does not immediately change resolution until the cache is flushed, so
|
||||||
|
// every successful change runs `ipconfig /flushdns`.
|
||||||
|
// - Windows hosts files conventionally use CRLF line endings; the shared
|
||||||
|
// rewrite normalizes on read and writes back with hostsEOL().
|
||||||
|
|
||||||
|
// hostsFilePathWindows, when set (tests only), redirects hostsFilePath() at a
|
||||||
|
// temp file so unit tests never touch the real system hosts file.
|
||||||
|
var hostsFilePathWindows string
|
||||||
|
|
||||||
|
// systemHostsPath resolves the real system hosts file.
|
||||||
|
func systemHostsPath() string {
|
||||||
|
root := os.Getenv("SystemRoot")
|
||||||
|
if root == "" {
|
||||||
|
root = `C:\Windows`
|
||||||
|
}
|
||||||
|
return root + `\System32\drivers\etc\hosts`
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostsFilePath() string {
|
||||||
|
if hostsFilePathWindows != "" {
|
||||||
|
return hostsFilePathWindows
|
||||||
|
}
|
||||||
|
return systemHostsPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostsEOL() string { return "\r\n" }
|
||||||
|
|
||||||
|
// flushDNSOnHostsChange invalidates the Windows DNS cache after a hosts edit.
|
||||||
|
// No-op when a test redirected the path to a temp file -- a temp file has no
|
||||||
|
// cached entries and running ipconfig here would just slow the tests down.
|
||||||
|
func flushDNSOnHostsChange() {
|
||||||
|
if hostsFilePathWindows != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := (&SystemExecutor{}).Execute("ipconfig", "/flushdns")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[local-discovery] ipconfig /flushdns failed (hosts override may not take effect immediately): %v: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTestHostsPath points hostsFilePath() at a temp file for tests and
|
||||||
|
// returns a restore func. Exists in both platform files so the shared test
|
||||||
|
// code can compile everywhere.
|
||||||
|
func setTestHostsPath(path string) (restore func()) {
|
||||||
|
prev := hostsFilePathWindows
|
||||||
|
hostsFilePathWindows = path
|
||||||
|
return func() { hostsFilePathWindows = prev }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// Windows IAM helpers (DESIGN-WINDOWS.md §4 "IAM on Windows"). The platform
|
||||||
|
// neutral parts live in iam.go; this file adds the Windows-specific pieces that
|
||||||
|
// applyIAM cannot express (it is the Linux engine).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// applyWindowsSSHKeys writes each user's authorized_keys into their profile so
|
||||||
|
// the built-in Windows OpenSSH server picks them up. OpenSSH on Windows reads
|
||||||
|
// %USERPROFILE%\.ssh\authorized_keys for standard accounts (administrators use
|
||||||
|
// %ProgramData%\ssh\administrators_authorized_keys; we mirror the key there too
|
||||||
|
// when the profile belongs to an admin is unknowable here, so we write both
|
||||||
|
// locations if present).
|
||||||
|
func applyWindowsSSHKeys(keys []SSHKey) error {
|
||||||
|
pd := os.Getenv("ProgramData")
|
||||||
|
if pd == "" {
|
||||||
|
pd = `C:\ProgramData`
|
||||||
|
}
|
||||||
|
|
||||||
|
// administrators_authorized_keys covers members of Administrators.
|
||||||
|
adminKeys := programDataAdminKeysPath(pd)
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.User == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content := strings.Join(k.Keys, "\n") + "\n"
|
||||||
|
|
||||||
|
profile := filepath.Join(`C:\Users`, k.User)
|
||||||
|
if st, err := os.Stat(profile); err == nil && st.IsDir() {
|
||||||
|
sshDir := filepath.Join(profile, ".ssh")
|
||||||
|
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("mkdir %s: %w", sshDir, err)
|
||||||
|
}
|
||||||
|
dest := filepath.Join(sshDir, "authorized_keys")
|
||||||
|
if err := os.WriteFile(dest, []byte(content), 0600); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also mirror into administrators_authorized_keys so admin-keyed logins
|
||||||
|
// work through OpenSSH's admin ACL check.
|
||||||
|
if err := os.MkdirAll(filepath.Dir(adminKeys), 0700); err == nil {
|
||||||
|
_ = os.WriteFile(adminKeys, []byte(content), 0600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func programDataAdminKeysPath(pd string) string {
|
||||||
|
return filepath.Join(pd, "ssh", "administrators_authorized_keys")
|
||||||
|
}
|
||||||
+73
-14
@@ -110,9 +110,40 @@ fi
|
|||||||
|
|
||||||
log "Starting Theta Agent installation..."
|
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
|
# 3. Install binary
|
||||||
log "Downloading binary from $BINARY_URL..."
|
log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..."
|
||||||
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary."
|
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL"
|
||||||
chmod +x "$BIN_PATH.tmp"
|
chmod +x "$BIN_PATH.tmp"
|
||||||
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
|
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
|
||||||
|
|
||||||
@@ -143,20 +174,48 @@ EOF
|
|||||||
else
|
else
|
||||||
log "Preserving existing configuration at $CONFIG_FILE"
|
log "Preserving existing configuration at $CONFIG_FILE"
|
||||||
fi
|
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)..."
|
||||||
# An agent with no public_key cannot verify signed commands and will refuse
|
if command -v groupadd >/dev/null 2>&1; then
|
||||||
# every one of them. That is the safe default, but it is silent at run time, so
|
getent group theta-secrets >/dev/null 2>&1 || groupadd -r theta-secrets 2>/dev/null || true
|
||||||
# say it plainly here where the operator is watching.
|
getent group theta >/dev/null 2>&1 || groupadd -r theta 2>/dev/null || true
|
||||||
if ! grep -qE '^public_key:[[:space:]]*"[^"]+"' "$CONFIG_FILE" 2>/dev/null; then
|
|
||||||
log "WARNING: no public_key configured — this agent will report telemetry but"
|
|
||||||
log " REFUSE reboot / configure_ldap / arbitrary_bash / update_binary."
|
|
||||||
log " Re-run with --public-key \"<base64 key>\" (shown at enrollment)."
|
|
||||||
fi
|
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
|
# 4c. Setup Desktop Tray Icon companion
|
||||||
if [ "$INSTALL_SSSD" -eq 1 ] || grep -qE -i 'configure_ldap:[[:space:]]*true' "$CONFIG_FILE" 2>/dev/null; then
|
TRAY_BINARY_NAME="theta-agent-tray-${OS_NAME}-${ARCH_NAME}"
|
||||||
install_sssd_deps
|
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
|
fi
|
||||||
|
|
||||||
# 5. Setup systemd service
|
# 5. Setup systemd service
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
; 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 "/DMyAppVersion=2.2.0" installer\windows\installer.iss
|
||||||
|
; theta-agent-2.2.0-windows-amd64-setup.exe /SILENT ^
|
||||||
|
; /SERVER_URL=https://sso.example.com /JOIN_KEY=tjk_...
|
||||||
|
;
|
||||||
|
; The version is passed in by scripts/setup-build-env.ps1 (derived from the
|
||||||
|
; git tag). The default below exists only so a bare `iscc installer.iss` call
|
||||||
|
; still compiles; it should never be the version in a real build.
|
||||||
|
;
|
||||||
|
; 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 "0.0.0-dev"
|
||||||
|
#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
|
||||||
|
SetupIconFile=theta-agent.ico
|
||||||
|
Compression=lzma2
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
UninstallDisplayName={#MyAppName}
|
||||||
|
UninstallDisplayIcon={app}\theta-agent.ico
|
||||||
|
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
|
||||||
|
|
||||||
|
; Product icon (Start menu shortcut, uninstaller display icon). Generated by
|
||||||
|
; cmd/icon-gen from the same badge the tray uses.
|
||||||
|
Source: "theta-agent.ico"; DestDir: "{app}"; 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"; IconFilename: "{app}\theta-agent.ico"; 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}"; IconFilename: "{app}\theta-agent.ico"
|
||||||
|
|
||||||
|
[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;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"comment": "Pinned third-party assets bundled into the Windows installer (DESIGN-WINDOWS.md §8). The setup script (scripts/setup-build-env.ps1) fetches these into installer/windows/vendor/ and verifies sha256; nothing is committed to git.",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "wireguard-amd64-0.5.3.msi",
|
||||||
|
"url": "https://download.wireguard.com/windows-client/wireguard-amd64-0.5.3.msi",
|
||||||
|
"sha256": "76FCEC042C5989C5B816CD32EAED1E5B1C3B998A4B1C9ECA55F299E3314EF7E4",
|
||||||
|
"purpose": "Official WireGuard for Windows client (vendor-signed drivers install offline)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vc_redist.x64.exe",
|
||||||
|
"url": "https://aka.ms/vs/17/release/vc_redist.x64.exe",
|
||||||
|
"sha256": "CC0FF0EB1DC3F5188AE6300FAEF32BF5BEEBA4BDD6E8E445A9184072096B713B",
|
||||||
|
"purpose": "VC++ v14 runtime required by the OpenCredential credential provider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OpenCredentialInstaller-1.0.0.0.exe",
|
||||||
|
"url": "https://github.com/pedropablobm/OpenCredential/releases/download/v1.0.0.0/OpenCredentialInstaller-1.0.0.0.exe",
|
||||||
|
"sha256": "7687A99F0B3D6E910BBFB883C31231EB8C8F6E4439134CC3522A86CC63DA1A52",
|
||||||
|
"purpose": "OpenCredential (BSD-3 pGina fork) credential provider installer — LDAP-backed Windows logon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"toolchain": {
|
||||||
|
"go": {
|
||||||
|
"version": "1.22.2",
|
||||||
|
"url": "https://go.dev/dl/go1.22.2.windows-amd64.zip"
|
||||||
|
},
|
||||||
|
"inno": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"url": "https://github.com/jrsoftware/issrc/releases/download/is-7_0_2/innosetup-7.0.2-x64.exe",
|
||||||
|
"sha256": "5AD54CA3DEF786F8F4212552E54CC6D8D61329E2D24A1CFEE0571D42C2684FF1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-12
@@ -39,19 +39,22 @@ func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
|
|||||||
|
|
||||||
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
|
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
|
||||||
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
||||||
os.Remove(socketPath)
|
// 1. UNIX Domain Socket Listener. Windows passes an empty path and relies
|
||||||
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
// on the TCP loopback listener below.
|
||||||
os.MkdirAll(dir, 0755)
|
if socketPath != "" {
|
||||||
}
|
os.Remove(socketPath)
|
||||||
|
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. UNIX Domain Socket Listener
|
lnUnix, err := net.Listen("unix", socketPath)
|
||||||
lnUnix, err := net.Listen("unix", socketPath)
|
if err == nil {
|
||||||
if err == nil {
|
os.Chmod(socketPath, 0666)
|
||||||
os.Chmod(socketPath, 0666)
|
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
|
||||||
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
|
go t.acceptLoop(lnUnix, stopCh)
|
||||||
go t.acceptLoop(lnUnix, stopCh)
|
} else {
|
||||||
} else {
|
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
|
||||||
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)
|
// 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890)
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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
|
||||||
|
lastIP := ""
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Pin the packet path too: the hosts override only fixes name
|
||||||
|
// resolution, the route table decides where the packets go.
|
||||||
|
// If the WireGuard mesh tunnel is up with AllowedIPs covering
|
||||||
|
// this LAN subnet, it would swallow the direct connection.
|
||||||
|
if err := applyLocalRoute(ip); err != nil {
|
||||||
|
log.Printf("[local-discovery] found %s locally at %s but failed to pin a direct host route (a WireGuard tunnel may override it): %v", targetHost, ip, err)
|
||||||
|
}
|
||||||
|
log.Printf("[local-discovery] %s announced locally at %s -- routing directly, skipping the relay/WAN path", targetHost, ip)
|
||||||
|
lastIP = ip
|
||||||
|
currentlyOverridden = true
|
||||||
|
notifyDiscoveryChange()
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
if lastIP != "" {
|
||||||
|
removeLocalRoute(lastIP)
|
||||||
|
}
|
||||||
|
log.Printf("[local-discovery] %s no longer announced locally -- reverting to normal resolution", targetHost)
|
||||||
|
currentlyOverridden = false
|
||||||
|
lastIP = ""
|
||||||
|
notifyDiscoveryChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(mdnsPollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoveryChangedCh is signaled (non-blocking) whenever a local-discovery
|
||||||
|
// apply/revert changes name resolution or routing, so the WebSocket loop can
|
||||||
|
// reconnect promptly and pick up the new path instead of waiting out its
|
||||||
|
// reconnect backoff.
|
||||||
|
var discoveryChangedCh = make(chan struct{}, 1)
|
||||||
|
|
||||||
|
func notifyDiscoveryChange() {
|
||||||
|
select {
|
||||||
|
case discoveryChangedCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local-route pinning for local-discovery (AGENT_LOCAL_DISCOVERY_SPEC.md).
|
||||||
|
//
|
||||||
|
// The hosts override redirects NAME resolution of the server hostname to the
|
||||||
|
// discovered LAN IP, but the packet path is decided by the routing table, not
|
||||||
|
// by DNS. If the agent's WireGuard mesh tunnel is up with AllowedIPs covering
|
||||||
|
// the LAN subnet (or a full-tunnel 0.0.0.0/0), that tunnel route will swallow
|
||||||
|
// the direct connection to the discovered IP -- the discovery optimization
|
||||||
|
// silently stops working, and worse, the LAN IP may not even be reachable
|
||||||
|
// through the tunnel. So when an override is applied, also pin a /32 host
|
||||||
|
// route for the discovered IP on the owning local interface (it is on-link by
|
||||||
|
// definition -- mDNS never crosses routers), with priority over the tunnel's
|
||||||
|
// routes; and drop that route again when the override is reverted.
|
||||||
|
//
|
||||||
|
// HARD RULE unchanged: this only changes where packets go. Nothing here
|
||||||
|
// touches TLS/certificate validation; a spoofed announcement still produces a
|
||||||
|
// TLS handshake failure against the real hostname's cert, not a silent MITM.
|
||||||
|
|
||||||
|
// routeExec is injectable so tests can assert on the commands instead of
|
||||||
|
// mutating the real routing table.
|
||||||
|
var routeExec = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return (&SystemExecutor{}).Execute(name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localIface is a minimal view of a local network interface for route
|
||||||
|
// pinning: its index, name, and the subnets configured on it.
|
||||||
|
type localIface struct {
|
||||||
|
index int
|
||||||
|
name string
|
||||||
|
nets []*net.IPNet
|
||||||
|
}
|
||||||
|
|
||||||
|
// localInterfaces lists up, non-loopback interfaces and their subnets.
|
||||||
|
// Injectable so tests can fake the machine's network layout.
|
||||||
|
var localInterfaces = func() ([]localIface, error) {
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]localIface, 0, len(ifaces))
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
li := localIface{index: iface.Index, name: iface.Name}
|
||||||
|
for _, a := range addrs {
|
||||||
|
if ipn, ok := a.(*net.IPNet); ok {
|
||||||
|
li.nets = append(li.nets, ipn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, li)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interfaceForIP returns the local interface whose subnet contains ip, which
|
||||||
|
// is the one the discovered (on-link) IP must route through.
|
||||||
|
func interfaceForIP(ip string) (index int, name string, ok bool) {
|
||||||
|
target := net.ParseIP(ip)
|
||||||
|
if target == nil {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
ifaces, err := localInterfaces()
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
for _, n := range iface.nets {
|
||||||
|
if n.Contains(target) {
|
||||||
|
return iface.index, iface.name, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLocalRoute pins the discovered IP on the owning local interface so the
|
||||||
|
// packet path stays direct even with the WireGuard tunnel up.
|
||||||
|
func applyLocalRoute(ip string) error {
|
||||||
|
index, name, ok := interfaceForIP(ip)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no local interface contains %s (cannot pin a direct route)", ip)
|
||||||
|
}
|
||||||
|
return addHostRoute(ip, index, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeLocalRoute drops the host route added by applyLocalRoute. Best-effort
|
||||||
|
// by design: a leftover /32 is harmless and a failed delete should not fail
|
||||||
|
// the discovery revert itself.
|
||||||
|
func removeLocalRoute(ip string) {
|
||||||
|
if err := delHostRoute(ip); err != nil {
|
||||||
|
log.Printf("[local-discovery] failed to remove host route for %s: %v", ip, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errAlreadyExists = errors.New("already exists")
|
||||||
|
errRouteOp = errors.New("route op failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
func withFakeInterfaces(nets []*net.IPNet) func() {
|
||||||
|
orig := localInterfaces
|
||||||
|
localInterfaces = func() ([]localIface, error) {
|
||||||
|
return []localIface{{index: 7, name: "fake0", nets: nets}}, nil
|
||||||
|
}
|
||||||
|
return func() { localInterfaces = orig }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterfaceForIP_FindsOwningInterface(t *testing.T) {
|
||||||
|
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
idx, name, ok := interfaceForIP("192.168.1.50")
|
||||||
|
if !ok || idx != 7 || name != "fake0" {
|
||||||
|
t.Fatalf("interfaceForIP(192.168.1.50) = (%d, %q, %v), want (7, fake0, true)", idx, name, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterfaceForIP_NotOnLocalSegment(t *testing.T) {
|
||||||
|
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
if _, _, ok := interfaceForIP("10.99.99.99"); ok {
|
||||||
|
t.Fatal("interfaceForIP should not claim a non-local IP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterfaceForIP_InvalidIP(t *testing.T) {
|
||||||
|
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
if _, _, ok := interfaceForIP("not-an-ip"); ok {
|
||||||
|
t.Fatal("interfaceForIP should reject garbage input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterfaceForIP_TakesFirstMatchAcrossInterfaces(t *testing.T) {
|
||||||
|
orig := localInterfaces
|
||||||
|
defer func() { localInterfaces = orig }()
|
||||||
|
localInterfaces = func() ([]localIface, error) {
|
||||||
|
return []localIface{
|
||||||
|
{index: 1, name: "eth0", nets: []*net.IPNet{ipNet("10.0.0.0/24")}},
|
||||||
|
{index: 2, name: "wlan0", nets: []*net.IPNet{ipNet("192.168.50.0/24")}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
idx, name, ok := interfaceForIP("192.168.50.9")
|
||||||
|
if !ok || idx != 2 || name != "wlan0" {
|
||||||
|
t.Fatalf("expected wlan0 (idx 2) to own 192.168.50.9, got (%d, %q, %v)", idx, name, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddHostRoute_IgnoresAlreadyExists(t *testing.T) {
|
||||||
|
orig := routeExec
|
||||||
|
defer func() { routeExec = orig }()
|
||||||
|
routeExec = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte("The object already exists."), errAlreadyExists
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addHostRoute("192.168.1.50", 7, "fake0"); err != nil {
|
||||||
|
t.Fatalf("addHostRoute should treat an already-present route as success, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddHostRoute_ReturnsOtherErrors(t *testing.T) {
|
||||||
|
orig := routeExec
|
||||||
|
defer func() { routeExec = orig }()
|
||||||
|
routeExec = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte("The parameter is incorrect."), errRouteOp
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addHostRoute("192.168.1.50", 7, "fake0"); err == nil {
|
||||||
|
t.Fatal("addHostRoute should surface non-already-exists errors")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelHostRoute_IgnoresMissingRoute(t *testing.T) {
|
||||||
|
orig := routeExec
|
||||||
|
defer func() { routeExec = orig }()
|
||||||
|
routeExec = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte("route not found"), errRouteOp
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := delHostRoute("192.168.1.50"); err != nil {
|
||||||
|
t.Fatalf("delHostRoute should treat a missing route as success, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyLocalRoute_FailsWhenNoOwningInterface(t *testing.T) {
|
||||||
|
restore := withFakeInterfaces([]*net.IPNet{ipNet("192.168.1.0/24")})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
if err := applyLocalRoute("172.16.0.9"); err == nil || !strings.Contains(err.Error(), "no local interface") {
|
||||||
|
t.Fatalf("applyLocalRoute should fail with a clear error for a non-local IP, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipNet(cidr string) *net.IPNet {
|
||||||
|
_, n, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unix host-route pinning via `ip route`. The discovered IP is on-link, so a
|
||||||
|
// /32 route straight out the owning interface is enough; `ip route replace`
|
||||||
|
// is idempotent (re-adds instead of failing when the route is already there).
|
||||||
|
// The /32 wins by longest-prefix-match over any broader tunnel route, even a
|
||||||
|
// full-tunnel 0.0.0.0/0 -- no metric games needed on Linux.
|
||||||
|
|
||||||
|
func addHostRoute(ip string, _ int, ifaceName string) error {
|
||||||
|
out, err := routeExec("ip", "route", "replace", ip+"/32", "dev", ifaceName)
|
||||||
|
if err != nil {
|
||||||
|
// `ip route replace` is idempotent in real usage -- it re-adds
|
||||||
|
// rather than erroring when the route already exists -- but tolerate
|
||||||
|
// an "already exists" error anyway (defensive, and matches
|
||||||
|
// local_route_windows.go's addHostRoute, which route.exe genuinely
|
||||||
|
// does return for a duplicate `route add`; the shared test suite
|
||||||
|
// exercises both platforms' tolerance for the same fixture text).
|
||||||
|
if strings.Contains(strings.ToLower(string(out)), "already exists") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ip route replace %s via %s: %v: %s", ip, ifaceName, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func delHostRoute(ip string) error {
|
||||||
|
out, err := routeExec("ip", "route", "del", ip+"/32")
|
||||||
|
if err != nil {
|
||||||
|
// A missing route isn't an error -- nothing to drop. Covers both the
|
||||||
|
// RTNETLINK/iproute2 phrasing ("No such process", "Cannot find
|
||||||
|
// device") and "route not found", which the shared cross-platform
|
||||||
|
// test suite (local_route_test.go) also exercises against
|
||||||
|
// local_route_windows.go's delHostRoute.
|
||||||
|
lower := strings.ToLower(string(out))
|
||||||
|
if strings.Contains(lower, "no such process") || strings.Contains(lower, "cannot find") || strings.Contains(lower, "route not found") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ip route del %s: %v: %s", ip, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Windows host-route pinning: add a /32 route for the discovered IP via the
|
||||||
|
// owning interface with metric 1. WireGuard's tunnel service adds routes for
|
||||||
|
// its AllowedIPs with a low metric; a /32 host route at metric 1 wins for the
|
||||||
|
// exact discovered IP, keeping the discovery path direct even with the tunnel
|
||||||
|
// up. `route.exe` is used rather than the wireguard.exe client because the
|
||||||
|
// tunnel is owned by a service we shouldn't rip down just to adjust one route.
|
||||||
|
|
||||||
|
func addHostRoute(ip string, ifaceIndex int, _ string) error {
|
||||||
|
out, err := routeExec("route.exe",
|
||||||
|
"add", ip,
|
||||||
|
"mask", "255.255.255.255",
|
||||||
|
"0.0.0.0", // on-link gateway; the interface index pins the interface
|
||||||
|
"metric", "1",
|
||||||
|
"IF", strconv.Itoa(ifaceIndex),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// Already present (previous apply never reverted, or route.exe
|
||||||
|
// re-add) is the expected steady-state case -- treat as success.
|
||||||
|
if strings.Contains(strings.ToLower(string(out)), "already exists") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("route add %s: %v: %s", ip, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func delHostRoute(ip string) error {
|
||||||
|
out, err := routeExec("route.exe", "delete", ip, "mask", "255.255.255.255")
|
||||||
|
if err != nil {
|
||||||
|
lower := strings.ToLower(string(out))
|
||||||
|
if strings.Contains(lower, "route not found") || strings.Contains(lower, "cannot find") {
|
||||||
|
return nil // nothing to drop; not an error
|
||||||
|
}
|
||||||
|
return fmt.Errorf("route delete %s: %v: %s", ip, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,18 +6,53 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"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() {
|
func main() {
|
||||||
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
|
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
|
||||||
return
|
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...")
|
log.Println("Starting Theta Agent...")
|
||||||
|
|
||||||
// Attempt to load configuration
|
// Attempt to load configuration
|
||||||
configPath := "/etc/theta42/agent.yml"
|
configPath := defaultConfigPath()
|
||||||
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
||||||
configPath = os.Args[1]
|
configPath = os.Args[1]
|
||||||
}
|
}
|
||||||
@@ -28,7 +63,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
cfg := cm.Get()
|
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",
|
log.Printf("Loaded capabilities: Telemetry=%v, LDAP=%v, Reboot=%v, Bash=%v",
|
||||||
cfg.Capabilities.Telemetry,
|
cfg.Capabilities.Telemetry,
|
||||||
cfg.Capabilities.ConfigureLDAP,
|
cfg.Capabilities.ConfigureLDAP,
|
||||||
@@ -36,16 +71,38 @@ func main() {
|
|||||||
cfg.Capabilities.ArbitraryBash,
|
cfg.Capabilities.ArbitraryBash,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize system executor
|
// Initialize system executor and pin the platform ops the command
|
||||||
|
// dispatcher runs behind.
|
||||||
exec := &SystemExecutor{}
|
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)
|
go connectWebSocket(cm, exec)
|
||||||
|
|
||||||
// Block until signal is received
|
// Home detection + tray status push (polls public IP every 60s).
|
||||||
sigs := make(chan os.Signal, 1)
|
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() })
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-sigs
|
// 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...")
|
fmt.Println("Shutting down Theta Agent...")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<#
|
||||||
|
.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 }
|
||||||
|
|
||||||
|
# The installer must not carry a stale hardcoded version: derive it from
|
||||||
|
# the tag on CI (GITHUB_REF_NAME, e.g. "v2.2.0"), else the nearest local
|
||||||
|
# tag, else a plain dev marker. Passed as -DMyAppVersion so
|
||||||
|
# installer.iss's #ifndef default is always overridden by the build.
|
||||||
|
$appVer = '0.0.0-dev'
|
||||||
|
if ($env:GITHUB_REF_NAME -match 'v?([0-9]+\.[0-9]+\.[0-9]+)') {
|
||||||
|
$appVer = $matches[1]
|
||||||
|
} else {
|
||||||
|
$desc = git describe --tags --abbrev=0 2>$null
|
||||||
|
if ($desc -match 'v?([0-9]+\.[0-9]+\.[0-9]+)') { $appVer = $matches[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Compiling installer with ISCC (version $appVer)"
|
||||||
|
& $iscc "/DMyAppVersion=$appVer" (Join-Path $RepoRoot 'installer\windows\installer.iss')
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Fail 'ISCC compile failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Main -----
|
||||||
|
New-Item -ItemType Directory -Force -Path $ToolDir | Out-Null
|
||||||
|
|
||||||
|
if (-not $SkipGo) { Install-Go }
|
||||||
|
if (-not $SkipInno) { $null = Install-Inno }
|
||||||
|
if (-not $SkipVendor) { Ensure-Vendor }
|
||||||
|
|
||||||
|
Assert-BuildReady
|
||||||
|
if ($Build) { Invoke-Build }
|
||||||
|
|
||||||
|
if ($script:anyFailed) {
|
||||||
|
if ($CI) { throw 'Setup failed' }
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
+9
-4
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,10 +69,14 @@ func TestRenderSecrets(t *testing.T) {
|
|||||||
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
|
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The target should be 0600 (holds secrets).
|
// The target should be 0600 (holds secrets). Windows has no POSIX modes and
|
||||||
info, _ := os.Stat(target)
|
// reports 0666 regardless; the intent there is covered by the ACLs the
|
||||||
if info.Mode().Perm() != 0600 {
|
// installer sets on the target directory.
|
||||||
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
|
if runtime.GOOS != "windows" {
|
||||||
|
info, _ := os.Stat(target)
|
||||||
|
if info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// Windows service wrapper. The installer registers theta-agent as a SYSTEM
|
||||||
|
// auto-start service; when the SCM launches the process this file takes over.
|
||||||
|
// The service is up before any user session, which is what allows the
|
||||||
|
// credential provider to validate LDAP logins at Ctrl+Alt+Del and what makes
|
||||||
|
// the tray IPC socket path (shared data dir) correct.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maybeRunAsService returns true (and runs the service loop) when launched by
|
||||||
|
// the Service Control Manager; false when run in a console.
|
||||||
|
func maybeRunAsService() bool {
|
||||||
|
isSvc, err := svc.IsWindowsService()
|
||||||
|
if err != nil || !isSvc {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := svc.Run("theta-agent", &agentService{}); err != nil {
|
||||||
|
log.Printf("service: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentService struct{}
|
||||||
|
|
||||||
|
func (s *agentService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
|
||||||
|
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
|
||||||
|
|
||||||
|
changes <- svc.Status{State: svc.StartPending}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
runAgent()
|
||||||
|
}()
|
||||||
|
|
||||||
|
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case c := <-r:
|
||||||
|
switch c.Cmd {
|
||||||
|
case svc.Interrogate:
|
||||||
|
changes <- c.CurrentStatus
|
||||||
|
case svc.Stop, svc.Shutdown:
|
||||||
|
changes <- svc.Status{State: svc.StopPending}
|
||||||
|
stopAgent()
|
||||||
|
<-done
|
||||||
|
changes <- svc.Status{State: svc.Stopped}
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
changes <- svc.Status{State: svc.Stopped}
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+466
-54
@@ -7,6 +7,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +20,56 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v3/mem"
|
"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 {
|
type DiscoveryData struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
IPs []string `json:"ip_addresses"`
|
IPs []string `json:"ip_addresses"`
|
||||||
@@ -24,19 +77,31 @@ type DiscoveryData struct {
|
|||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Kernel string `json:"kernel"`
|
Kernel string `json:"kernel"`
|
||||||
CPUModel string `json:"cpu"`
|
CPUModel string `json:"cpu"`
|
||||||
|
CPUDetails CPUDetails `json:"cpu_details"`
|
||||||
RAMTotalGB float64 `json:"ram_total_gb"`
|
RAMTotalGB float64 `json:"ram_total_gb"`
|
||||||
|
RAMDetails RAMDetails `json:"ram_details"`
|
||||||
DiskTotalGB float64 `json:"disk_total_gb"`
|
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"`
|
Location string `json:"location"`
|
||||||
Capabilities map[string]interface{} `json:"capabilities"`
|
Capabilities map[string]interface{} `json:"capabilities"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelemetryData struct {
|
type TelemetryData struct {
|
||||||
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
||||||
RAMUsagePercent float64 `json:"ram_usage_percent"`
|
CPUDetails CPUDetails `json:"cpu_details"`
|
||||||
DiskUsagePercent float64 `json:"disk_usage_percent"`
|
RAMUsagePercent float64 `json:"ram_usage_percent"`
|
||||||
ZFSHealth string `json:"zfs_health,omitempty"`
|
RAMDetails RAMDetails `json:"ram_details"`
|
||||||
GPUUsage float64 `json:"gpu_usage_percent,omitempty"`
|
DiskUsagePercent float64 `json:"disk_usage_percent"`
|
||||||
Timestamp string `json:"timestamp"`
|
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 {
|
func getPublicIP() string {
|
||||||
@@ -62,6 +127,306 @@ func getPublicIP() string {
|
|||||||
return ""
|
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.2.0"
|
||||||
|
|
||||||
// CollectDiscoveryData gathers static host information.
|
// CollectDiscoveryData gathers static host information.
|
||||||
func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
||||||
h, _ := host.Info()
|
h, _ := host.Info()
|
||||||
@@ -76,36 +441,56 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vm, _ := mem.VirtualMemory()
|
vm := collectRAMDetails()
|
||||||
d, _ := disk.Usage("/")
|
disks := collectDiskItems()
|
||||||
|
cpuDet := collectCPUDetails()
|
||||||
|
loggedUsers := collectLoggedUsers()
|
||||||
|
|
||||||
cpuInfo, _ := cpu.Info()
|
pubIP := ""
|
||||||
cpuModel := "Unknown"
|
if cfg.DetectPublicIP() {
|
||||||
if len(cpuInfo) > 0 {
|
pubIP = getPublicIP()
|
||||||
cpuModel = cpuInfo[0].Model
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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{
|
return DiscoveryData{
|
||||||
Hostname: h.Hostname,
|
Hostname: h.Hostname,
|
||||||
IPs: ips,
|
IPs: ips,
|
||||||
PublicIP: pubIP,
|
PublicIP: pubIP,
|
||||||
OS: fmt.Sprintf("%s %s", h.OS, h.Platform),
|
OS: fmt.Sprintf("%s %s", h.OS, h.Platform),
|
||||||
Kernel: h.KernelVersion,
|
Kernel: h.KernelVersion,
|
||||||
CPUModel: cpuModel,
|
CPUModel: cpuDet.Model,
|
||||||
RAMTotalGB: float64(vm.Total) / (1024 * 1024 * 1024),
|
CPUDetails: cpuDet,
|
||||||
DiskTotalGB: float64(d.Total) / (1024 * 1024 * 1024),
|
RAMTotalGB: float64(vm.TotalBytes) / (1024 * 1024 * 1024),
|
||||||
Location: cfg.Location,
|
RAMDetails: vm,
|
||||||
|
DiskTotalGB: diskTotalGB,
|
||||||
|
Disks: disks,
|
||||||
|
LoggedUsers: loggedUsers,
|
||||||
|
HostDetails: hostDet,
|
||||||
|
Version: AgentVersion,
|
||||||
|
Location: cfg.Location,
|
||||||
Capabilities: map[string]interface{}{
|
Capabilities: map[string]interface{}{
|
||||||
"telemetry": cfg.Capabilities.Telemetry,
|
"telemetry": cfg.Capabilities.Telemetry,
|
||||||
"configure_ldap": cfg.Capabilities.ConfigureLDAP,
|
"configure_ldap": cfg.Capabilities.ConfigureLDAP,
|
||||||
"ldap_tunnel": cfg.Capabilities.LdapTunnel,
|
"ldap_tunnel": cfg.Capabilities.LdapTunnel,
|
||||||
"secrets": cfg.Capabilities.Secrets,
|
"secrets": cfg.Capabilities.Secrets,
|
||||||
"iam": cfg.Capabilities.IAM,
|
"iam": cfg.Capabilities.IAM,
|
||||||
"reboot": cfg.Capabilities.Reboot,
|
"reboot": cfg.Capabilities.Reboot,
|
||||||
"service_control": cfg.Capabilities.ServiceControl,
|
"shutdown": true,
|
||||||
"arbitrary_bash": cfg.Capabilities.ArbitraryBash,
|
"desktop_controls": true,
|
||||||
|
"service_control": cfg.Capabilities.ServiceControl,
|
||||||
|
"arbitrary_bash": cfg.Capabilities.ArbitraryBash,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,21 +498,41 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
|||||||
// CollectTelemetryData gathers real-time performance metrics including ZFS and GPU.
|
// CollectTelemetryData gathers real-time performance metrics including ZFS and GPU.
|
||||||
func CollectTelemetryData(exec Executor) TelemetryData {
|
func CollectTelemetryData(exec Executor) TelemetryData {
|
||||||
cpuPerc, _ := cpu.Percent(time.Second, false)
|
cpuPerc, _ := cpu.Percent(time.Second, false)
|
||||||
vm, _ := mem.VirtualMemory()
|
vm := collectRAMDetails()
|
||||||
d, _ := disk.Usage("/")
|
disks := collectDiskItems()
|
||||||
|
cpuDet := collectCPUDetails()
|
||||||
|
loggedUsers := collectLoggedUsers()
|
||||||
|
hostDet := collectHostDetails()
|
||||||
|
|
||||||
cpuVal := 0.0
|
cpuVal := 0.0
|
||||||
if len(cpuPerc) > 0 {
|
if len(cpuPerc) > 0 {
|
||||||
cpuVal = 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{
|
return TelemetryData{
|
||||||
CPUUsagePercent: cpuVal,
|
CPUUsagePercent: cpuVal,
|
||||||
|
CPUDetails: cpuDet,
|
||||||
RAMUsagePercent: vm.UsedPercent,
|
RAMUsagePercent: vm.UsedPercent,
|
||||||
DiskUsagePercent: d.UsedPercent,
|
RAMDetails: vm,
|
||||||
ZFSHealth: collectZFSHealth(exec),
|
DiskUsagePercent: diskVal,
|
||||||
GPUUsage: collectGPUUsage(exec),
|
Disks: disks,
|
||||||
Timestamp: time.Now().Format(time.RFC3339),
|
LoggedUsers: loggedUsers,
|
||||||
|
HostDetails: hostDet,
|
||||||
|
Version: AgentVersion,
|
||||||
|
ZFSHealth: collectZFSHealth(exec),
|
||||||
|
GPUUsage: collectGPUUsage(exec),
|
||||||
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +562,9 @@ func collectGPUUsage(exec Executor) float64 {
|
|||||||
func StartTelemetryLoop(c MessageWriter, cm *ConfigManager, exec Executor, stopCh <-chan struct{}) {
|
func StartTelemetryLoop(c MessageWriter, cm *ConfigManager, exec Executor, stopCh <-chan struct{}) {
|
||||||
cfg := cm.Get()
|
cfg := cm.Get()
|
||||||
|
|
||||||
// 1. Immediate Discovery Push
|
// 1. Immediate Discovery Push & Initial Telemetry Frame
|
||||||
pushDiscovery(c, cfg)
|
pushDiscovery(c, cfg)
|
||||||
|
pushTelemetry(c, exec)
|
||||||
|
|
||||||
// If telemetry capability is disabled in agent.yml, return early after discovery
|
// If telemetry capability is disabled in agent.yml, return early after discovery
|
||||||
if !cfg.Capabilities.Telemetry {
|
if !cfg.Capabilities.Telemetry {
|
||||||
@@ -189,27 +595,33 @@ func StartTelemetryLoop(c MessageWriter, cm *ConfigManager, exec Executor, stopC
|
|||||||
lastIPs = currentIPs
|
lastIPs = currentIPs
|
||||||
}
|
}
|
||||||
|
|
||||||
telemetry := CollectTelemetryData(exec)
|
pushTelemetry(c, 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func collectIPs() []string {
|
||||||
var ips []string
|
var ips []string
|
||||||
addrs, _ := net.InterfaceAddrs()
|
addrs, _ := net.InterfaceAddrs()
|
||||||
@@ -251,6 +663,6 @@ func pushDiscovery(c MessageWriter, cfg *Config) {
|
|||||||
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
|
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||||
log.Printf("Failed to send discovery data: %v", err)
|
log.Printf("Failed to send discovery data: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Println("Discovery data pushed to SSO Manager.")
|
log.Println("Discovery data pushed to Theta Directory.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
+86
@@ -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
@@ -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
|
||||||
|
}
|
||||||
+162
-102
@@ -12,7 +12,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -147,7 +146,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
|||||||
// credential every 5s just floods the SSO and its audit log
|
// credential every 5s just floods the SSO and its audit log
|
||||||
// forever, so back off hard and say plainly what is wrong.
|
// forever, so back off hard and say plainly what is wrong.
|
||||||
if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
|
if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
|
||||||
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the SSO Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
|
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the Theta Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
|
||||||
time.Sleep(authRetryInterval)
|
time.Sleep(authRetryInterval)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -156,7 +155,8 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Successfully connected to SSO Manager.")
|
log.Println("Successfully connected to Theta Directory.")
|
||||||
|
wsConnected.Store(true)
|
||||||
|
|
||||||
stopCh := make(chan struct{})
|
stopCh := make(chan struct{})
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
|||||||
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
|
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
|
||||||
socketPath := cfg.LdapSocket
|
socketPath := cfg.LdapSocket
|
||||||
if socketPath == "" {
|
if socketPath == "" {
|
||||||
socketPath = "/run/theta/ldap.sock"
|
socketPath = defaultLdapSocketPath()
|
||||||
}
|
}
|
||||||
go tunnel.start(socketPath, stopCh)
|
go tunnel.start(socketPath, stopCh)
|
||||||
}
|
}
|
||||||
@@ -213,7 +213,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
|||||||
// than at Dial.
|
// than at Dial.
|
||||||
if websocket.IsCloseError(err, closeUnauthorized, closeRevoked, closeTokenRotated) {
|
if websocket.IsCloseError(err, closeUnauthorized, closeRevoked, closeTokenRotated) {
|
||||||
authRejected = true
|
authRejected = true
|
||||||
log.Printf("Server closed the connection: %v. This agent's token is not valid for that SSO — re-enroll it and update agent.yml.", err)
|
log.Printf("Server closed the connection: %v. This agent's token is not valid for that Theta Directory — re-enroll it and update agent.yml.", err)
|
||||||
} else {
|
} else {
|
||||||
log.Println("WebSocket read error:", err)
|
log.Println("WebSocket read error:", err)
|
||||||
}
|
}
|
||||||
@@ -230,6 +230,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup on disconnect
|
// Cleanup on disconnect
|
||||||
|
wsConnected.Store(false)
|
||||||
close(stopCh)
|
close(stopCh)
|
||||||
c.Close()
|
c.Close()
|
||||||
|
|
||||||
@@ -240,7 +241,15 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Println("WebSocket disconnected. Reconnecting in 5 seconds...")
|
log.Println("WebSocket disconnected. Reconnecting in 5 seconds...")
|
||||||
time.Sleep(5 * time.Second)
|
// A local-discovery apply/revert (hosts override + route change) wants
|
||||||
|
// the new resolution path picked up right away rather than after the
|
||||||
|
// full backoff. discoveryChangedCh is drained here only; a change
|
||||||
|
// while still connected takes effect on the next natural reconnect.
|
||||||
|
select {
|
||||||
|
case <-discoveryChangedCh:
|
||||||
|
log.Println("Local-discovery routing changed; reconnecting immediately.")
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +304,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
|
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 {
|
if err != nil {
|
||||||
log.Printf("Log fetch failed: %v", err)
|
log.Printf("Log fetch failed: %v", err)
|
||||||
sendResponse("error", "failed to fetch logs")
|
sendResponse("error", "failed to fetch logs")
|
||||||
@@ -327,20 +336,16 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Updating binary from %s...", urlStr)
|
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)
|
log.Printf("Update failed: %v", err)
|
||||||
sendResponse("error", fmt.Sprintf("update failed: %v", err))
|
sendResponse("error", fmt.Sprintf("update failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendResponse("ok", "update applied successfully; restarting agent...")
|
sendResponse("ok", "update applied successfully; restarting agent...")
|
||||||
os.Exit(0)
|
defaultPlatformOps.SelfRestart()
|
||||||
case "config":
|
case "config":
|
||||||
// A config frame carrying credentials means the server accepted our
|
// A config frame carrying credentials means the server accepted our
|
||||||
// join key and enrolled this host. Persist what it issued -- our own
|
// join key and enrolled this host.
|
||||||
// per-agent token and the public key to pin -- so the next connection
|
|
||||||
// authenticates as this agent rather than re-enrolling, and so signed
|
|
||||||
// commands can be verified. This is what lets an install ship with only
|
|
||||||
// a join key and still end up fully configured.
|
|
||||||
if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled {
|
if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled {
|
||||||
token, _ := msg.Payload["auth_token"].(string)
|
token, _ := msg.Payload["auth_token"].(string)
|
||||||
pubKey, _ := msg.Payload["public_key"].(string)
|
pubKey, _ := msg.Payload["public_key"].(string)
|
||||||
@@ -348,11 +353,17 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
log.Printf("Enrolled, but could not persist credentials: %v", err)
|
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)
|
log.Printf("This agent will re-enroll on every reconnect until %s is writable.", cm.configPath)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Enrolled with the SSO. Credentials written to %s; the join key is no longer needed.", cm.configPath)
|
log.Printf("Enrolled with Theta Directory. Credentials written to %s; the join key is no longer needed.", cm.configPath)
|
||||||
}
|
}
|
||||||
sendResponse("ok", "enrollment stored")
|
sendResponse("ok", "enrollment stored")
|
||||||
return
|
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)
|
log.Printf("Received config payload: %v", msg.Payload)
|
||||||
sendResponse("ok", "Configuration received")
|
sendResponse("ok", "Configuration received")
|
||||||
case "reboot":
|
case "reboot":
|
||||||
@@ -366,12 +377,88 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Executing reboot...")
|
log.Printf("Executing reboot...")
|
||||||
if _, err := exec.Execute("reboot"); err != nil {
|
if _, err := defaultPlatformOps.Reboot(); err != nil {
|
||||||
log.Printf("Reboot failed: %v", err)
|
log.Printf("Reboot failed: %v", err)
|
||||||
sendResponse("error", "reboot failed")
|
sendResponse("error", "reboot failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendResponse("ok", "system rebooting")
|
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":
|
case "service_restart":
|
||||||
serviceName, ok := msg.Payload["service"].(string)
|
serviceName, ok := msg.Payload["service"].(string)
|
||||||
if !ok || !cfg.Capabilities.CanManageService(serviceName) {
|
if !ok || !cfg.Capabilities.CanManageService(serviceName) {
|
||||||
@@ -380,7 +467,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Restarting service %s...", serviceName)
|
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)
|
log.Printf("Service restart failed: %v", err)
|
||||||
sendResponse("error", "restart failed")
|
sendResponse("error", "restart failed")
|
||||||
return
|
return
|
||||||
@@ -404,70 +491,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Pushing updated SSSD configuration...")
|
if err := defaultPlatformOps.ConfigureLDAP(configData); err != nil {
|
||||||
_ = os.MkdirAll("/etc/sssd", 0755)
|
log.Printf("LDAP configuration failed: %v", err)
|
||||||
if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
|
sendResponse("error", err.Error())
|
||||||
log.Printf("Failed to write SSSD config: %v", err)
|
|
||||||
sendResponse("error", "failed to write config")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure /etc/nsswitch.conf enables sss for passwd, group, shadow, sudoers
|
|
||||||
if nssBytes, err := os.ReadFile("/etc/nsswitch.conf"); err == nil {
|
|
||||||
nssContent := string(nssBytes)
|
|
||||||
updatedNss := false
|
|
||||||
lines := strings.Split(nssContent, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if (strings.HasPrefix(trimmed, "passwd:") || strings.HasPrefix(trimmed, "group:") || strings.HasPrefix(trimmed, "shadow:") || strings.HasPrefix(trimmed, "sudoers:")) && !strings.Contains(trimmed, "sss") {
|
|
||||||
lines[i] = line + " sss"
|
|
||||||
updatedNss = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if updatedNss {
|
|
||||||
_ = os.WriteFile("/etc/nsswitch.conf", []byte(strings.Join(lines, "\n")), 0644)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Restarting SSSD service...")
|
|
||||||
if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil {
|
|
||||||
log.Printf("SSSD restart failed (%v), attempting auto-install of missing packages...", err)
|
|
||||||
if _, err2 := exec.Execute("sh", "-c", "DEBIAN_FRONTEND=noninteractive apt-get update -y -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || dnf install -y sssd sssd-ldap sssd-tools || yum install -y sssd sssd-ldap sssd-tools"); err2 == nil {
|
|
||||||
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
|
||||||
if _, err3 := exec.Execute("systemctl", "restart", "sssd"); err3 == nil {
|
|
||||||
// Configure SSH AuthorizedKeysCommand
|
|
||||||
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
|
|
||||||
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
|
|
||||||
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
|
||||||
_ = os.WriteFile(sshConfPath, []byte(sshConfContent), 0644)
|
|
||||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
|
||||||
sendResponse("ok", "LDAP configuration updated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sendResponse("error", "failed to restart sssd")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure /etc/ssh/sshd_config.d/theta-sssd.conf is created for SSH AuthorizedKeysCommand
|
|
||||||
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
|
|
||||||
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
|
|
||||||
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
|
||||||
if err := os.WriteFile(sshConfPath, []byte(sshConfContent), 0644); err == nil {
|
|
||||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
|
||||||
}
|
|
||||||
if sshdBytes, err2 := os.ReadFile("/etc/ssh/sshd_config"); err2 == nil {
|
|
||||||
sshdStr := string(sshdBytes)
|
|
||||||
if !strings.Contains(sshdStr, "sss_ssh_authorizedkeys") {
|
|
||||||
sshdStr += "\nAuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
|
||||||
_ = os.WriteFile("/etc/ssh/sshd_config", []byte(sshdStr), 0644)
|
|
||||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure PAM mkhomedir is enabled
|
|
||||||
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
|
||||||
|
|
||||||
sendResponse("ok", "LDAP configuration updated")
|
sendResponse("ok", "LDAP configuration updated")
|
||||||
case "render_secrets":
|
case "render_secrets":
|
||||||
if !verifySignature(cfg, msg) {
|
if !verifySignature(cfg, msg) {
|
||||||
@@ -503,12 +532,53 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID)
|
log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID)
|
||||||
if err := applyIAM(payload, exec); err != nil {
|
if err := defaultPlatformOps.ApplyIAM(payload); err != nil {
|
||||||
log.Printf("IAM apply failed: %v", err)
|
log.Printf("IAM apply failed: %v", err)
|
||||||
sendResponse("error", fmt.Sprintf("iam apply failed: %v", err))
|
sendResponse("error", fmt.Sprintf("iam apply failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendResponse("ok", "iam applied")
|
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":
|
case "arbitrary_bash":
|
||||||
if !verifySignature(cfg, msg) {
|
if !verifySignature(cfg, msg) {
|
||||||
sendResponse("error", "signature verification failed")
|
sendResponse("error", "signature verification failed")
|
||||||
@@ -528,7 +598,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Executing remote script: %s", script)
|
log.Printf("Executing remote script: %s", script)
|
||||||
out, err := exec.Execute("bash", "-c", script)
|
out, err := defaultPlatformOps.RunScript(script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Script execution failed: %v", err)
|
log.Printf("Script execution failed: %v", err)
|
||||||
sendResponse("error", fmt.Sprintf("execution failed: %v", err))
|
sendResponse("error", fmt.Sprintf("execution failed: %v", err))
|
||||||
@@ -555,20 +625,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)
|
resp, err := http.Get(downloadURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("http fetch failed: %w", err)
|
return "", fmt.Errorf("http fetch failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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-*")
|
tmpFile, err := os.CreateTemp("", "theta-agent-update-*")
|
||||||
if err != nil {
|
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()
|
tmpPath := tmpFile.Name()
|
||||||
defer os.Remove(tmpPath)
|
defer os.Remove(tmpPath)
|
||||||
@@ -578,32 +652,18 @@ func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
|
|||||||
|
|
||||||
if _, err := io.Copy(writer, resp.Body); err != nil {
|
if _, err := io.Copy(writer, resp.Body); err != nil {
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
return fmt.Errorf("failed to save binary: %w", err)
|
return "", fmt.Errorf("failed to save binary: %w", err)
|
||||||
}
|
}
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
|
||||||
actualSHA256 := fmt.Sprintf("%x", hasher.Sum(nil))
|
actualSHA256 := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||||
if !strings.EqualFold(actualSHA256, strings.TrimSpace(expectedSHA256)) {
|
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 {
|
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()
|
return tmpPath, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -218,6 +219,47 @@ func TestHandleCommand(t *testing.T) {
|
|||||||
expectedStatus: "error",
|
expectedStatus: "error",
|
||||||
expectedCmd: nil,
|
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",
|
name: "heartbeat_ack is silently ignored",
|
||||||
cfg: &Config{
|
cfg: &Config{
|
||||||
@@ -245,6 +287,19 @@ func TestHandleCommand(t *testing.T) {
|
|||||||
mockConn := &MockConn{}
|
mockConn := &MockConn{}
|
||||||
mockExec := &MockExecutor{}
|
mockExec := &MockExecutor{}
|
||||||
cm := &ConfigManager{current: tc.cfg}
|
cm := &ConfigManager{current: tc.cfg}
|
||||||
|
|
||||||
|
// The dispatch tests assert the exact command lines the Linux
|
||||||
|
// executor produces; pin the platform ops so they behave the same
|
||||||
|
// on any CI host (Windows included). A temp dir holds the WireGuard
|
||||||
|
// config so wireguard_apply's persistence step is writable.
|
||||||
|
prevOps := defaultPlatformOps
|
||||||
|
defaultPlatformOps = &linuxPlatformOps{
|
||||||
|
exec: mockExec,
|
||||||
|
tunnelName: "theta-mesh",
|
||||||
|
confPath: filepath.Join(t.TempDir(), "theta-mesh.conf"),
|
||||||
|
}
|
||||||
|
defer func() { defaultPlatformOps = prevOps }()
|
||||||
|
|
||||||
msg := tc.msg
|
msg := tc.msg
|
||||||
if tc.signed {
|
if tc.signed {
|
||||||
msg.Payload = sign(t, msg.Payload)
|
msg.Payload = sign(t, msg.Payload)
|
||||||
|
|||||||
Reference in New Issue
Block a user