Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| aa1f85a9d5 | |||
| 8f0158eb9f | |||
| d128807431 | |||
| 7d36318885 |
@@ -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/
|
||||
+129
@@ -5,6 +5,135 @@ All notable changes to the `theta-agent` daemon will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [v2.1.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
|
||||
|
||||
### Added
|
||||
- **On-demand CLI Secret Fetching (`theta-agent get-secret <key>`).** Fetch raw secret values directly over TLS without writing plaintext files to disk. Supports `theta-agent get-secrets --env` (formatted for Systemd `EnvironmentFile`) and `theta-agent get-secrets --json`.
|
||||
- **CLI Self-Update and Re-enrollment Commands.** Added `theta-agent update` and `theta-agent reinitialize [--join-key <key>]` CLI options with automated service restarts (`sssd`, `sshd`).
|
||||
- **Zero-Trust LDAP WebSocket Tunnel (`ldap_tunnel`).** Auto-starts local `/run/theta/ldap.sock` and `127.0.0.1:3890` loopback listeners.
|
||||
- **Dynamic Site Matching.** Auto-detects WAN IP for public site matching and discovery.
|
||||
|
||||
### Fixed
|
||||
- **SSSD Socket Activation Exit Code 17.** Removed legacy `services` key in generated `sssd.conf` to satisfy modern systemd socket activation requirements.
|
||||
|
||||
## [Unreleased] - LDAP byte-pump tunnel (DESIGN.md §4)
|
||||
|
||||
The agent now serves a local LDAP socket for SSSD/PAM. It is a **pure byte
|
||||
pump**: it forwards raw LDAP bytes to the SSO over the WSS channel, and the SSO
|
||||
relays them into its real OpenLDAP and pipes the response back. The agent never
|
||||
parses LDAP.
|
||||
|
||||
### Added
|
||||
- **`ldap_tunnel` capability + `ldap_socket` config.** When enabled, the agent
|
||||
binds a unix socket (default `/run/theta/ldap.sock`, root:theta `0660`) and
|
||||
relays bytes bidirectionally as `ldap_tunnel` messages over the existing WSS
|
||||
channel. Point SSSD at it with `ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock`.
|
||||
- **`safeWriter`** — serializes WebSocket writes. Gorilla allows only one
|
||||
concurrent writer, but telemetry, heartbeat, the LDAP tunnel and command
|
||||
responses all write to the same socket; without this, concurrent writes
|
||||
corrupt the stream.
|
||||
- **Offline behavior:** when the WSS is down the agent cannot forward bytes, so
|
||||
it closes local socket connections; SSSD sees a connection failure and falls
|
||||
back to its local cache.
|
||||
|
||||
### Added — secrets engine (DESIGN.md §5)
|
||||
- **`secrets` capability + `secrets` config.** The agent renders local templates
|
||||
that embed OpenBao secrets (`{{ bao "secret/data/nodes/<id>/<name>#<key>" }}`).
|
||||
It parses the placeholders, fetches the values from the SSO (which holds the
|
||||
OpenBao access; the agent never holds a Vault token), renders each target
|
||||
atomically at `0600`, and runs the configured reload. Triggered by a signed
|
||||
`render_secrets` command.
|
||||
|
||||
### Added — capability reporting
|
||||
- **The agent reports its enabled capabilities in its `discovery` frame.** The
|
||||
SSO stores them and the Directory UI shows them as badges on the host's Metrics
|
||||
tab, so an operator can see at a glance what an agent is allowed to do
|
||||
(telemetry, LDAP tunnel, secrets, IAM, reboot, bash, service control).
|
||||
|
||||
### Added — IAM engine (DESIGN.md §6)
|
||||
- **`iam` capability.** The SSO pushes node-scoped identity config as a signed
|
||||
`iam_apply` command; the agent verifies the Ed25519 signature (fail-closed) and
|
||||
applies it locally:
|
||||
- **Sudo rules** — writes `/etc/sudoers.d/theta-iam-<node_id>`, validates with
|
||||
`visudo -c`, atomic swap.
|
||||
- **SSH keys** — stores per-user keys and installs the `AuthorizedKeysCommand`
|
||||
script (`/usr/local/bin/theta-authorized-keys`) that sshd calls per login.
|
||||
- **Access control** — writes `/etc/security/access.conf` with allowed login
|
||||
groups.
|
||||
- **Revocation** — flushes the SSSD cache (`sss_cache -E`) and drops active
|
||||
sessions (`pkill -u`) for revoked users.
|
||||
|
||||
## [v1.5.1] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
- **Rebuilt the prebuilt `theta-agent-linux-amd64`.** theta-suite's `setup.sh` installs that committed binary rather than building from source, so a stale one means the fix in this repo never reaches the host. The v1.5.0 binary predated join-key support: an install would have written a `join_key` into `agent.yml` that the running agent did not understand, and it would have looped on `close 4001: Unauthorized`. (Same trap as the v1.3.0 heartbeat fix.)
|
||||
|
||||
## [v1.5.0] - 2026-08-06
|
||||
|
||||
Join-key enrollment (protocol v1.2.0 §1.1). Installing the agent with one key is now all it takes to add a host.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,211 @@
|
||||
# theta-agent v2 — Simplified Architecture (LDAP-over-HTTPS)
|
||||
|
||||
**Status:** Draft for review · **Supersedes:** the WebRTC/SCTP spec (WebRTC dropped)
|
||||
|
||||
This document defines the v2 architecture for `theta-agent`. It replaces the
|
||||
earlier WebRTC/SCTP design with a strictly simpler model: **one agent per node,
|
||||
one outbound WSS channel, and the agent provides local LDAP + secrets + IAM.**
|
||||
The core problem it solves is the original ask — *LDAP binds are painful across
|
||||
hostnames, networks, and TLS cert chains* — by making the client stop speaking
|
||||
LDAP and instead do an HTTPS call to the SSO, where the directory is reachable.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
```
|
||||
APP (no agent) SSO
|
||||
HTTPS POST /ldap/bind ──────────────► [LDAP-over-HTTPS API] ──► OpenLDAP
|
||||
HTTPS POST /ldap/search ───────────► ▲
|
||||
│ raw bytes
|
||||
NODE (with theta-agent) │
|
||||
SSSD ──► /run/theta/ldap.sock ──► agent ──► WSS (ldap_tunnel) ─┘
|
||||
secrets: /etc/theta/templates/*.tpl ──► agent ──► HTTPS ──► OpenBao
|
||||
IAM: sudoers.d / authorized_keys ──► agent ◄── WSS ◄── IAM engine
|
||||
```
|
||||
|
||||
Two ways to reach the directory, both simple:
|
||||
- **Apps (no agent):** call the HTTPS LDAP API directly — one bind/search
|
||||
contract, no LDAP protocol.
|
||||
- **Nodes (with agent):** the agent is a **pure byte pump** — it forwards raw
|
||||
LDAP bytes from a local socket to the SSO, which relays them into OpenLDAP.
|
||||
The agent never parses LDAP.
|
||||
|
||||
---
|
||||
|
||||
## 2. Transport
|
||||
|
||||
- **Single transport: persistent outbound WebSocket (WSS) over TCP 443.** No
|
||||
WebRTC, no SCTP, no UDP, no ICE, no fallback logic. The agent dials out; the
|
||||
SSO never needs an inbound port.
|
||||
- **Authentication:** the existing token / join-key enrollment model carries
|
||||
over unchanged (see `PROTOCOL.md` §1.1). The agent presents its per-agent
|
||||
token; the SSO rejects anything it did not issue.
|
||||
- **Message framing:** the existing `WSMessage` JSON envelope (`type` + `payload`)
|
||||
carries all traffic. Message types are extended for LDAP, secrets, and IAM
|
||||
(below). No binary framing, no stream multiplexing. The LDAP tunnel carries
|
||||
raw bytes as base64 in `ldap_tunnel` messages (see §4).
|
||||
|
||||
---
|
||||
|
||||
## 3. LDAP-over-HTTPS API (SSO side)
|
||||
|
||||
A small service (or routes in the existing proxy/sso) that performs the real
|
||||
LDAP operation against OpenLDAP, which is reachable from the SSO network. This
|
||||
is the pinepain/ldap-auth-proxy model: the client never speaks LDAP.
|
||||
|
||||
| Endpoint | Request | Response |
|
||||
| :--- | :--- | :--- |
|
||||
| `POST /api/v1/ldap/bind` | `{username, password}` | `200 {dn, attributes}` or `401` |
|
||||
| `POST /api/v1/ldap/search` | `{base_dn, scope, filter, attributes}` | `200 {entries: [...]}` |
|
||||
|
||||
- **bind** performs a real LDAP bind server-side and returns the bound DN and
|
||||
identity attributes.
|
||||
- **search** runs a real LDAP search server-side and returns entries.
|
||||
- **Authorization:** the API authorizes the *caller* (an app, or an agent acting
|
||||
for a node). OpenLDAP enforces the actual directory ACLs. This resolves the
|
||||
original spec's contradiction — we do not parse BER to enforce per-operation
|
||||
policy; the directory does.
|
||||
|
||||
### 3.1 Consumers
|
||||
|
||||
- **Apps/services:** call the API directly over HTTPS. No LDAP hostname, no
|
||||
LDAPS cert chain, no cross-network LDAP firewall rule.
|
||||
- **theta-agent:** does **not** use this API. It tunnels raw LDAP bytes to the
|
||||
SSO's OpenLDAP over the WSS channel instead (see §4) — a byte pump, not a
|
||||
translation. The HTTPS API is for apps that have no agent on their network.
|
||||
|
||||
---
|
||||
|
||||
## 4. Agent local LDAP socket — a pure byte pump (for SSSD/PAM)
|
||||
|
||||
The agent provides a local LDAP endpoint so SSSD/PAM on the node can
|
||||
authenticate without direct LDAP connectivity. **The agent does not speak LDAP
|
||||
at all.** It is a dumb byte pump: whatever bytes land on the local socket are
|
||||
forwarded to the SSO, which relays them into its real OpenLDAP and pipes the
|
||||
response back.
|
||||
|
||||
```
|
||||
SSSD ──► /run/theta/ldap.sock ──► agent ──► WSS (ldap_tunnel) ──► SSO ──► OpenLDAP
|
||||
◄───────────────────────────────────────────────────────────────────────◄
|
||||
```
|
||||
|
||||
- **Socket:** a **Unix domain socket** at `/run/theta/ldap.sock`, owned by root,
|
||||
mode `0660` (root + theta group). A unix socket is preferred over
|
||||
`127.0.0.1:389` because filesystem permissions restrict *which local processes*
|
||||
can connect — any process can reach a TCP port, only root/theta can reach the
|
||||
socket.
|
||||
- **Tunnel framing:** each local connection gets a `conn_id`. Bytes are carried
|
||||
over the existing WSS channel as `ldap_tunnel` messages:
|
||||
`{type:"ldap_tunnel", payload:{conn_id, data:<base64>, close:bool}}`. The
|
||||
agent reads the socket and sends chunks up; the SSO relays them into OpenLDAP
|
||||
and sends OpenLDAP's response chunks back down; the agent writes them to the
|
||||
socket. `close:true` ends a connection.
|
||||
- **SSSD config:** `ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock`, StartTLS off
|
||||
(the transport to the SSO is already TLS/WSS; the local hop is plaintext on a
|
||||
root-owned socket).
|
||||
|
||||
### 4.1 Offline boot handling (validated)
|
||||
|
||||
If the agent cannot reach the SSO (WSS down), it cannot forward bytes, so it
|
||||
closes local socket connections. SSSD sees a connection failure and falls back
|
||||
to its local cache. **This has been validated against real SSSD behavior:** any
|
||||
user/group seen in the last N days can log in offline — a laptop user can log in
|
||||
on the road, and an admin can still reach a broken service host to fix it.
|
||||
|
||||
### 4.2 Open question — `ldapi://` support
|
||||
|
||||
SSSD's `ldap_uri` accepts `ldapi://` unix-socket URIs on modern versions, but
|
||||
this must be confirmed on the target SSSD build. If unsupported, fall back to
|
||||
`127.0.0.1:389` with a local firewall rule restricting the port to loopback.
|
||||
|
||||
---
|
||||
|
||||
## 5. Secrets engine
|
||||
|
||||
The agent renders OpenBao secrets to local files, reusing the existing
|
||||
`@simpleworkjs/bao-conf` patterns rather than inventing a parallel mechanism.
|
||||
|
||||
- **Templates:** `/etc/theta/templates/*.tpl` declare the secrets a service
|
||||
needs, e.g. `DB_PASS="{{ bao "secret/data/nodes/node-42/db#password" }}"`.
|
||||
- **Flow:** the agent requests the secret paths over the WSS channel → the SSO
|
||||
fetches from OpenBao (node-scoped to `/secret/data/nodes/${NODE_ID}/*`) →
|
||||
the agent renders the file **atomically** (write temp + rename) with mode
|
||||
`0600` → runs the configured post-render action (`systemctl reload <svc>`).
|
||||
- **Rotation:** on a rotation/invalidation event pushed down the channel, the
|
||||
agent re-fetches, re-renders, and reloads.
|
||||
- **Note:** OpenBao KV v2 secrets have no leases; "renewal" is a re-read on
|
||||
invalidation, not a lease renewal. (Dynamic secrets, if used, are a separate
|
||||
path.)
|
||||
|
||||
---
|
||||
|
||||
## 6. IAM engine
|
||||
|
||||
The SSO pushes node-scoped identity config down the WSS channel; the agent
|
||||
applies it locally.
|
||||
|
||||
- **Sudo rules:** write `/etc/sudoers.d/theta-iam-<node_id>`, run `visudo -c`,
|
||||
and atomically swap on success.
|
||||
- **SSH keys:** sync user public keys via `AuthorizedKeysCommand` (the agent
|
||||
implements the command the SSH daemon calls per login) — *not* a non-standard
|
||||
`/etc/ssh/authorized_keys.d/` directory.
|
||||
- **Access control:** configure SSSD / `/etc/security/access.conf` for allowed
|
||||
login groups.
|
||||
- **Revocation:** on account disable / group drop, flush local SSSD caches and
|
||||
drop active sessions for the affected user (mechanism TBD — `loginctl` vs
|
||||
`pkill -u`; high-risk, needs a defined trigger/event model).
|
||||
|
||||
### 6.1 Security — signed IAM payloads
|
||||
|
||||
Sudo rules and SSH keys grant root-equivalent access, so **every IAM push is
|
||||
Ed25519-signed** using the existing signature model (`PROTOCOL.md` §5). The agent
|
||||
verifies the signature against its pinned `public_key` before applying anything.
|
||||
Unsigned or invalid IAM payloads are rejected fail-closed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security model
|
||||
|
||||
- **Fail-closed, capability-matrix philosophy carries over** from v1: the agent
|
||||
only applies what its local config permits; the SSO cannot override local
|
||||
settings.
|
||||
- **Local socket auth:** only root/theta can reach `/run/theta/ldap.sock`.
|
||||
- **Signed high-risk operations:** IAM pushes (and any new high-risk command)
|
||||
require the Ed25519 signature.
|
||||
- **Node-scoped secrets:** OpenBao access is restricted to the node's own path
|
||||
prefix.
|
||||
- **Blast radius:** the agent runs as root; the unix socket + signature model +
|
||||
node-scoped secrets contain the damage if the agent is compromised.
|
||||
|
||||
---
|
||||
|
||||
## 8. What this drops from the original spec
|
||||
|
||||
- WebRTC / SCTP / DTLS / UDP / ICE — **gone**, WSS only.
|
||||
- Three SCTP streams — **replaced** by one WSS channel with message types.
|
||||
- The "node-scoped authorization" contradiction — **resolved**: the API
|
||||
authorizes the caller, OpenLDAP enforces ACLs.
|
||||
- LDAP parsing in the agent — **gone**. The agent is a byte pump; it never
|
||||
parses LDAP. The SSO relays raw bytes into its real OpenLDAP.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions / verification items
|
||||
|
||||
1. **SSSD `ldapi://` unix-socket support** on the target build (§4.2).
|
||||
2. **Revocation mechanism** — implemented as `sss_cache -E` + `pkill -u <user>`
|
||||
(§6). The event model (what triggers a push) is still to be wired into the
|
||||
SSO UI/engine.
|
||||
3. **`AuthorizedKeysCommand`** — implemented: the agent installs
|
||||
`/usr/local/bin/theta-authorized-keys` which cats the user's key file
|
||||
(`/etc/theta/authorized_keys/<user>`). sshd must be configured with
|
||||
`AuthorizedKeysCommand /usr/local/bin/theta-authorized-keys %u` (§6).
|
||||
4. **Versioning/migration** — is v2 a replacement for v1, or a parallel mode?
|
||||
The existing `/api/agent/ws` vs the new `/api/v1/ldap/*` paths need a story.
|
||||
5. **SSO relay target** — the SSO relays tunnel bytes into its local OpenLDAP
|
||||
(slapd). The target address comes from `conf.ldap.url`; confirm it is a
|
||||
plaintext LDAP port reachable from the SSO process (§4).
|
||||
6. **Secrets node scope** — the agent's node scope is its agent id
|
||||
(`secret/data/nodes/<agent-id>/*`). Confirm this matches how node secrets are
|
||||
provisioned in OpenBao (§5).
|
||||
+20
@@ -70,6 +70,24 @@ All messages are exchanged as JSON objects following the `WSMessage` structure.
|
||||
}
|
||||
```
|
||||
|
||||
### 2.1 `ldap_tunnel` — the LDAP byte pump (DESIGN.md §4)
|
||||
|
||||
The agent serves a local LDAP socket for SSSD/PAM. It is a **pure byte pump**:
|
||||
the agent forwards raw LDAP bytes to the SSO, which relays them into its real
|
||||
OpenLDAP and pipes the response back. Neither side parses LDAP.
|
||||
|
||||
- **Type**: `ldap_tunnel` (bidirectional — sent by both agent and SSO)
|
||||
- **Payload**:
|
||||
- `conn_id`: (string) correlates one local LDAP connection.
|
||||
- `data`: (string, optional) base64-encoded raw LDAP bytes.
|
||||
- `close`: (bool, optional) ends the connection.
|
||||
|
||||
The agent reads its local socket and sends `data` chunks up; the SSO relays them
|
||||
into OpenLDAP and sends OpenLDAP's response chunks back down; the agent writes
|
||||
them to the socket. `close:true` ends a connection. When the WSS is down the
|
||||
agent cannot forward bytes, so it closes local socket connections and SSSD falls
|
||||
back to its local cache.
|
||||
|
||||
## 3. Client $\rightarrow$ Server Messages
|
||||
|
||||
### 3.1 Discovery (One-time & On-Change)
|
||||
@@ -153,6 +171,8 @@ These commands **require** an Ed25519 signature in the payload. The agent verifi
|
||||
| `configure_ldap` | `{ "config": "...", "signature": "..." }` | Writes `/etc/sssd/sssd.conf` and restarts `sssd`. |
|
||||
| `arbitrary_bash` | `{ "script": "...", "signature": "..." }` | Executes raw bash script. |
|
||||
| `update_binary` | `{ "url": "...", "sha256": "...", "signature": "..." }` | Downloads, verifies, and replaces the agent binary. |
|
||||
| `render_secrets` | `{ "signature": "..." }` | Renders the configured secret templates to their targets (DESIGN.md §5). |
|
||||
| `iam_apply` | `{ "node_id", "revision", "access_control", "signature" }` | Applies node IAM: sudo rules, SSH keys, access control, revocation (DESIGN.md §6). |
|
||||
|
||||
## 5. Cryptographic Verification Process
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
# Theta Agent
|
||||
|
||||
Theta Agent is a unified endpoint management daemon for the theta42 stack. It replaces legacy bash installation scripts and one-way metric scripts with a powerful, 2-way Command & Control (C2) Go daemon.
|
||||
Theta Agent is a lightweight, cross-platform host telemetry, secret delivery, and desktop control daemon for the [Theta Suite](https://github.com/theta42/theta-suite) ecosystem. Built in Go, it replaces legacy bash scripts and one-way metrics with a secure, 2-way WebSocket connection to **Theta Directory** (`theta-directory`).
|
||||
|
||||
The agent dials out to the central SSO Manager via a persistent WebSocket connection, enabling real-time telemetry, dynamic discovery, and secure remote operations.
|
||||
The agent dials out over a single persistent outbound WebSocket connection (`wss://sso.example.com/api/agent/ws`), enabling real-time telemetry, hardware discovery, desktop session controls, and secret delivery.
|
||||
|
||||
## What you get
|
||||
|
||||
Install the agent on a node and it becomes a managed member of the directory — over a **single outbound connection**, with no inbound ports, no LDAP hostname/firewall/TLS setup, and no manual secret copying.
|
||||
|
||||
- **Directory logins (LDAP byte pump).** SSSD/PAM on the node authenticates through the agent's local socket, which forwards raw LDAP bytes to the SSO's OpenLDAP. OS logins work across any network — laptops, CGNAT, cloud VMs — and fall back to the local SSSD cache when offline.
|
||||
- **Secrets delivered on-demand.** Services, scripts, and Docker containers fetch secrets dynamically via `theta-agent get-secret DB_PASSWORD` or `theta-agent get-secrets --env`. Zero plaintext secrets on disk! Multi-level secret inheritance (Global Site -> Host -> Service) is resolved automatically.
|
||||
- **IAM managed centrally.** Sudo rules, SSH keys, and login access are pushed from the SSO to the node. Add a user to a group and their access appears on the right hosts; revoke them and their sessions are dropped.
|
||||
- **Telemetry & remote operations.** Host discovery, live metrics, and signed remote commands (reboot, service control, config, self-update) — the original C2 capabilities.
|
||||
|
||||
Everything is gated by a strict, local-first capability matrix and high-risk operations are Ed25519-signed (see below).
|
||||
|
||||
## Core Functionality
|
||||
|
||||
@@ -56,6 +67,9 @@ either drops the agent's live connection immediately. See `PROTOCOL.md` §1.1.
|
||||
|------------|------------|-------------|---------|
|
||||
| `telemetry` | Safe | Read-only metrics. | Pushes system health to SSO Manager. |
|
||||
| `configure_ldap` | Moderate | Configures SSSD. | Updates `/etc/sssd/sssd.conf` and restarts `sssd`. |
|
||||
| `ldap_tunnel` | Moderate | Local LDAP byte-pump socket. | Forwards raw LDAP bytes to the SSO for SSSD/PAM (DESIGN.md §4). |
|
||||
| `secrets` | Moderate | Renders OpenBao secrets. | Renders `/etc/theta/templates/*.tpl` to targets, atomic + reload (DESIGN.md §5). |
|
||||
| `iam` | High | Applies node IAM. | Writes sudo rules, SSH keys, access control; revokes sessions (DESIGN.md §6). |
|
||||
| `reboot` | High | System reboot. | Triggers an immediate host reboot. |
|
||||
| `service_control` | High | Service management. | Restarts services listed in the allowed list. |
|
||||
| `arbitrary_bash` | CRITICAL | Raw bash execution. | Executes any script sent by the manager as root. |
|
||||
|
||||
+58
-7
@@ -1,5 +1,5 @@
|
||||
# theta-agent configuration file
|
||||
# Default location: /etc/theta42/agent.yml
|
||||
# Default location: /etc/theta42/agent.yml (Linux) or %ProgramData%\Theta42\agent.yml (Windows)
|
||||
|
||||
server_url: "https://sso.example.com"
|
||||
|
||||
@@ -26,22 +26,73 @@ public_key: ""
|
||||
|
||||
location: "default" # Location identifier (e.g., site, datacenter) for naming
|
||||
|
||||
# Local LDAP byte-pump socket (DESIGN.md ??4). The agent forwards raw LDAP bytes
|
||||
# from this socket to the SSO, which relays them into its OpenLDAP. The agent
|
||||
# never parses LDAP. Point SSSD at it with:
|
||||
# ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock
|
||||
# (Windows relies on the TCP loopback listener 127.0.0.1:389 instead.)
|
||||
ldap_socket: "/run/theta/ldap.sock"
|
||||
|
||||
# Auto-connect the WireGuard tunnel when this host is away from home and the
|
||||
# directory WebSocket is up. The tray checkbox persists here too.
|
||||
auto_vpn: false
|
||||
|
||||
# mDNS local-discovery (MULTI_SITE_SPEC.md Appendix B): when a
|
||||
# theta-gateway/theta-proxy on the local network segment announces itself as
|
||||
# fronting this agent's server_url host, skip the relay/WAN path and talk to
|
||||
# it directly. Off by default -- it changes host name resolution on this
|
||||
# machine. Linux only for now; Windows/macOS need their own platform-native
|
||||
# support before this does anything there.
|
||||
prefer_local_directory: false
|
||||
|
||||
# Windows-specific (DESIGN-WINDOWS.md ??11). Ignored on Linux.
|
||||
service_name: "theta-agent" # Windows service name
|
||||
desktop_helper: "" # theta-agent-helper.exe path (session-0 ops)
|
||||
public_ip_detect: true # false = air-gap: never call external IP services
|
||||
|
||||
# WireGuard mesh client (DESIGN-WINDOWS.md ??5). The signed wireguard_apply
|
||||
# command pushes the peer config down the WSS channel; these are local paths.
|
||||
wireguard:
|
||||
tunnel_name: "theta-mesh"
|
||||
conf: "" # "" = platform default (/etc/wireguard/... or %ProgramData%\Theta42\wg\...)
|
||||
executable: "" # wireguard.exe path (Windows; "" = PATH/default install)
|
||||
|
||||
capabilities:
|
||||
# ---------------------------------------------------------
|
||||
# Basic Capabilities (Safe, read-only or infrastructure management)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
# Push CPU, RAM, GPU, and ZFS metrics to the SSO Manager
|
||||
|
||||
# Push CPU, RAM, GPU, and ZFS metrics to Theta Directory
|
||||
telemetry: true
|
||||
|
||||
# Allow the SSO Manager to push down SSSD and SSH keys configuration
|
||||
|
||||
# Allow Theta Directory to push down SSSD and SSH keys configuration
|
||||
configure_ldap: true
|
||||
|
||||
# Serve the local LDAP byte-pump socket for SSSD/PAM (DESIGN.md ??4)
|
||||
ldap_tunnel: true
|
||||
|
||||
# Render OpenBao secrets to local files from /etc/theta/templates (DESIGN.md ??5)
|
||||
secrets: true
|
||||
|
||||
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md ??6)
|
||||
iam: true
|
||||
|
||||
# Accept signed wireguard_apply/wireguard_remove commands (DESIGN-WINDOWS.md ??5)
|
||||
wireguard: false
|
||||
|
||||
# Secret templates to render (DESIGN.md ??5). Each maps a local template to a
|
||||
# target file and an optional post-render reload. The template embeds secrets as
|
||||
# {{ bao "secret/data/nodes/<node-id>/<name>#<key>" }}.
|
||||
# secrets:
|
||||
# - template: /etc/theta/templates/db.env.tpl
|
||||
# target: /etc/theta/db.env
|
||||
# reload: systemctl reload app
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Advanced Capabilities (High risk, remote operations)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
# Allow remote system reboots via the SSO Manager
|
||||
# Allow remote system reboots via Theta Directory
|
||||
reboot: false
|
||||
|
||||
# Allow restarting, starting, or stopping specific systemd services.
|
||||
@@ -50,6 +101,6 @@ capabilities:
|
||||
# Setting to true or [] denies all.
|
||||
service_control: []
|
||||
|
||||
# CRITICAL: Allow the execution of raw bash scripts sent from the SSO Manager.
|
||||
# CRITICAL: Allow the execution of raw bash scripts sent from Theta Directory.
|
||||
# Useful for GitOps deployments, but allows remote code execution.
|
||||
arbitrary_bash: false
|
||||
|
||||
Executable
+60
@@ -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"
|
||||
@@ -0,0 +1,324 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func handleCLI(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
arg := strings.ToLower(args[0])
|
||||
switch arg {
|
||||
case "get-secret", "secret-get":
|
||||
runGetSecret(args[1:])
|
||||
return true
|
||||
case "get-secrets", "secret-list", "secrets":
|
||||
runGetSecrets(args[1:])
|
||||
return true
|
||||
case "--update", "update":
|
||||
runSelfUpdate(args[1:])
|
||||
return true
|
||||
case "--reinitialize", "reinitialize", "--reinit", "reinit":
|
||||
runReinitialize(args[1:])
|
||||
return true
|
||||
case "install-service":
|
||||
handleServiceCommand(args[1:])
|
||||
return true
|
||||
case "remove-service", "uninstall-service":
|
||||
handleServiceCommand(append([]string{"remove"}, args[1:]...))
|
||||
return true
|
||||
case "--version", "version", "-v":
|
||||
fmt.Println("Theta Agent " + AgentVersion)
|
||||
return true
|
||||
case "--help", "help", "-h":
|
||||
printUsage()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("Theta Agent - Unified Endpoint Management CLI")
|
||||
fmt.Println()
|
||||
fmt.Println("Usage:")
|
||||
fmt.Println(" theta-agent Run agent daemon in foreground")
|
||||
fmt.Println(" theta-agent get-secret <key> Fetch single secret value from OpenBao")
|
||||
fmt.Println(" theta-agent get-secrets [flags] Fetch all host/resource secrets (flags: --json, --env)")
|
||||
fmt.Println(" theta-agent update Self-update binary from Theta Directory")
|
||||
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register")
|
||||
fmt.Println(" theta-agent install-service (Windows) register the agent as a service")
|
||||
fmt.Println(" theta-agent remove-service (Windows) unregister the agent service")
|
||||
fmt.Println(" theta-agent version Show version info")
|
||||
fmt.Println()
|
||||
fmt.Println("Reinitialize Flags:")
|
||||
fmt.Println(" --join-key <key> Supply new join key for re-enrollment")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func runSelfUpdate(args []string) {
|
||||
configPath := defaultConfigPath()
|
||||
cm, err := NewConfigManager(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
|
||||
}
|
||||
_ = cm.Get()
|
||||
|
||||
// Binaries are GitHub release artifacts (DESIGN-WINDOWS.md §9); nothing
|
||||
// binary is served from the SSO's /resources anymore.
|
||||
arch := "amd64"
|
||||
if runtime.GOARCH == "arm64" {
|
||||
arch = "arm64"
|
||||
}
|
||||
ext := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
ext = ".exe"
|
||||
}
|
||||
artifact := fmt.Sprintf("theta-agent-%s-%s%s", runtime.GOOS, arch, ext)
|
||||
downloadURL := releaseAssetURL(artifact)
|
||||
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(downloadURL)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
log.Fatalf("[!] Failed to download update binary from %s (HTTP %d): %v", downloadURL, resp.StatusCode, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
binPath := "/usr/local/bin/theta-agent"
|
||||
if selfPath, err := os.Executable(); err == nil && selfPath != "" {
|
||||
binPath = selfPath
|
||||
}
|
||||
|
||||
tmpPath := binPath + ".tmp"
|
||||
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
log.Fatalf("[!] Cannot write binary to %s: %v", tmpPath, err)
|
||||
}
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
out.Close()
|
||||
log.Fatalf("[!] Error writing binary update: %v", err)
|
||||
}
|
||||
out.Close()
|
||||
|
||||
if err := os.Rename(tmpPath, binPath); err != nil {
|
||||
log.Fatalf("[!] Cannot replace binary at %s: %v", binPath, err)
|
||||
}
|
||||
|
||||
log.Printf("[+] Binary updated successfully at %s.", binPath)
|
||||
exec := &SystemExecutor{}
|
||||
restartAffectedServices(exec)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func runReinitialize(args []string) {
|
||||
configPath := defaultConfigPath()
|
||||
joinKey := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
|
||||
joinKey = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[!] Cannot read %s: %v", configPath, err)
|
||||
}
|
||||
|
||||
content := string(raw)
|
||||
// Clear auth_token
|
||||
reToken := regexp.MustCompile(`(?m)^auth_token:.*$`)
|
||||
content = reToken.ReplaceAllString(content, `auth_token: ""`)
|
||||
|
||||
if joinKey != "" {
|
||||
reKey := regexp.MustCompile(`(?m)^join_key:.*$`)
|
||||
if reKey.MatchString(content) {
|
||||
content = reKey.ReplaceAllString(content, fmt.Sprintf(`join_key: "%s"`, joinKey))
|
||||
} else {
|
||||
content += fmt.Sprintf("\njoin_key: \"%s\"\n", joinKey)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
|
||||
log.Fatalf("[!] Failed to update %s: %v", configPath, err)
|
||||
}
|
||||
|
||||
log.Printf("[+] Cleared token in %s and reset enrollment status.", configPath)
|
||||
exec := &SystemExecutor{}
|
||||
restartAffectedServices(exec)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// releaseAssetURL returns the GitHub release download URL for a theta-agent
|
||||
// artifact (e.g. "theta-agent-linux-amd64"). Binaries are built by CI and
|
||||
// attached to the release; nothing binary lives in the repos (DESIGN-WINDOWS.md §9).
|
||||
func releaseAssetURL(artifact string) string {
|
||||
return "https://github.com/theta42/theta-agent/releases/latest/download/" + artifact
|
||||
}
|
||||
|
||||
func restartAffectedServices(exec Executor) {
|
||||
log.Printf("[+] Restarting theta-agent service...")
|
||||
if runtime.GOOS == "windows" {
|
||||
// sc.exe has no one-shot restart.
|
||||
_, _ = exec.Execute("sc", "stop", "theta-agent")
|
||||
_, _ = exec.Execute("sc", "start", "theta-agent")
|
||||
return
|
||||
}
|
||||
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
|
||||
|
||||
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
|
||||
log.Printf("[+] Restarting sssd service...")
|
||||
_, _ = exec.Execute("systemctl", "restart", "sssd")
|
||||
}
|
||||
|
||||
if _, err := exec.Execute("systemctl", "is-active", "sshd"); err == nil {
|
||||
log.Printf("[+] Reloading sshd service...")
|
||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
||||
} else if _, err := exec.Execute("systemctl", "is-active", "ssh"); err == nil {
|
||||
log.Printf("[+] Reloading ssh service...")
|
||||
_, _ = exec.Execute("systemctl", "reload", "ssh")
|
||||
}
|
||||
}
|
||||
|
||||
func runGetSecret(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "[!] Error: secret key name required (e.g. theta-agent get-secret DB_PASSWORD)\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
key := args[0]
|
||||
|
||||
secrets, err := fetchAgentSecrets()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
val, exists := secrets[key]
|
||||
if !exists {
|
||||
fmt.Fprintf(os.Stderr, "[!] Error: secret '%s' not found for this host/resource\n", key)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Print raw secret value to stdout without trailing newline
|
||||
fmt.Print(val)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func runGetSecrets(args []string) {
|
||||
jsonMode := false
|
||||
envMode := false
|
||||
for _, arg := range args {
|
||||
if arg == "--json" {
|
||||
jsonMode = true
|
||||
} else if arg == "--env" {
|
||||
envMode = true
|
||||
}
|
||||
}
|
||||
|
||||
secrets, err := fetchAgentSecrets()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[!] Error fetching secrets: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonMode {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(secrets); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[!] JSON encode error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if envMode {
|
||||
for k, v := range secrets {
|
||||
escaped := strings.ReplaceAll(v, `"`, `\"`)
|
||||
fmt.Printf("%s=\"%s\"\n", k, escaped)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if len(secrets) == 0 {
|
||||
fmt.Println("No secrets configured for this host/resource.")
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Printf("%-30s %s\n", "SECRET KEY", "VALUE STATUS")
|
||||
fmt.Println(strings.Repeat("-", 60))
|
||||
for k, v := range secrets {
|
||||
status := fmt.Sprintf("Configured (%d chars)", len(v))
|
||||
fmt.Printf("%-30s %s\n", k, status)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func fetchAgentSecrets() (map[string]string, error) {
|
||||
configPath := defaultConfigPath()
|
||||
cm, err := NewConfigManager(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
|
||||
}
|
||||
cfg := cm.Get()
|
||||
serverURL := strings.TrimRight(cfg.ServerURL, "/")
|
||||
if serverURL == "" {
|
||||
return nil, fmt.Errorf("server_url is empty in %s", configPath)
|
||||
}
|
||||
token := cfg.AuthToken
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("agent is not enrolled (auth_token empty in %s)", configPath)
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{})
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/agent/secrets", serverURL)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var resData struct {
|
||||
Status string `json:"status"`
|
||||
Secrets map[string]map[string]interface{} `json:"secrets"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&resData); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JSON response: %w", err)
|
||||
}
|
||||
|
||||
mergedSecrets := make(map[string]string)
|
||||
for _, pathMap := range resData.Secrets {
|
||||
for k, v := range pathMap {
|
||||
if strV, ok := v.(string); ok {
|
||||
mergedSecrets[k] = strV
|
||||
} else if v != nil {
|
||||
mergedSecrets[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergedSecrets, nil
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleCLIHelpAndVersion(t *testing.T) {
|
||||
if !handleCLI([]string{"version"}) {
|
||||
t.Errorf("expected handleCLI('version') to return true")
|
||||
}
|
||||
if !handleCLI([]string{"--help"}) {
|
||||
t.Errorf("expected handleCLI('--help') to return true")
|
||||
}
|
||||
if handleCLI([]string{"unknown-command"}) {
|
||||
t.Errorf("expected handleCLI('unknown-command') to return false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//go:build windows
|
||||
|
||||
// theta-agent-helper — session-aware companion for the theta-agent Windows
|
||||
// service (DESIGN-WINDOWS.md §4 and §8).
|
||||
//
|
||||
// The agent service runs in session 0 with no interactive desktop. Operations
|
||||
// that need an interactive session (lock, display off, logout) or that must
|
||||
// outlive the service process (staged self-update: swap the locked exe and
|
||||
// restart the service) run here instead.
|
||||
//
|
||||
// theta-agent-helper lock
|
||||
// theta-agent-helper display_off
|
||||
// theta-agent-helper logout [user]
|
||||
// theta-agent-helper update <newExe> <currentExe> <serviceName>
|
||||
//
|
||||
// Sleep is handled in-process by the service (SetSuspendState works from
|
||||
// session 0); it is also exposed here for manual use.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
wmSyscommand = 0x0112
|
||||
scMonitorpower = 0xF170
|
||||
sleepHwnd = ^uintptr(0) // HWND_BROADCAST
|
||||
monitorPowerOff = 2
|
||||
|
||||
wtsCurrentServerHandle = 0
|
||||
wtsUserName = 5
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = syscall.NewLazyDLL("user32.dll")
|
||||
wtsapi32 = syscall.NewLazyDLL("wtsapi32.dll")
|
||||
powrprof = syscall.NewLazyDLL("powrprof.dll")
|
||||
|
||||
procLockWorkStation = user32.NewProc("LockWorkStation")
|
||||
procSendMessageW = user32.NewProc("SendMessageW")
|
||||
procWTSLogoffSession = wtsapi32.NewProc("WTSLogoffSession")
|
||||
procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW")
|
||||
procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW")
|
||||
procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory")
|
||||
procSetSuspendState = powrprof.NewProc("SetSuspendState")
|
||||
)
|
||||
|
||||
type wtsSessionInfo struct {
|
||||
SessionID uint32
|
||||
WinStation *uint16
|
||||
ConnectState uint32
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: theta-agent-helper <lock|display_off|logout|update|sleep> [args...]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "lock":
|
||||
lockSession()
|
||||
case "display_off":
|
||||
displayOff()
|
||||
case "logout":
|
||||
logoutSession(argOrEmpty(2))
|
||||
case "sleep":
|
||||
sleepHost()
|
||||
case "update":
|
||||
if len(os.Args) < 5 {
|
||||
fmt.Fprintln(os.Stderr, "usage: theta-agent-helper update <newExe> <currentExe> <serviceName>")
|
||||
os.Exit(1)
|
||||
}
|
||||
doUpdate(os.Args[2], os.Args[3], os.Args[4])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown action %q\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func argOrEmpty(i int) string {
|
||||
if len(os.Args) > i {
|
||||
return os.Args[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func lockSession() {
|
||||
r, _, err := procLockWorkStation.Call()
|
||||
if r == 0 {
|
||||
fmt.Fprintf(os.Stderr, "LockWorkStation failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func displayOff() {
|
||||
r, _, err := procSendMessageW.Call(sleepHwnd, wmSyscommand, scMonitorpower, monitorPowerOff)
|
||||
if r == 0 {
|
||||
fmt.Fprintf(os.Stderr, "SendMessage(SC_MONITORPOWER) failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func sleepHost() {
|
||||
r, _, err := procSetSuspendState.Call(0, 0, 0)
|
||||
if r == 0 {
|
||||
fmt.Fprintf(os.Stderr, "SetSuspendState failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// logoutSession logs off the active console session, or every session of the
|
||||
// given user (matched by WTS session enumeration).
|
||||
func logoutSession(user string) {
|
||||
var ids []uint32
|
||||
if user == "" {
|
||||
if id := activeConsoleSessionID(); id != 0 {
|
||||
ids = []uint32{id}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
ids, err = sessionsForUser(user)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "logout: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "logout: no active session to log off")
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, id := range ids {
|
||||
logoffSession(id)
|
||||
}
|
||||
}
|
||||
|
||||
func logoffSession(sessionID uint32) {
|
||||
r, _, err := procWTSLogoffSession.Call(wtsCurrentServerHandle, uintptr(sessionID), 0)
|
||||
if r == 0 {
|
||||
fmt.Fprintf(os.Stderr, "WTSLogoffSession(%d) failed: %v\n", sessionID, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("logged off session %d\n", sessionID)
|
||||
}
|
||||
|
||||
func activeConsoleSessionID() uint32 {
|
||||
id, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("WTSGetActiveConsoleSessionId").Call()
|
||||
return uint32(id)
|
||||
}
|
||||
|
||||
// sessionsForUser returns every session whose logged-in user matches.
|
||||
func sessionsForUser(user string) ([]uint32, error) {
|
||||
var pInfo *wtsSessionInfo
|
||||
var count uint32
|
||||
|
||||
r, _, err := procWTSEnumerateSessionsW.Call(wtsCurrentServerHandle, 0, 1, uintptr(unsafe.Pointer(&pInfo)), uintptr(unsafe.Pointer(&count)))
|
||||
if r == 0 {
|
||||
return nil, fmt.Errorf("WTSEnumerateSessionsW: %v", err)
|
||||
}
|
||||
defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pInfo)))
|
||||
|
||||
want := strings.ToLower(user)
|
||||
var ids []uint32
|
||||
for i := uint32(0); i < count; i++ {
|
||||
info := (*wtsSessionInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(pInfo)) + uintptr(i)*unsafe.Sizeof(*pInfo)))
|
||||
name := wtsSessionUsername(info.SessionID)
|
||||
if name != "" && strings.ToLower(name) == want {
|
||||
ids = append(ids, info.SessionID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no active session for user %q", user)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func wtsSessionUsername(sessionID uint32) string {
|
||||
var pBuf *uint16
|
||||
var bytes uint32
|
||||
r, _, _ := procWTSQuerySessionInformationW.Call(wtsCurrentServerHandle, uintptr(sessionID), wtsUserName, uintptr(unsafe.Pointer(&pBuf)), uintptr(unsafe.Pointer(&bytes)))
|
||||
if r == 0 || pBuf == nil {
|
||||
return ""
|
||||
}
|
||||
defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pBuf)))
|
||||
return syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(pBuf))[:bytes/2])
|
||||
}
|
||||
|
||||
// doUpdate swaps the staged new binary over the running one and restarts the
|
||||
// service. The service that spawned us stops itself (stopAgent) once we are
|
||||
// launched; we wait for it to actually stop (the exe is locked while running),
|
||||
// swap, and start it again.
|
||||
func doUpdate(newExe, currentExe, serviceName string) {
|
||||
if serviceName == "" {
|
||||
serviceName = "theta-agent"
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !serviceRunning(serviceName) {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
// The file can stay locked a moment after the SCM reports STOPPED, so retry.
|
||||
swapped := false
|
||||
for i := 0; i < 40; i++ {
|
||||
if err := os.Rename(newExe, currentExe); err == nil {
|
||||
swapped = true
|
||||
break
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
if !swapped {
|
||||
fmt.Fprintf(os.Stderr, "update: could not replace %s (is the service still running?)\n", currentExe)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cmd := exec.Command("sc", "start", serviceName)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "update: sc start %s: %v: %s\n", serviceName, err, out)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("update: swapped %s and restarted %s\n", currentExe, serviceName)
|
||||
}
|
||||
|
||||
func serviceRunning(name string) bool {
|
||||
out, err := exec.Command("sc", "query", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToUpper(string(out))
|
||||
return strings.Contains(text, "RUNNING") || strings.Contains(text, "START_PENDING")
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngToIco wraps a PNG in a Windows ICO with BMP (not PNG-compressed) entries,
|
||||
// because LoadImage cannot read PNG-in-ICO. Verify the container is well-formed
|
||||
// and every entry's DIB is a valid 32bpp bitmap with an AND mask.
|
||||
func TestPNGToIco(t *testing.T) {
|
||||
ico := pngToIco(iconGreen)
|
||||
if len(ico) < 6+16 {
|
||||
t.Fatal("ico too short")
|
||||
}
|
||||
|
||||
// ICONDIR
|
||||
if ico[0] != 0 || ico[1] != 0 {
|
||||
t.Errorf("reserved must be 0")
|
||||
}
|
||||
if ico[2] != 1 || ico[3] != 0 {
|
||||
t.Errorf("type must be 1 (icon)")
|
||||
}
|
||||
count := int(ico[4]) | int(ico[5])<<8
|
||||
if count != 3 {
|
||||
t.Fatalf("expected 3 icon entries, got %d", count)
|
||||
}
|
||||
|
||||
// Each ICONDIRENTRY: valid size, planes=1, bpp=32, DIB with BITMAPINFOHEADER.
|
||||
for i := 0; i < count; i++ {
|
||||
e := 6 + i*16
|
||||
w := int(ico[e])
|
||||
h := int(ico[e+1])
|
||||
if w == 0 {
|
||||
w = 256
|
||||
}
|
||||
if h == 0 {
|
||||
h = 256
|
||||
}
|
||||
planes := int(ico[e+4]) | int(ico[e+5])<<8
|
||||
bpp := int(ico[e+6]) | int(ico[e+7])<<8
|
||||
size := int(ico[e+8]) | int(ico[e+9])<<8 | int(ico[e+10])<<16 | int(ico[e+11])<<24
|
||||
offset := int(ico[e+12]) | int(ico[e+13])<<8 | int(ico[e+14])<<16 | int(ico[e+15])<<24
|
||||
|
||||
if planes != 1 || bpp != 32 {
|
||||
t.Errorf("entry %d: want planes=1 bpp=32, got %d/%d", i, planes, bpp)
|
||||
}
|
||||
if offset+size > len(ico) {
|
||||
t.Fatalf("entry %d: DIB out of range", i)
|
||||
}
|
||||
|
||||
dib := ico[offset : offset+size]
|
||||
// BITMAPINFOHEADER: biSize=40, biHeight = 2x for XOR+AND.
|
||||
biSize := int(dib[0]) | int(dib[1])<<8 | int(dib[2])<<16 | int(dib[3])<<24
|
||||
biW := int(dib[4]) | int(dib[5])<<8 | int(dib[6])<<16 | int(dib[7])<<24
|
||||
biH := int(dib[8]) | int(dib[9])<<8 | int(dib[10])<<16 | int(dib[11])<<24
|
||||
if biSize != 40 {
|
||||
t.Errorf("entry %d: expected BITMAPINFOHEADER (40), got %d", i, biSize)
|
||||
}
|
||||
if biW != w || biH != h*2 {
|
||||
t.Errorf("entry %d: DIB dims %dx%d, want %dx%d", i, biW, biH, w, h*2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toWindowsIcon passes the PNG through untouched on non-Windows and returns an
|
||||
// ICO on Windows (whose validity TestPNGToIco covers).
|
||||
func TestToWindowsIcon(t *testing.T) {
|
||||
pngMagic := []byte{0x89, 'P', 'N', 'G'}
|
||||
if runtime.GOOS == "windows" {
|
||||
if bytes.HasPrefix(toWindowsIcon(iconRed), pngMagic) {
|
||||
t.Error("windows must not receive a raw PNG")
|
||||
}
|
||||
} else {
|
||||
if !bytes.HasPrefix(toWindowsIcon(iconRed), pngMagic) {
|
||||
t.Error("non-windows must receive the PNG unchanged")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,29 @@ type Capabilities struct {
|
||||
Reboot bool `yaml:"reboot"`
|
||||
ServiceControl []string `yaml:"service_control"`
|
||||
ArbitraryBash bool `yaml:"arbitrary_bash"`
|
||||
LdapTunnel bool `yaml:"ldap_tunnel"`
|
||||
Secrets bool `yaml:"secrets"`
|
||||
IAM bool `yaml:"iam"`
|
||||
WireGuard bool `yaml:"wireguard"`
|
||||
}
|
||||
|
||||
// SecretTarget maps a local template to a rendered target file and an optional
|
||||
// post-render reload command (DESIGN.md §5).
|
||||
type SecretTarget struct {
|
||||
Template string `yaml:"template"`
|
||||
Target string `yaml:"target"`
|
||||
Reload string `yaml:"reload"`
|
||||
}
|
||||
|
||||
// WireGuardConfig holds the mesh client settings (DESIGN-WINDOWS.md §5).
|
||||
type WireGuardConfig struct {
|
||||
// TunnelName is the WireGuard interface (Linux) / service name (Windows).
|
||||
TunnelName string `yaml:"tunnel_name"`
|
||||
// Conf is where the pushed peer config is persisted on disk.
|
||||
Conf string `yaml:"conf"`
|
||||
// Executable is the wireguard.exe path (Windows); "" = PATH or default
|
||||
// install location.
|
||||
Executable string `yaml:"executable"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -25,10 +48,48 @@ type Config struct {
|
||||
// the server exchanges it for a per-agent AuthToken (written back to this
|
||||
// file), so it is a bootstrap value, not a long-term credential. Used only
|
||||
// when AuthToken is empty.
|
||||
JoinKey string `yaml:"join_key"`
|
||||
Location string `yaml:"location"`
|
||||
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
|
||||
Capabilities Capabilities `yaml:"capabilities"`
|
||||
JoinKey string `yaml:"join_key"`
|
||||
Location string `yaml:"location"`
|
||||
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
|
||||
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
|
||||
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
|
||||
Capabilities Capabilities `yaml:"capabilities"`
|
||||
|
||||
// Windows-specific (DESIGN-WINDOWS.md §11).
|
||||
ServiceName string `yaml:"service_name"` // Windows service name
|
||||
DesktopHelper string `yaml:"desktop_helper"` // theta-agent-helper.exe path
|
||||
PublicIPDetect *bool `yaml:"public_ip_detect"` // false disables external lookups (air-gap)
|
||||
AutoVPN bool `yaml:"auto_vpn"` // auto-connect WireGuard when away
|
||||
WireGuard WireGuardConfig `yaml:"wireguard"`
|
||||
|
||||
// PreferLocalDirectory opts into mDNS local-discovery (MULTI_SITE_SPEC.md
|
||||
// Appendix B): when a theta-gateway/theta-proxy on the local network
|
||||
// segment announces itself as fronting this agent's ServerURL host, skip
|
||||
// the WAN/relay path and talk to it directly. Off by default -- it
|
||||
// changes name resolution behavior on the host, so it's opt-in, not
|
||||
// automatic. Linux only for now (see local_discovery.go); Windows/macOS
|
||||
// need their own platform-native investigation before this flag does
|
||||
// anything there.
|
||||
PreferLocalDirectory bool `yaml:"prefer_local_directory"`
|
||||
}
|
||||
|
||||
// DetectPublicIP reports whether the agent may perform external public-IP
|
||||
// lookups. Defaults to true; an air-gapped host sets public_ip_detect: false so
|
||||
// the agent never tries to reach ipify/icanhazip/etc.
|
||||
func (c *Config) DetectPublicIP() bool {
|
||||
if c.PublicIPDetect == nil {
|
||||
return true
|
||||
}
|
||||
return *c.PublicIPDetect
|
||||
}
|
||||
|
||||
// ServiceNameOrDefault returns the Windows service name, defaulting to
|
||||
// theta-agent when unset.
|
||||
func (c *Config) ServiceNameOrDefault() string {
|
||||
if c.ServiceName != "" {
|
||||
return c.ServiceName
|
||||
}
|
||||
return "theta-agent"
|
||||
}
|
||||
|
||||
// Credential returns the value to present when connecting: our own token once
|
||||
@@ -118,12 +179,69 @@ func (cm *ConfigManager) PersistEnrollment(token, publicKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistAutoVPN writes the tray's auto-VPN preference back into agent.yml so
|
||||
// it survives a restart. Same line-preserving edit as PersistEnrollment.
|
||||
func (cm *ConfigManager) PersistAutoVPN(value bool) error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
raw, err := os.ReadFile(cm.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", cm.configPath, err)
|
||||
}
|
||||
out := setYamlScalarValue(string(raw), "auto_vpn", fmt.Sprintf("%t", value), false)
|
||||
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", cm.configPath, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(cm.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload after auto_vpn: %w", err)
|
||||
}
|
||||
cm.current = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearEnrollment blanks the auth_token and public_key so the agent re-enrolls
|
||||
// with whatever join_key is configured. Triggered by the tray's "re-enroll".
|
||||
func (cm *ConfigManager) ClearEnrollment() error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
raw, err := os.ReadFile(cm.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", cm.configPath, err)
|
||||
}
|
||||
out := setYamlScalar(string(raw), "auth_token", "")
|
||||
out = setYamlScalar(out, "public_key", "")
|
||||
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", cm.configPath, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(cm.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload after enrollment clear: %w", err)
|
||||
}
|
||||
cm.current = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// setYamlScalar replaces the value of a top-level `key: "..."` line, or appends
|
||||
// the key when it is absent. Deliberately line-based rather than a YAML
|
||||
// round-trip so comments and formatting survive.
|
||||
func setYamlScalar(doc, key, value string) string {
|
||||
return setYamlScalarValue(doc, key, value, true)
|
||||
}
|
||||
|
||||
// setYamlScalarValue is setYamlScalar with control over quoting. Numeric/bool
|
||||
// scalars (e.g. auto_vpn: true) must stay unquoted or YAML decodes them as
|
||||
// strings.
|
||||
func setYamlScalarValue(doc, key, value string, quote bool) string {
|
||||
line := fmt.Sprintf("%s: %s", key, value)
|
||||
if quote {
|
||||
line = fmt.Sprintf("%s: %q", key, value)
|
||||
}
|
||||
re := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*:.*$`)
|
||||
line := fmt.Sprintf("%s: %q", key, value)
|
||||
if re.MatchString(doc) {
|
||||
return re.ReplaceAllString(doc, line)
|
||||
}
|
||||
@@ -146,6 +264,10 @@ func LoadConfig(path string) (*Config, error) {
|
||||
return nil, fmt.Errorf("failed to decode YAML config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Capabilities.ConfigureLDAP {
|
||||
cfg.Capabilities.LdapTunnel = true
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
|
||||
+60
-4
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -155,10 +156,13 @@ capabilities:
|
||||
t.Errorf("Credential() should prefer the issued token, got %q", cm.Get().Credential())
|
||||
}
|
||||
|
||||
// file must stay 0600 -- it now holds a credential
|
||||
fi, _ := os.Stat(path)
|
||||
if fi.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
|
||||
// file must stay 0600 -- it now holds a credential. POSIX-only: Windows has
|
||||
// no mode bits (0666 is reported regardless) and relies on ACLs instead.
|
||||
if runtime.GOOS != "windows" {
|
||||
fi, _ := os.Stat(path)
|
||||
if fi.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected mode 0600, got %o", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,3 +206,55 @@ func TestPersistEnrollmentRejectsEmptyToken(t *testing.T) {
|
||||
t.Error("expected an error when the server sends no token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistAutoVPN writes the tray preference into the file and reloads it.
|
||||
func TestPersistAutoVPN(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/agent.yml"
|
||||
os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\njoin_key: \"tjk_abc123\"\n"), 0600)
|
||||
cm, err := NewConfigManager(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := cm.PersistAutoVPN(true); err != nil {
|
||||
t.Fatalf("PersistAutoVPN: %v", err)
|
||||
}
|
||||
if !cm.Get().AutoVPN {
|
||||
t.Errorf("AutoVPN should be true after persist")
|
||||
}
|
||||
out, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(out), "auto_vpn: true") {
|
||||
t.Errorf("expected auto_vpn: true in file, got:\n%s", out)
|
||||
}
|
||||
|
||||
if err := cm.PersistAutoVPN(false); err != nil {
|
||||
t.Fatalf("PersistAutoVPN(false): %v", err)
|
||||
}
|
||||
if cm.Get().AutoVPN {
|
||||
t.Errorf("AutoVPN should be false after persist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearEnrollment blanks credentials so the agent re-enrolls.
|
||||
func TestClearEnrollment(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/agent.yml"
|
||||
os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\nauth_token: \"tok-abc\"\npublic_key: \"PK\"\njoin_key: \"tjk_keep\"\n"), 0600)
|
||||
cm, err := NewConfigManager(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := cm.ClearEnrollment(); err != nil {
|
||||
t.Fatalf("ClearEnrollment: %v", err)
|
||||
}
|
||||
cfg := cm.Get()
|
||||
if cfg.AuthToken != "" || cfg.PublicKey != "" {
|
||||
t.Errorf("expected cleared credentials, got token=%q pub=%q", cfg.AuthToken, cfg.PublicKey)
|
||||
}
|
||||
out, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(out), "tok-abc") {
|
||||
t.Errorf("old token should be gone from file, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache bash
|
||||
WORKDIR /demo
|
||||
COPY get-secret.sh get-secret.js ./
|
||||
RUN chmod +x get-secret.sh
|
||||
CMD ["sh", "-c", "sh /demo/get-secret.sh && node /demo/get-secret.js"]
|
||||
@@ -0,0 +1,17 @@
|
||||
// Demo: a 3rd-party node app reads the secret the theta-agent rendered to disk.
|
||||
const fs = require('fs');
|
||||
const path = '/etc/theta/rendered/db.env';
|
||||
console.log('=== node app reads the rendered secret ===');
|
||||
if (fs.existsSync(path)) {
|
||||
const env = fs.readFileSync(path, 'utf8');
|
||||
const db = {};
|
||||
for (const line of env.split('\n')) {
|
||||
const m = /^(\w+)="(.*)"$/.exec(line.trim());
|
||||
if (m) db[m[1]] = m[2];
|
||||
}
|
||||
console.log('DB_USER=' + db.DB_USER);
|
||||
console.log('DB_PASS=' + db.DB_PASS);
|
||||
} else {
|
||||
console.error('rendered secret not found — run render_secrets first');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# Demo: a 3rd-party bash app reads the secret the theta-agent rendered to disk.
|
||||
# The agent rendered /etc/theta/rendered/db.env from a template + OpenBao.
|
||||
echo "=== bash app reads the rendered secret ==="
|
||||
if [ -f /etc/theta/rendered/db.env ]; then
|
||||
. /etc/theta/rendered/db.env
|
||||
echo "DB_USER=$DB_USER"
|
||||
echo "DB_PASS=$DB_PASS"
|
||||
else
|
||||
echo "rendered secret not found — run render_secrets first"
|
||||
exit 1
|
||||
fi
|
||||
@@ -2,16 +2,24 @@ module github.com/theta42/theta-agent
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.5
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
golang.org/x/sys v0.20.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/miekg/dns v1.1.41 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,29 +1,60 @@
|
||||
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE=
|
||||
github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
|
||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+183
@@ -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,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Linux-only for now (AGENT_LOCAL_DISCOVERY_SPEC.md §3) -- Windows/macOS
|
||||
// hosts-file semantics (elevation, DNS caching, whether mDNSResponder should
|
||||
// be used instead of hand-rolled hosts edits) need their own platform-native
|
||||
// investigation before this mechanism is trusted there.
|
||||
//
|
||||
// var, not const, so tests can point it at a temp file instead of touching
|
||||
// the real /etc/hosts.
|
||||
var hostsFilePathLinux = "/etc/hosts"
|
||||
|
||||
const hostsBlockBegin = "# BEGIN theta-agent-local-discovery (managed, do not edit by hand)"
|
||||
const hostsBlockEnd = "# END theta-agent-local-discovery"
|
||||
|
||||
var hostsMu sync.Mutex
|
||||
|
||||
// applyHostsOverride replaces the managed block in /etc/hosts with exactly
|
||||
// `entries` (hostname -> IP). Passing an empty map removes the block
|
||||
// entirely rather than leaving an empty marker pair, so a host that never
|
||||
// discovers anything -- or stops discovering something it used to -- leaves
|
||||
// hosts file with no discovery trace at all.
|
||||
func applyHostsOverride(entries map[string]string) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("hosts-file override is Linux-only for now (see AGENT_LOCAL_DISCOVERY_SPEC.md §3)")
|
||||
}
|
||||
hostsMu.Lock()
|
||||
defer hostsMu.Unlock()
|
||||
|
||||
existing, err := readLines(hostsFilePathLinux)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %s: %w", hostsFilePathLinux, err)
|
||||
}
|
||||
|
||||
kept := make([]string, 0, len(existing))
|
||||
inBlock := false
|
||||
for _, line := range existing {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == hostsBlockBegin {
|
||||
inBlock = true
|
||||
continue
|
||||
}
|
||||
if trimmed == hostsBlockEnd {
|
||||
inBlock = false
|
||||
continue
|
||||
}
|
||||
if inBlock {
|
||||
continue // drop old managed lines unconditionally; rebuilt below
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
|
||||
// Trim any trailing blank lines the block removal left, then rebuild.
|
||||
for len(kept) > 0 && strings.TrimSpace(kept[len(kept)-1]) == "" {
|
||||
kept = kept[:len(kept)-1]
|
||||
}
|
||||
|
||||
out := strings.Join(kept, "\n")
|
||||
if len(entries) > 0 {
|
||||
out += "\n" + hostsBlockBegin + "\n"
|
||||
for host, ip := range entries {
|
||||
out += fmt.Sprintf("%s\t%s\n", ip, host)
|
||||
}
|
||||
out += hostsBlockEnd + "\n"
|
||||
} else {
|
||||
out += "\n"
|
||||
}
|
||||
|
||||
// NOT write-tmp-then-rename: on a real host that's the safer, atomic
|
||||
// way to update a file, but /etc/hosts is frequently a bind mount
|
||||
// (every container runtime does this, Docker included) -- confirmed the
|
||||
// hard way: rename() onto a bind-mounted /etc/hosts fails with EBUSY
|
||||
// ("device or resource busy"), since you cannot atomically replace a
|
||||
// mountpoint. Truncate-and-rewrite in place instead; hostsMu already
|
||||
// serializes calls from this process, which is the only writer of the
|
||||
// managed block, so the lost atomicity is a real but small tradeoff
|
||||
// against a confirmed hard failure.
|
||||
if err := os.WriteFile(hostsFilePathLinux, []byte(out), 0644); err != nil {
|
||||
return fmt.Errorf("writing %s: %w", hostsFilePathLinux, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readLines(path string) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
return lines, scanner.Err()
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/mdns"
|
||||
)
|
||||
|
||||
func withTempHostsFile(t *testing.T, initial string) string {
|
||||
t.Helper()
|
||||
// applyHostsOverride refuses unconditionally on non-Linux (see
|
||||
// hosts_override.go) -- these tests exercise the Linux write path
|
||||
// specifically, so they'd fail for the right reason on the Windows CI
|
||||
// runner if not skipped. Confirmed the hard way: a real CI run failed
|
||||
// here after this was missed.
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("applyHostsOverride is Linux-only; skipping on " + runtime.GOOS)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hosts")
|
||||
if initial != "" {
|
||||
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
|
||||
t.Fatalf("seeding temp hosts file: %v", err)
|
||||
}
|
||||
}
|
||||
orig := hostsFilePathLinux
|
||||
hostsFilePathLinux = path
|
||||
t.Cleanup(func() { hostsFilePathLinux = orig })
|
||||
return path
|
||||
}
|
||||
|
||||
func TestApplyHostsOverride_AddsManagedBlock(t *testing.T) {
|
||||
path := withTempHostsFile(t, "127.0.0.1\tlocalhost\n")
|
||||
|
||||
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil {
|
||||
t.Fatalf("applyHostsOverride: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
s := string(got)
|
||||
if !strings.Contains(s, "127.0.0.1\tlocalhost") {
|
||||
t.Errorf("existing content was clobbered: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, hostsBlockBegin) || !strings.Contains(s, hostsBlockEnd) {
|
||||
t.Errorf("managed block markers missing: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "10.0.0.5\tsso.example.com") {
|
||||
t.Errorf("override entry missing: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHostsOverride_ReplacesPriorBlockRatherThanStacking(t *testing.T) {
|
||||
path := withTempHostsFile(t, "")
|
||||
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil {
|
||||
t.Fatalf("first apply: %v", err)
|
||||
}
|
||||
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.9"}); err != nil {
|
||||
t.Fatalf("second apply: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
s := string(got)
|
||||
if strings.Count(s, hostsBlockBegin) != 1 {
|
||||
t.Fatalf("expected exactly one managed block, got content: %q", s)
|
||||
}
|
||||
if strings.Contains(s, "10.0.0.5") {
|
||||
t.Errorf("stale override (10.0.0.5) should have been replaced, got: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "10.0.0.9") {
|
||||
t.Errorf("new override missing, got: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHostsOverride_EmptyEntriesRemovesBlockEntirely(t *testing.T) {
|
||||
path := withTempHostsFile(t, "127.0.0.1\tlocalhost\n")
|
||||
if err := applyHostsOverride(map[string]string{"sso.example.com": "10.0.0.5"}); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if err := applyHostsOverride(map[string]string{}); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
s := string(got)
|
||||
if strings.Contains(s, hostsBlockBegin) || strings.Contains(s, "10.0.0.5") {
|
||||
t.Errorf("expected no discovery trace left after clearing, got: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "127.0.0.1\tlocalhost") {
|
||||
t.Errorf("pre-existing content should survive a full clear, got: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostFromURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://sso.example.com:443/api": "sso.example.com",
|
||||
"http://sso.example.com": "sso.example.com",
|
||||
"not a url at all": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := hostFromURL(in); got != want {
|
||||
t.Errorf("hostFromURL(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryAnnouncesHost(t *testing.T) {
|
||||
entry := &mdns.ServiceEntry{InfoFields: []string{"hosts=sso.example.com,proxy.example.com"}}
|
||||
if !entryAnnouncesHost(entry, "sso.example.com") {
|
||||
t.Error("expected match for sso.example.com")
|
||||
}
|
||||
if !entryAnnouncesHost(entry, "proxy.example.com") {
|
||||
t.Error("expected match for proxy.example.com")
|
||||
}
|
||||
if entryAnnouncesHost(entry, "jump.example.com") {
|
||||
t.Error("expected no match for a host not in the TXT record")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IAM engine (DESIGN.md §6). The SSO pushes node-scoped identity config down the
|
||||
// WSS channel as a signed `iam_apply` command; the agent verifies the signature
|
||||
// (fail-closed) and applies it locally: sudo rules, SSH keys, access control,
|
||||
// and session revocation.
|
||||
|
||||
// Paths are package-level vars so tests can redirect them to temp dirs.
|
||||
var (
|
||||
iamSudoersDir = "/etc/sudoers.d"
|
||||
iamKeysDir = "/etc/theta/authorized_keys"
|
||||
iamKeysCommand = "/usr/local/bin/theta-authorized-keys"
|
||||
iamAccessConf = "/etc/security/access.conf"
|
||||
)
|
||||
|
||||
// IAMPayload is the signed body of an `iam_apply` command.
|
||||
type IAMPayload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Revision int `json:"revision"`
|
||||
AccessControl AccessControl `json:"access_control"`
|
||||
}
|
||||
|
||||
type AccessControl struct {
|
||||
AllowedLoginGroups []string `json:"allowed_login_groups"`
|
||||
SudoRules []SudoRule `json:"sudo_rules"`
|
||||
SSHKeys []SSHKey `json:"ssh_keys"`
|
||||
RevokeUsers []string `json:"revoke_users"`
|
||||
}
|
||||
|
||||
type SudoRule struct {
|
||||
Group string `json:"group"`
|
||||
RunAs string `json:"run_as"`
|
||||
Commands []string `json:"commands"`
|
||||
Nopasswd bool `json:"nopasswd"`
|
||||
}
|
||||
|
||||
type SSHKey struct {
|
||||
User string `json:"user"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
// applyIAM applies a verified IAM payload. The caller must have already verified
|
||||
// the Ed25519 signature.
|
||||
func applyIAM(payload IAMPayload, exec Executor) error {
|
||||
if len(payload.AccessControl.SudoRules) > 0 {
|
||||
if err := applySudoRules(payload.AccessControl.SudoRules, payload.NodeID, exec); err != nil {
|
||||
return fmt.Errorf("sudo rules: %w", err)
|
||||
}
|
||||
}
|
||||
if len(payload.AccessControl.SSHKeys) > 0 {
|
||||
if err := applySSHKeys(payload.AccessControl.SSHKeys, exec); err != nil {
|
||||
return fmt.Errorf("ssh keys: %w", err)
|
||||
}
|
||||
}
|
||||
if len(payload.AccessControl.AllowedLoginGroups) > 0 {
|
||||
if err := applyAccessControl(payload.AccessControl.AllowedLoginGroups, exec); err != nil {
|
||||
return fmt.Errorf("access control: %w", err)
|
||||
}
|
||||
}
|
||||
if len(payload.AccessControl.RevokeUsers) > 0 {
|
||||
applyRevocation(payload.AccessControl.RevokeUsers, exec)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySudoRules writes /etc/sudoers.d/theta-iam-<node_id>, verifies with
|
||||
// `visudo -c`, and atomically swaps it in on success.
|
||||
func applySudoRules(rules []SudoRule, nodeID string, exec Executor) error {
|
||||
var b strings.Builder
|
||||
for _, r := range rules {
|
||||
if r.Group == "" {
|
||||
continue
|
||||
}
|
||||
runAs := r.RunAs
|
||||
if runAs == "" {
|
||||
runAs = "ALL"
|
||||
}
|
||||
cmds := strings.Join(r.Commands, ", ")
|
||||
if cmds == "" {
|
||||
cmds = "ALL"
|
||||
}
|
||||
prefix := ""
|
||||
if r.Nopasswd {
|
||||
prefix = "NOPASSWD:"
|
||||
}
|
||||
fmt.Fprintf(&b, "%%%s ALL=(%s) %s%s\n", r.Group, runAs, prefix, cmds)
|
||||
}
|
||||
content := b.String()
|
||||
|
||||
// Make sure the sudoers.d dir exists (a minimal host may not have it).
|
||||
if err := os.MkdirAll(iamSudoersDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write to a temp file in the sudoers.d dir, verify, then rename.
|
||||
tmp, err := os.CreateTemp(iamSudoersDir, ".theta-iam-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.WriteString(content); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
tmp.Close()
|
||||
os.Chmod(tmpName, 0440)
|
||||
|
||||
// visudo -c -f <file> validates a single file without touching the rest.
|
||||
if _, err := exec.Execute("visudo", "-c", "-f", tmpName); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("visudo rejected the rules: %w", err)
|
||||
}
|
||||
|
||||
target := filepath.Join(iamSudoersDir, "theta-iam-"+nodeID)
|
||||
if err := os.Rename(tmpName, target); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
log.Printf("IAM: wrote %s", target)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySSHKeys stores per-user keys and installs the AuthorizedKeysCommand
|
||||
// script that sshd calls per login.
|
||||
func applySSHKeys(keys []SSHKey, exec Executor) error {
|
||||
if err := os.MkdirAll(iamKeysDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.User == "" {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(iamKeysDir, k.User)
|
||||
content := strings.Join(k.Keys, "\n") + "\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorizedKeysCommand script: cat the user's key file.
|
||||
script := "#!/bin/sh\ncat \"" + iamKeysDir + "/$1\" 2>/dev/null\n"
|
||||
if err := os.WriteFile(iamKeysCommand, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("IAM: wrote %d ssh key file(s) + %s", len(keys), iamKeysCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyAccessControl writes /etc/security/access.conf with the allowed login
|
||||
// groups (PAM access). Format: `+:group:ALL` for each allowed group, then deny
|
||||
// everything else.
|
||||
func applyAccessControl(groups []string, exec Executor) error {
|
||||
var b strings.Builder
|
||||
for _, g := range groups {
|
||||
if g != "" {
|
||||
fmt.Fprintf(&b, "+:%s:ALL\n", g)
|
||||
}
|
||||
}
|
||||
b.WriteString("-:ALL:ALL\n")
|
||||
if err := os.MkdirAll(filepath.Dir(iamAccessConf), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(iamAccessConf, []byte(b.String()), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("IAM: wrote %s", iamAccessConf)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyRevocation flushes the SSSD cache and drops active sessions for the
|
||||
// revoked users.
|
||||
func applyRevocation(users []string, exec Executor) {
|
||||
// Flush the whole SSSD cache once — a revoked user must not be resolvable
|
||||
// from cache.
|
||||
if _, err := exec.Execute("sss_cache", "-E"); err != nil {
|
||||
log.Printf("IAM: sss_cache -E failed: %v", err)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := exec.Execute("pkill", "-u", u); err != nil {
|
||||
log.Printf("IAM: pkill -u %s failed (no sessions?): %v", u, err)
|
||||
}
|
||||
log.Printf("IAM: revoked %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
// parseIAMPayload extracts an IAMPayload from a WSMessage payload map.
|
||||
func parseIAMPayload(payload map[string]interface{}) (IAMPayload, error) {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return IAMPayload{}, err
|
||||
}
|
||||
var p IAMPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return IAMPayload{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestApplyIAM verifies the agent applies sudo rules, SSH keys, access control,
|
||||
// and revocation from a signed IAM payload.
|
||||
func TestApplyIAM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
iamSudoersDir = dir
|
||||
iamKeysDir = filepath.Join(dir, "keys")
|
||||
iamKeysCommand = filepath.Join(dir, "theta-authorized-keys")
|
||||
iamAccessConf = filepath.Join(dir, "access.conf")
|
||||
|
||||
payload := IAMPayload{
|
||||
NodeID: "node-42",
|
||||
Revision: 82,
|
||||
AccessControl: AccessControl{
|
||||
AllowedLoginGroups: []string{"sysadmins", "node-operators"},
|
||||
SudoRules: []SudoRule{
|
||||
{Group: "sysadmins", RunAs: "ALL", Commands: []string{"ALL"}},
|
||||
{Group: "node-operators", RunAs: "ALL", Commands: []string{"ALL"}, Nopasswd: true},
|
||||
},
|
||||
SSHKeys: []SSHKey{
|
||||
{User: "admin", Keys: []string{"ssh-ed25519 AAAA admin@theta"}},
|
||||
},
|
||||
RevokeUsers: []string{"olduser"},
|
||||
},
|
||||
}
|
||||
|
||||
exec := &MockExecutor{}
|
||||
if err := applyIAM(payload, exec); err != nil {
|
||||
t.Fatalf("applyIAM: %v", err)
|
||||
}
|
||||
|
||||
// Sudo rules file.
|
||||
sudoers, err := os.ReadFile(filepath.Join(dir, "theta-iam-node-42"))
|
||||
if err != nil {
|
||||
t.Fatalf("read sudoers: %v", err)
|
||||
}
|
||||
wantSudoers := "%sysadmins ALL=(ALL) ALL\n%node-operators ALL=(ALL) NOPASSWD:ALL\n"
|
||||
if string(sudoers) != wantSudoers {
|
||||
t.Fatalf("sudoers mismatch:\n got: %q\nwant: %q", sudoers, wantSudoers)
|
||||
}
|
||||
|
||||
// SSH key file + AuthorizedKeysCommand script.
|
||||
keyFile, err := os.ReadFile(filepath.Join(iamKeysDir, "admin"))
|
||||
if err != nil {
|
||||
t.Fatalf("read key file: %v", err)
|
||||
}
|
||||
if string(keyFile) != "ssh-ed25519 AAAA admin@theta\n" {
|
||||
t.Fatalf("key file mismatch: %q", keyFile)
|
||||
}
|
||||
script, err := os.ReadFile(iamKeysCommand)
|
||||
if err != nil {
|
||||
t.Fatalf("read keys command: %v", err)
|
||||
}
|
||||
if string(script) != "#!/bin/sh\ncat \""+iamKeysDir+"/$1\" 2>/dev/null\n" {
|
||||
t.Fatalf("keys command mismatch: %q", script)
|
||||
}
|
||||
|
||||
// Access control.
|
||||
access, err := os.ReadFile(iamAccessConf)
|
||||
if err != nil {
|
||||
t.Fatalf("read access.conf: %v", err)
|
||||
}
|
||||
wantAccess := "+:sysadmins:ALL\n+:node-operators:ALL\n-:ALL:ALL\n"
|
||||
if string(access) != wantAccess {
|
||||
t.Fatalf("access.conf mismatch:\n got: %q\nwant: %q", access, wantAccess)
|
||||
}
|
||||
|
||||
// Commands: visudo -c -f, sss_cache -E, pkill -u olduser.
|
||||
ran := map[string]bool{}
|
||||
for _, c := range exec.ExecutedCommands {
|
||||
if c[0] == "visudo" {
|
||||
ran["visudo"] = true
|
||||
}
|
||||
if c[0] == "sss_cache" {
|
||||
ran["sss_cache"] = true
|
||||
}
|
||||
if c[0] == "pkill" && len(c) >= 3 && c[2] == "olduser" {
|
||||
ran["pkill"] = true
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"visudo", "sss_cache", "pkill"} {
|
||||
if !ran[k] {
|
||||
t.Errorf("expected %s to run, got commands %v", k, exec.ExecutedCommands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseIAMPayload verifies the payload parses from a WSMessage payload map.
|
||||
func TestParseIAMPayload(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"node_id": "node-42",
|
||||
"revision": float64(82),
|
||||
"access_control": map[string]interface{}{
|
||||
"allowed_login_groups": []interface{}{"sysadmins"},
|
||||
"sudo_rules": []interface{}{
|
||||
map[string]interface{}{"group": "sysadmins", "run_as": "ALL", "commands": []interface{}{"ALL"}, "nopasswd": true},
|
||||
},
|
||||
"ssh_keys": []interface{}{
|
||||
map[string]interface{}{"user": "admin", "keys": []interface{}{"ssh-ed25519 AAAA"}},
|
||||
},
|
||||
"revoke_users": []interface{}{"olduser"},
|
||||
},
|
||||
}
|
||||
p, err := parseIAMPayload(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("parseIAMPayload: %v", err)
|
||||
}
|
||||
if p.NodeID != "node-42" || p.Revision != 82 {
|
||||
t.Fatalf("bad node/revision: %+v", p)
|
||||
}
|
||||
if len(p.AccessControl.SudoRules) != 1 || p.AccessControl.SudoRules[0].Group != "sysadmins" {
|
||||
t.Fatalf("bad sudo rules: %+v", p.AccessControl.SudoRules)
|
||||
}
|
||||
if len(p.AccessControl.SSHKeys) != 1 || p.AccessControl.SSHKeys[0].User != "admin" {
|
||||
t.Fatalf("bad ssh keys: %+v", p.AccessControl.SSHKeys)
|
||||
}
|
||||
if len(p.AccessControl.RevokeUsers) != 1 || p.AccessControl.RevokeUsers[0] != "olduser" {
|
||||
t.Fatalf("bad revoke users: %+v", p.AccessControl.RevokeUsers)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+89
-26
@@ -19,7 +19,7 @@ log() { echo -e "${GREEN}[+]${NC} $1"; }
|
||||
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
|
||||
|
||||
# 1. Root check
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
|
||||
error "This script must be run as root."
|
||||
fi
|
||||
|
||||
@@ -29,9 +29,10 @@ install_sssd_deps() {
|
||||
log "Installing SSSD and PAM integration dependencies..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo pam-auth-update || true
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
|
||||
if command -v pam-auth-update >/dev/null 2>&1; then
|
||||
pam-auth-update --enable mkhomedir || true
|
||||
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
|
||||
fi
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y sssd sssd-ldap sssd-tools || true
|
||||
@@ -45,6 +46,8 @@ install_sssd_deps() {
|
||||
else
|
||||
log "SSSD is already installed."
|
||||
fi
|
||||
mkdir -p /etc/sssd
|
||||
chmod 755 /etc/sssd
|
||||
}
|
||||
|
||||
# 2. Argument Parsing
|
||||
@@ -55,7 +58,7 @@ PUBLIC_KEY=""
|
||||
B64_CONFIG=""
|
||||
INSTALL_SSSD=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--url)
|
||||
URL="$2"
|
||||
@@ -91,8 +94,8 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Validation
|
||||
if [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
|
||||
# Validation: require credentials ONLY if config file does not already exist
|
||||
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
|
||||
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
|
||||
echo "Usage examples:"
|
||||
echo " sh install.sh \"BASE64_CONFIG\""
|
||||
@@ -107,10 +110,42 @@ fi
|
||||
|
||||
log "Starting Theta Agent installation..."
|
||||
|
||||
# Architecture and OS detection
|
||||
OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH_NAME="$(uname -m)"
|
||||
BINARY_NAME="theta-agent-linux-amd64"
|
||||
|
||||
case "$OS_NAME" in
|
||||
linux*)
|
||||
case "$ARCH_NAME" in
|
||||
x86_64|amd64) BINARY_NAME="theta-agent-linux-amd64" ;;
|
||||
aarch64|arm64) BINARY_NAME="theta-agent-linux-arm64" ;;
|
||||
armv7*|armhf) BINARY_NAME="theta-agent-linux-armv7" ;;
|
||||
*) BINARY_NAME="theta-agent-linux-amd64" ;;
|
||||
esac
|
||||
;;
|
||||
darwin*)
|
||||
case "$ARCH_NAME" in
|
||||
x86_64|amd64) BINARY_NAME="theta-agent-darwin-amd64" ;;
|
||||
arm64|aarch64) BINARY_NAME="theta-agent-darwin-arm64" ;;
|
||||
*) BINARY_NAME="theta-agent-darwin-arm64" ;;
|
||||
esac
|
||||
;;
|
||||
mingw*|msys*|cygwin*)
|
||||
case "$ARCH_NAME" in
|
||||
aarch64|arm64) BINARY_NAME="theta-agent-windows-arm64.exe" ;;
|
||||
*) BINARY_NAME="theta-agent-windows-amd64.exe" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${BINARY_NAME}"
|
||||
|
||||
# 3. Install binary
|
||||
log "Downloading binary from $BINARY_URL..."
|
||||
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
|
||||
chmod +x "$BIN_PATH"
|
||||
log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..."
|
||||
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL"
|
||||
chmod +x "$BIN_PATH.tmp"
|
||||
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
|
||||
|
||||
# 4. Setup configuration
|
||||
log "Preparing configuration directory $CONFIG_DIR..."
|
||||
@@ -120,9 +155,8 @@ chmod 755 "$CONFIG_DIR"
|
||||
if [ -n "$B64_CONFIG" ]; then
|
||||
log "Decoding and writing configuration from base64..."
|
||||
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
|
||||
else
|
||||
elif [ ! -f "$CONFIG_FILE" ]; then
|
||||
log "Generating minimal configuration from arguments..."
|
||||
# Create a minimal yaml with the provided URL and Token
|
||||
cat <<EOF > "$CONFIG_FILE"
|
||||
server_url: "$URL"
|
||||
auth_token: "$TOKEN"
|
||||
@@ -131,26 +165,57 @@ public_key: "$PUBLIC_KEY"
|
||||
location: "unknown"
|
||||
capabilities:
|
||||
telemetry: true
|
||||
configure_ldap: false
|
||||
configure_ldap: true
|
||||
ldap_tunnel: true
|
||||
reboot: false
|
||||
service_control: []
|
||||
arbitrary_bash: false
|
||||
EOF
|
||||
else
|
||||
log "Preserving existing configuration at $CONFIG_FILE"
|
||||
fi
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
|
||||
# An agent with no public_key cannot verify signed commands and will refuse
|
||||
# every one of them. That is the safe default, but it is silent at run time, so
|
||||
# say it plainly here where the operator is watching.
|
||||
if ! grep -qE '^public_key:[[:space:]]*"[^"]+"' "$CONFIG_FILE" 2>/dev/null; then
|
||||
log "WARNING: no public_key configured — this agent will report telemetry but"
|
||||
log " REFUSE reboot / configure_ldap / arbitrary_bash / update_binary."
|
||||
log " Re-run with --public-key \"<base64 key>\" (shown at enrollment)."
|
||||
# Ensure theta-secrets & theta groups exist for non-root secret access
|
||||
log "Configuring non-root secret access groups (theta-secrets)..."
|
||||
if command -v groupadd >/dev/null 2>&1; then
|
||||
getent group theta-secrets >/dev/null 2>&1 || groupadd -r theta-secrets 2>/dev/null || true
|
||||
getent group theta >/dev/null 2>&1 || groupadd -r theta 2>/dev/null || true
|
||||
fi
|
||||
SECRETS_GROUP="root"
|
||||
if getent group theta-secrets >/dev/null 2>&1; then
|
||||
SECRETS_GROUP="theta-secrets"
|
||||
elif getent group theta >/dev/null 2>&1; then
|
||||
SECRETS_GROUP="theta"
|
||||
fi
|
||||
chown -R "root:$SECRETS_GROUP" "$CONFIG_DIR" 2>/dev/null || true
|
||||
chmod 750 "$CONFIG_DIR"
|
||||
chmod 640 "$CONFIG_FILE"
|
||||
|
||||
# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled
|
||||
if [ "$INSTALL_SSSD" -eq 1 ] || grep -q -i "configure_ldap:\s*true" "$CONFIG_FILE" 2>/dev/null; then
|
||||
install_sssd_deps
|
||||
# 4c. Setup Desktop Tray Icon companion
|
||||
TRAY_BINARY_NAME="theta-agent-tray-${OS_NAME}-${ARCH_NAME}"
|
||||
case "$OS_NAME" in
|
||||
linux*) TRAY_BINARY_NAME="theta-agent-tray-linux-amd64" ;;
|
||||
windows*) TRAY_BINARY_NAME="theta-agent-tray-windows-amd64.exe" ;;
|
||||
esac
|
||||
TRAY_BIN_PATH="/usr/local/bin/theta-agent-tray"
|
||||
TRAY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${TRAY_BINARY_NAME}"
|
||||
|
||||
log "Attempting to install desktop tray companion ($TRAY_BINARY_NAME)..."
|
||||
if curl -fsSL "$TRAY_URL" -o "$TRAY_BIN_PATH.tmp" 2>/dev/null; then
|
||||
chmod +x "$TRAY_BIN_PATH.tmp"
|
||||
mv -f "$TRAY_BIN_PATH.tmp" "$TRAY_BIN_PATH"
|
||||
mkdir -p /etc/xdg/autostart
|
||||
cat <<EOF > /etc/xdg/autostart/theta-agent-tray.desktop
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Theta Agent Tray
|
||||
Comment=Theta Agent Desktop Tray Companion
|
||||
Exec=/usr/local/bin/theta-agent-tray
|
||||
Icon=network-workgroup
|
||||
Terminal=false
|
||||
Categories=Utility;System;
|
||||
X-GNOME-Autostart-enabled=true
|
||||
EOF
|
||||
log "Desktop tray companion installed at $TRAY_BIN_PATH with autostart."
|
||||
fi
|
||||
|
||||
# 5. Setup systemd service
|
||||
@@ -165,8 +230,6 @@ Type=simple
|
||||
ExecStart=$BIN_PATH
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=theta-agent
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
; Theta Agent ??? Windows installer (Inno Setup 6.4+)
|
||||
;
|
||||
; Fully offline: bundles the agent, tray, session helper, the official WireGuard
|
||||
; for Windows client, the OpenCredential credential provider, and the VC++ v14
|
||||
; runtime. Nothing on the target machine requires internet access.
|
||||
;
|
||||
; Usage:
|
||||
; iscc installer\windows\installer.iss
|
||||
; theta-agent-2.1.0-windows-amd64-setup.exe /SILENT ^
|
||||
; /SERVER_URL=https://sso.example.com /JOIN_KEY=tjk_...
|
||||
;
|
||||
; Interactively, a wizard page asks for the Theta Directory URL and a join key
|
||||
; (with a button that opens the Theta Directory's Directory -> Install Agent page
|
||||
; to mint one). In silent mode, /SERVER_URL, /JOIN_KEY, /AUTH_TOKEN, /PUBLIC_KEY
|
||||
; and /B64_CONFIG (base64 of a full agent.yml) drive the same result. The values
|
||||
; are written into agent.yml so the installed service enrolls on first start.
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "2.1.0"
|
||||
#endif
|
||||
|
||||
#define MyAppName "Theta Agent"
|
||||
#define MyAppPublisher "Theta42"
|
||||
#define MyAppExeName "theta-agent-windows-amd64.exe"
|
||||
#define AgentDir "..\..\dist"
|
||||
#define VendorDir "vendor"
|
||||
|
||||
[Setup]
|
||||
AppId={{E2F64E2C-7A2B-4F4D-9E8C-9C0D0E9F3A21}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
VersionInfoVersion={#MyAppVersion}
|
||||
DefaultDirName={autopf}\Theta42
|
||||
DefaultGroupName=Theta42
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=admin
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
OutputDir={#AgentDir}
|
||||
OutputBaseFilename=theta-agent-{#MyAppVersion}-windows-amd64-setup
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
UninstallDisplayName={#MyAppName}
|
||||
CloseApplications=no
|
||||
MinVersion=10.0.17763
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Dirs]
|
||||
; The daemon (SYSTEM) and the tray (logged-in user) share %ProgramData%\Theta42
|
||||
; for agent.yml, the tray IPC socket and the WireGuard config. Users need write
|
||||
; access for the socket; the agent.yml ACL is tightened by the code.
|
||||
Name: "{commonappdata}\Theta42"; Permissions: users-modify
|
||||
Name: "{app}\vendor"
|
||||
|
||||
[Files]
|
||||
Source: "{#AgentDir}\theta-agent-windows-amd64.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#AgentDir}\theta-agent-tray-windows-amd64.exe"; DestDir: "{app}\tray"; Flags: ignoreversion
|
||||
Source: "{#AgentDir}\theta-agent-helper-windows-amd64.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
; WireGuard for Windows ??? official, vendor-signed MSI. Installs offline (the
|
||||
; driver is signed; no signature phone-home).
|
||||
Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags: ignoreversion
|
||||
|
||||
; OpenCredential credential provider installer (BSD-3 pGina fork) + the VC++
|
||||
; runtime it needs. Both install silently at [Run].
|
||||
Source: "{#VendorDir}\OpenCredentialInstaller-1.0.0.0.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
|
||||
Source: "{#VendorDir}\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\Theta Agent Tray"; Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Comment: "Theta Agent status tray"
|
||||
Name: "{group}\Open Agent Config"; Filename: "notepad.exe"; Parameters: "{commonappdata}\Theta42\agent.yml"; Comment: "Open the agent configuration file"
|
||||
Name: "{group}\Uninstall Theta Agent"; Filename: "{uninstallexe}"
|
||||
|
||||
[Registry]
|
||||
; Start the tray for every interactive logon.
|
||||
Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "ThetaAgentTray"; ValueData: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Flags: uninsdeletevalue
|
||||
|
||||
[Run]
|
||||
; VC++ v14 runtime (OpenCredential native deps).
|
||||
Filename: "{app}\vendor\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Installing VC++ runtime..."; Flags: runhidden waituntilterminated
|
||||
; OpenCredential credential provider ??? must be registered before logon.
|
||||
Filename: "{app}\vendor\OpenCredentialInstaller-1.0.0.0.exe"; Parameters: "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"; StatusMsg: "Installing OpenCredential credential provider..."; Flags: runhidden waituntilterminated
|
||||
; WireGuard for Windows client.
|
||||
Filename: "msiexec.exe"; Parameters: "/i ""{app}\vendor\wireguard-amd64-0.5.3.msi"" /qn /norestart"; StatusMsg: "Installing WireGuard client..."; Flags: runhidden waituntilterminated
|
||||
; The WireGuard client launches its UI at the end of the MSI; close it ??? the
|
||||
; tunnel is managed by the agent (wireguard.exe /installtunnelservice).
|
||||
Filename: "taskkill.exe"; Parameters: "/f /im wireguard.exe"; Flags: runhidden
|
||||
; Register the agent as a SYSTEM auto-start service.
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "install-service"; StatusMsg: "Registering theta-agent service..."; Flags: runhidden waituntilterminated
|
||||
; Show the tray right away, in silent and interactive installs alike (a silent
|
||||
; install is the common path from the Directory's install command, and the tray
|
||||
; should appear immediately there too, not only at the next logon).
|
||||
Filename: "{app}\tray\theta-agent-tray-windows-amd64.exe"; Description: "Start Theta Agent tray"; StatusMsg: "Starting Theta Agent tray..."; Flags: nowait postinstall
|
||||
|
||||
[Code]
|
||||
var
|
||||
ServerURL: String;
|
||||
JoinKey: String;
|
||||
AuthToken: String;
|
||||
PublicKey: String;
|
||||
B64Config: String;
|
||||
|
||||
AgentConfigPage: TWizardPage;
|
||||
ServerURLEdit: TNewEdit;
|
||||
JoinKeyEdit: TNewEdit;
|
||||
OpenSSOButton: TNewButton;
|
||||
|
||||
// Reads a custom setup command-line parameter (e.g. /SERVER_URL=https://...).
|
||||
// {param:...} raises when the parameter is absent, so the exception becomes "".
|
||||
function GetCmdParam(const Name: String): String;
|
||||
begin
|
||||
try
|
||||
Result := ExpandConstant('{param:' + Name + '}');
|
||||
except
|
||||
Result := '';
|
||||
end;
|
||||
end;
|
||||
|
||||
function InitializeSetup(): Boolean;
|
||||
begin
|
||||
ServerURL := GetCmdParam('SERVER_URL');
|
||||
JoinKey := GetCmdParam('JOIN_KEY');
|
||||
AuthToken := GetCmdParam('AUTH_TOKEN');
|
||||
PublicKey := GetCmdParam('PUBLIC_KEY');
|
||||
B64Config := GetCmdParam('B64_CONFIG');
|
||||
Result := True;
|
||||
end;
|
||||
|
||||
// Opens the Theta Directory page so the operator can mint a join key right
|
||||
// from the wizard. Uses the Server URL they just typed.
|
||||
procedure OnOpenSSOClick(Sender: TObject);
|
||||
var
|
||||
Url: String;
|
||||
ErrorCode: Integer;
|
||||
begin
|
||||
Url := Trim(ServerURLEdit.Text);
|
||||
if Url = '' then begin
|
||||
MsgBox('Enter the Theta Directory URL first (e.g. https://directory.example.com).',
|
||||
mbInformation, MB_OK);
|
||||
Exit;
|
||||
end;
|
||||
if not ShellExec('open', Url, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
|
||||
MsgBox('Could not open the browser: ' + SysErrorMessage(ErrorCode), mbError, MB_OK);
|
||||
end;
|
||||
|
||||
procedure CreateAgentConfigPage();
|
||||
var
|
||||
InfoLabel: TNewStaticText;
|
||||
UrlLabel: TNewStaticText;
|
||||
KeyLabel: TNewStaticText;
|
||||
Y: Integer;
|
||||
begin
|
||||
AgentConfigPage := CreateCustomPage(wpWelcome,
|
||||
'Theta Directory connection',
|
||||
'Tell the agent which Theta Directory to enroll with.');
|
||||
|
||||
InfoLabel := TNewStaticText.Create(AgentConfigPage);
|
||||
InfoLabel.Parent := AgentConfigPage.Surface;
|
||||
InfoLabel.WordWrap := True;
|
||||
InfoLabel.Caption := 'Paste the Theta Directory URL for this deployment. Then either paste a join key '
|
||||
+ '(mint one with the button below, under Directory -> Install Agent) or leave it blank to '
|
||||
+ 'enroll from the tray / CLI later.';
|
||||
// WordWrap + AutoSize are mutually exclusive in VCL; give the wrapped label a
|
||||
// fixed height so the fields below it land on screen.
|
||||
InfoLabel.Width := AgentConfigPage.SurfaceWidth;
|
||||
InfoLabel.Height := ScaleY(48);
|
||||
|
||||
Y := InfoLabel.Top + InfoLabel.Height + ScaleY(12);
|
||||
|
||||
UrlLabel := TNewStaticText.Create(AgentConfigPage);
|
||||
UrlLabel.Parent := AgentConfigPage.Surface;
|
||||
UrlLabel.Caption := 'Theta Directory URL:';
|
||||
UrlLabel.Top := Y;
|
||||
|
||||
ServerURLEdit := TNewEdit.Create(AgentConfigPage);
|
||||
ServerURLEdit.Parent := AgentConfigPage.Surface;
|
||||
ServerURLEdit.Top := UrlLabel.Top + UrlLabel.Height + ScaleY(4);
|
||||
ServerURLEdit.Width := AgentConfigPage.SurfaceWidth;
|
||||
ServerURLEdit.Text := ServerURL;
|
||||
|
||||
OpenSSOButton := TNewButton.Create(AgentConfigPage);
|
||||
OpenSSOButton.Parent := AgentConfigPage.Surface;
|
||||
OpenSSOButton.Top := ServerURLEdit.Top + ServerURLEdit.Height + ScaleY(10);
|
||||
OpenSSOButton.Left := ServerURLEdit.Left;
|
||||
OpenSSOButton.Caption := 'Open Theta Directory install-agent page...';
|
||||
OpenSSOButton.Width := WizardForm.CalculateButtonWidth([OpenSSOButton.Caption]);
|
||||
OpenSSOButton.Height := ScaleY(23);
|
||||
OpenSSOButton.OnClick := @OnOpenSSOClick;
|
||||
|
||||
KeyLabel := TNewStaticText.Create(AgentConfigPage);
|
||||
KeyLabel.Parent := AgentConfigPage.Surface;
|
||||
KeyLabel.Caption := 'Join key (optional):';
|
||||
KeyLabel.Top := OpenSSOButton.Top + OpenSSOButton.Height + ScaleY(12);
|
||||
|
||||
JoinKeyEdit := TNewEdit.Create(AgentConfigPage);
|
||||
JoinKeyEdit.Parent := AgentConfigPage.Surface;
|
||||
JoinKeyEdit.Top := KeyLabel.Top + KeyLabel.Height + ScaleY(4);
|
||||
JoinKeyEdit.Width := AgentConfigPage.SurfaceWidth;
|
||||
JoinKeyEdit.Text := JoinKey;
|
||||
end;
|
||||
|
||||
procedure InitializeWizard();
|
||||
begin
|
||||
CreateAgentConfigPage();
|
||||
end;
|
||||
|
||||
// Pull the values the operator typed into the wizard so WriteAgentConfig can
|
||||
// use them. In silent mode the wizard is walked programmatically and
|
||||
// CurPageChanged fires too -- reading the (empty) edit boxes there would wipe
|
||||
// the /SERVER_URL /JOIN_KEY command-line params and leave agent.yml with an
|
||||
// empty server_url. Only take the edit values when the wizard is actually
|
||||
// being shown interactively.
|
||||
procedure CurPageChanged(CurPageID: Integer);
|
||||
begin
|
||||
if (CurPageID = AgentConfigPage.ID) and (not WizardSilent()) then begin
|
||||
ServerURL := Trim(ServerURLEdit.Text);
|
||||
JoinKey := Trim(JoinKeyEdit.Text);
|
||||
end;
|
||||
end;
|
||||
|
||||
// Minimal base64 decoder returning a plain String (agent.yml is ASCII).
|
||||
function B64Decode(const S: String): String;
|
||||
var
|
||||
i, v, p: Integer;
|
||||
buf: array[0..3] of Integer;
|
||||
outStr: String;
|
||||
begin
|
||||
outStr := '';
|
||||
v := 0;
|
||||
for i := 1 to Length(S) do begin
|
||||
if S[i] = '=' then begin
|
||||
buf[v] := 0;
|
||||
Inc(v);
|
||||
end else begin
|
||||
p := Pos(S[i], 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/');
|
||||
buf[v] := p - 1;
|
||||
Inc(v);
|
||||
end;
|
||||
if v = 4 then begin
|
||||
outStr := outStr + Chr((buf[0] shl 2) or (buf[1] shr 4));
|
||||
outStr := outStr + Chr(((buf[1] and $F) shl 4) or (buf[2] shr 2));
|
||||
outStr := outStr + Chr(((buf[2] and 3) shl 6) or buf[3]);
|
||||
v := 0;
|
||||
end;
|
||||
end;
|
||||
Result := outStr;
|
||||
end;
|
||||
|
||||
// Write agent.yml only after all files are in place. The file lives in
|
||||
// ProgramData so the SYSTEM service and user processes share it.
|
||||
procedure WriteAgentConfig(ConfigPath: String);
|
||||
var
|
||||
Lines: TArrayOfString;
|
||||
Decoded: String;
|
||||
begin
|
||||
// /B64_CONFIG=<base64 agent.yml> overrides everything (the SSO's Custom Config
|
||||
// wizard emits it).
|
||||
if B64Config <> '' then begin
|
||||
Decoded := B64Decode(B64Config);
|
||||
SetArrayLength(Lines, 1);
|
||||
Lines[0] := Decoded;
|
||||
SaveStringsToUTF8FileWithoutBOM(ConfigPath, Lines, False);
|
||||
Exit;
|
||||
end;
|
||||
|
||||
SetArrayLength(Lines, 17);
|
||||
Lines[0] := '# theta-agent configuration (written by installer)';
|
||||
Lines[1] := 'server_url: "' + ServerURL + '"';
|
||||
Lines[2] := 'auth_token: "' + AuthToken + '"';
|
||||
Lines[3] := 'join_key: "' + JoinKey + '"';
|
||||
Lines[4] := 'public_key: "' + PublicKey + '"';
|
||||
Lines[5] := 'auto_vpn: false';
|
||||
Lines[6] := 'service_name: "theta-agent"';
|
||||
Lines[7] := 'desktop_helper: "' + ExpandConstant('{app}') + '\theta-agent-helper-windows-amd64.exe"';
|
||||
Lines[8] := 'public_ip_detect: true';
|
||||
Lines[9] := 'capabilities:';
|
||||
Lines[10] := ' telemetry: true';
|
||||
Lines[11] := ' ldap_tunnel: true';
|
||||
Lines[12] := ' wireguard: true';
|
||||
Lines[13] := ' secrets: false';
|
||||
Lines[14] := ' iam: false';
|
||||
Lines[15] := ' reboot: false';
|
||||
Lines[16] := ' arbitrary_bash: false';
|
||||
SaveStringsToUTF8FileWithoutBOM(ConfigPath, Lines, False);
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
begin
|
||||
if CurStep = ssPostInstall then
|
||||
WriteAgentConfig(ExpandConstant('{commonappdata}\Theta42\agent.yml'));
|
||||
end;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ldapTunnel is the agent's local LDAP socket (DESIGN.md §4). It is a pure byte
|
||||
// pump: bytes from a local LDAP client (SSSD) are forwarded to the SSO over the
|
||||
// WSS channel as `ldap_tunnel` messages, and the SSO's responses are written
|
||||
// back to the socket. The agent never parses LDAP — it does not know or care
|
||||
// what the bytes mean.
|
||||
//
|
||||
// Each local connection gets a conn_id. The agent reads the socket and sends
|
||||
// chunks up; the SSO relays them into its real OpenLDAP and sends the response
|
||||
// chunks back down; the agent writes them to the socket. `close:true` ends a
|
||||
// connection.
|
||||
type ldapTunnel struct {
|
||||
mu sync.Mutex
|
||||
conns map[string]net.Conn
|
||||
send func(WSMessage) error
|
||||
}
|
||||
|
||||
func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
|
||||
return &ldapTunnel{
|
||||
conns: make(map[string]net.Conn),
|
||||
send: send,
|
||||
}
|
||||
}
|
||||
|
||||
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
|
||||
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
||||
// 1. UNIX Domain Socket Listener. Windows passes an empty path and relies
|
||||
// on the TCP loopback listener below.
|
||||
if socketPath != "" {
|
||||
os.Remove(socketPath)
|
||||
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
||||
os.MkdirAll(dir, 0755)
|
||||
}
|
||||
|
||||
lnUnix, err := net.Listen("unix", socketPath)
|
||||
if err == nil {
|
||||
os.Chmod(socketPath, 0666)
|
||||
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
|
||||
go t.acceptLoop(lnUnix, stopCh)
|
||||
} else {
|
||||
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890)
|
||||
lnTcp, errTcp := net.Listen("tcp", "127.0.0.1:389")
|
||||
if errTcp != nil {
|
||||
lnTcp, errTcp = net.Listen("tcp", "127.0.0.1:3890")
|
||||
}
|
||||
|
||||
if errTcp == nil {
|
||||
log.Printf("LDAP tunnel: listening on tcp %s", lnTcp.Addr().String())
|
||||
go t.acceptLoop(lnTcp, stopCh)
|
||||
} else {
|
||||
log.Printf("LDAP tunnel: cannot bind tcp loopback: %v", errTcp)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ldapTunnel) acceptLoop(ln net.Listener, stopCh <-chan struct{}) {
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
<-stopCh
|
||||
ln.Close()
|
||||
}()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go t.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConn pumps one local LDAP connection up to the SSO.
|
||||
func (t *ldapTunnel) handleConn(conn net.Conn) {
|
||||
connID := newConnID()
|
||||
t.mu.Lock()
|
||||
t.conns[connID] = conn
|
||||
t.mu.Unlock()
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
msg := WSMessage{
|
||||
Type: "ldap_tunnel",
|
||||
Payload: map[string]interface{}{
|
||||
"conn_id": connID,
|
||||
"data": base64.StdEncoding.EncodeToString(buf[:n]),
|
||||
},
|
||||
}
|
||||
if err := t.send(msg); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Signal end of connection to the SSO so it can close its OpenLDAP relay.
|
||||
t.send(WSMessage{
|
||||
Type: "ldap_tunnel",
|
||||
Payload: map[string]interface{}{
|
||||
"conn_id": connID,
|
||||
"close": true,
|
||||
},
|
||||
})
|
||||
|
||||
t.mu.Lock()
|
||||
delete(t.conns, connID)
|
||||
t.mu.Unlock()
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
// handleMessage writes SSO→agent tunnel bytes to the matching local socket.
|
||||
// Called from handleCommand when an `ldap_tunnel` message arrives.
|
||||
func (t *ldapTunnel) handleMessage(payload map[string]interface{}) {
|
||||
connID, _ := payload["conn_id"].(string)
|
||||
if connID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if closeFlag, _ := payload["close"].(bool); closeFlag {
|
||||
t.mu.Lock()
|
||||
conn := t.conns[connID]
|
||||
delete(t.conns, connID)
|
||||
t.mu.Unlock()
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dataStr, _ := payload["data"].(string)
|
||||
if dataStr == "" {
|
||||
return
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(dataStr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
conn := t.conns[connID]
|
||||
t.mu.Unlock()
|
||||
if conn != nil {
|
||||
conn.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
var connCounter uint64
|
||||
|
||||
func newConnID() string {
|
||||
connCounter++
|
||||
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), connCounter)
|
||||
}
|
||||
|
||||
// safeWriter serializes writes to the WebSocket. Gorilla allows only one
|
||||
// concurrent writer, but the agent has several (telemetry, heartbeat, the LDAP
|
||||
// tunnel, command responses) — without this, concurrent WriteMessage calls
|
||||
// corrupt the stream.
|
||||
type safeWriter struct {
|
||||
mu sync.Mutex
|
||||
c *websocket.Conn
|
||||
}
|
||||
|
||||
func (w *safeWriter) WriteMessage(messageType int, data []byte) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.c.WriteMessage(messageType, data)
|
||||
}
|
||||
|
||||
// sendTunnelMessage marshals a WSMessage and writes it as a text frame.
|
||||
func sendTunnelMessage(w MessageWriter, msg WSMessage) error {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.WriteMessage(websocket.TextMessage, payload)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLdapTunnelBytePump verifies the agent is a pure byte pump: bytes written
|
||||
// to the local socket are forwarded up as ldap_tunnel messages, and bytes sent
|
||||
// back down are written to the socket. No LDAP parsing anywhere.
|
||||
func TestLdapTunnelBytePump(t *testing.T) {
|
||||
socketPath := filepath.Join(t.TempDir(), "ldap.sock")
|
||||
|
||||
var mu sync.Mutex
|
||||
var sent []WSMessage
|
||||
tunnel := newLdapTunnel(func(msg WSMessage) error {
|
||||
mu.Lock()
|
||||
sent = append(sent, msg)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
defer close(stopCh)
|
||||
go tunnel.start(socketPath, stopCh)
|
||||
|
||||
// Wait for the socket to exist.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if _, err := net.Dial("unix", socketPath); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("socket never became reachable")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
conn, err := net.Dial("unix", socketPath)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Write bytes up to the SSO.
|
||||
if _, err := conn.Write([]byte("hello-ldap")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
// The tunnel should forward them as a base64 ldap_tunnel message.
|
||||
var upMsg WSMessage
|
||||
deadline = time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
if len(sent) > 0 {
|
||||
upMsg = sent[0]
|
||||
mu.Unlock()
|
||||
break
|
||||
}
|
||||
mu.Unlock()
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("no ldap_tunnel message was sent")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if upMsg.Type != "ldap_tunnel" {
|
||||
t.Fatalf("expected ldap_tunnel type, got %q", upMsg.Type)
|
||||
}
|
||||
connID, _ := upMsg.Payload["conn_id"].(string)
|
||||
if connID == "" {
|
||||
t.Fatal("missing conn_id")
|
||||
}
|
||||
dataStr, _ := upMsg.Payload["data"].(string)
|
||||
decoded, err := base64.StdEncoding.DecodeString(dataStr)
|
||||
if err != nil {
|
||||
t.Fatalf("bad base64: %v", err)
|
||||
}
|
||||
if string(decoded) != "hello-ldap" {
|
||||
t.Fatalf("expected 'hello-ldap', got %q", decoded)
|
||||
}
|
||||
|
||||
// Send bytes back down from the SSO; the client should receive them.
|
||||
tunnel.handleMessage(map[string]interface{}{
|
||||
"conn_id": connID,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("world-ldap")),
|
||||
})
|
||||
|
||||
buf := make([]byte, 32)
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if string(buf[:n]) != "world-ldap" {
|
||||
t.Fatalf("expected 'world-ldap', got %q", buf[:n])
|
||||
}
|
||||
|
||||
// Closing the client should emit a close signal up to the SSO.
|
||||
conn.Close()
|
||||
deadline = time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
closed := false
|
||||
for _, m := range sent {
|
||||
if m.Type == "ldap_tunnel" {
|
||||
if c, _ := m.Payload["close"].(bool); c {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if closed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("no close signal was sent")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/mdns"
|
||||
)
|
||||
|
||||
// mDNS local-discovery (AGENT_LOCAL_DISCOVERY_SPEC.md): when a
|
||||
// theta-gateway/theta-proxy on the local network segment announces itself
|
||||
// as fronting this agent's own server hostname, skip the relay/WAN path and
|
||||
// talk to it directly. Opt-in via Config.PreferLocalDirectory.
|
||||
//
|
||||
// HARD RULE (non-negotiable): this changes WHERE we connect (DNS
|
||||
// resolution via /etc/hosts), never WHETHER we trust what answers. Nothing
|
||||
// here touches TLS/certificate validation -- the agent's normal TLS client
|
||||
// code path is completely untouched, so a spoofed rogue mDNS announcement
|
||||
// just produces a TLS handshake failure against the real hostname's cert,
|
||||
// not a silent MITM. Do not "fix" a discovery-related connection failure by
|
||||
// loosening cert checks; that would defeat the entire point of this rule.
|
||||
|
||||
const mdnsServiceName = "_theta-suite._tcp"
|
||||
const mdnsPollInterval = 30 * time.Second
|
||||
const mdnsLookupTimeout = 3 * time.Second
|
||||
|
||||
// StartLocalDiscovery runs until the process exits. No-op (logs once, then
|
||||
// returns) if the feature isn't enabled or the target host can't be
|
||||
// determined -- callers just `go StartLocalDiscovery(cm)` unconditionally.
|
||||
func StartLocalDiscovery(cm *ConfigManager) {
|
||||
cfg := cm.Get()
|
||||
if !cfg.PreferLocalDirectory {
|
||||
return
|
||||
}
|
||||
targetHost := hostFromURL(cfg.ServerURL)
|
||||
if targetHost == "" {
|
||||
log.Printf("[local-discovery] could not parse a hostname out of server_url %q -- disabled", cfg.ServerURL)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[local-discovery] enabled, watching for a local announcement fronting %s", targetHost)
|
||||
currentlyOverridden := false
|
||||
|
||||
for {
|
||||
ip := findLocalAnnouncement(targetHost)
|
||||
switch {
|
||||
case ip != "" && !currentlyOverridden:
|
||||
if err := applyHostsOverride(map[string]string{targetHost: ip}); err != nil {
|
||||
log.Printf("[local-discovery] found %s locally at %s but failed to apply hosts override: %v", targetHost, ip, err)
|
||||
} else {
|
||||
log.Printf("[local-discovery] %s announced locally at %s -- routing directly, skipping the relay/WAN path", targetHost, ip)
|
||||
currentlyOverridden = true
|
||||
}
|
||||
case ip == "" && currentlyOverridden:
|
||||
if err := applyHostsOverride(map[string]string{}); err != nil {
|
||||
log.Printf("[local-discovery] lost local announcement for %s but failed to clear hosts override: %v", targetHost, err)
|
||||
} else {
|
||||
log.Printf("[local-discovery] %s no longer announced locally -- reverting to normal resolution", targetHost)
|
||||
currentlyOverridden = false
|
||||
}
|
||||
}
|
||||
time.Sleep(mdnsPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func hostFromURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
// findLocalAnnouncement browses for _theta-suite._tcp on the local segment
|
||||
// and returns the announcing host's IP if its TXT "hosts" field lists
|
||||
// targetHost, or "" if nothing matching is currently visible. mDNS is
|
||||
// inherently link-local (multicast doesn't cross routers/VLANs), so "found
|
||||
// vs not found" naturally tracks "on this LAN vs not" with no separate
|
||||
// network-detection logic needed.
|
||||
func findLocalAnnouncement(targetHost string) string {
|
||||
entriesCh := make(chan *mdns.ServiceEntry, 8)
|
||||
done := make(chan struct{})
|
||||
var found string
|
||||
|
||||
go func() {
|
||||
for entry := range entriesCh {
|
||||
if entryAnnouncesHost(entry, targetHost) && found == "" {
|
||||
if entry.AddrV4 != nil {
|
||||
found = entry.AddrV4.String()
|
||||
} else if entry.AddrV6 != nil {
|
||||
found = entry.AddrV6.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// NOT mdns.Lookup() -- its DefaultParams() requests both IPv4 and IPv6,
|
||||
// and the underlying client sends the v4 query, THEN the v6 query, and
|
||||
// returns whatever error the v6 send produced -- aborting the entire
|
||||
// Query() synchronously if IPv6 isn't available, even though the v4
|
||||
// query it already sent may have already gotten (or will get) a valid
|
||||
// response. Confirmed with a packet capture: the v4 query and its
|
||||
// response both went out/came back fine, but Query() still returned
|
||||
// "network is unreachable" (from the v6 send) before the response-
|
||||
// listening loop ever started, so the entry was silently discarded.
|
||||
// IPv6 multicast isn't guaranteed present on every host this runs on
|
||||
// (many servers/containers are v4-only) -- disable it explicitly rather
|
||||
// than depend on IPv6 being configured for IPv4 discovery to work at all.
|
||||
params := mdns.DefaultParams(mdnsServiceName)
|
||||
params.Entries = entriesCh
|
||||
params.Timeout = mdnsLookupTimeout
|
||||
params.DisableIPv6 = true
|
||||
|
||||
err := mdns.Query(params)
|
||||
close(entriesCh)
|
||||
<-done
|
||||
if err != nil {
|
||||
// Transient lookup errors (e.g. no multicast-capable interface at
|
||||
// the moment) are expected on some networks -- treat as "not found
|
||||
// right now", not a fatal condition.
|
||||
return ""
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func entryAnnouncesHost(entry *mdns.ServiceEntry, targetHost string) bool {
|
||||
for _, field := range entry.InfoFields {
|
||||
// TXT format: "hosts=sso.example.com,proxy.example.com"
|
||||
if !strings.HasPrefix(field, "hosts=") {
|
||||
continue
|
||||
}
|
||||
hosts := strings.Split(strings.TrimPrefix(field, "hosts="), ",")
|
||||
for _, h := range hosts {
|
||||
if strings.TrimSpace(h) == targetHost {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -5,15 +5,55 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// wsConnected is flipped atomically by connectWebSocket as the connection
|
||||
// comes up and drops, so StartHomeMonitor can read it without a mutex.
|
||||
var wsConnected atomic.Bool
|
||||
|
||||
// agentStopCh closes once the agent should exit: a SIGINT/SIGTERM in the
|
||||
// foreground, or the SCM stop request when running as a Windows service. Both
|
||||
// runAgent (foreground) and the service handler wait on it.
|
||||
var (
|
||||
agentStopOnce sync.Once
|
||||
agentStopCh = make(chan struct{})
|
||||
)
|
||||
|
||||
// currentCM lets the tray IPC server persist preferences (auto_vpn) and reset
|
||||
// enrollment into the live config file. Set once in runAgent.
|
||||
var currentCM *ConfigManager
|
||||
|
||||
// stopAgent signals the running agent to shut down. Idempotent.
|
||||
func stopAgent() {
|
||||
agentStopOnce.Do(func() { close(agentStopCh) })
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
|
||||
return
|
||||
}
|
||||
|
||||
// Windows: when installed as a service the SCM starts us and svc.Run takes
|
||||
// over the process lifecycle. Foreground (or any other OS) falls through to
|
||||
// runAgent, which blocks until a signal arrives.
|
||||
if maybeRunAsService() {
|
||||
return
|
||||
}
|
||||
|
||||
runAgent()
|
||||
}
|
||||
|
||||
// runAgent runs the agent daemon until stopAgent is called.
|
||||
func runAgent() {
|
||||
log.Println("Starting Theta Agent...")
|
||||
|
||||
// Attempt to load configuration
|
||||
configPath := "/etc/theta42/agent.yml"
|
||||
if len(os.Args) > 1 {
|
||||
configPath := defaultConfigPath()
|
||||
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
||||
configPath = os.Args[1]
|
||||
}
|
||||
|
||||
@@ -23,7 +63,7 @@ func main() {
|
||||
}
|
||||
cfg := cm.Get()
|
||||
|
||||
log.Printf("Connecting to SSO Manager at %s", cfg.ServerURL)
|
||||
log.Printf("Connecting to Theta Directory at %s", cfg.ServerURL)
|
||||
log.Printf("Loaded capabilities: Telemetry=%v, LDAP=%v, Reboot=%v, Bash=%v",
|
||||
cfg.Capabilities.Telemetry,
|
||||
cfg.Capabilities.ConfigureLDAP,
|
||||
@@ -31,16 +71,38 @@ func main() {
|
||||
cfg.Capabilities.ArbitraryBash,
|
||||
)
|
||||
|
||||
// Initialize system executor
|
||||
// Initialize system executor and pin the platform ops the command
|
||||
// dispatcher runs behind.
|
||||
exec := &SystemExecutor{}
|
||||
defaultPlatformOps = NewPlatformOps(cfg, exec)
|
||||
currentCM = cm
|
||||
|
||||
// WebSocket connection to SSO Manager
|
||||
// Seed the auto-VPN preference from disk; the tray checkbox updates it.
|
||||
SetAutoVPN(cfg.AutoVPN)
|
||||
|
||||
// Tray IPC server — desktop tray connects here for status updates.
|
||||
go globalTrayServer.Start()
|
||||
|
||||
// WebSocket connection to Theta Directory
|
||||
go connectWebSocket(cm, exec)
|
||||
|
||||
// Block until signal is received
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
// Home detection + tray status push (polls public IP every 60s).
|
||||
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() })
|
||||
|
||||
// mDNS local-discovery (MULTI_SITE_SPEC.md Appendix B) -- no-op unless
|
||||
// prefer_local_directory is set.
|
||||
go StartLocalDiscovery(cm)
|
||||
|
||||
// Foreground: exit on SIGINT/SIGTERM. A Windows service ignores these and
|
||||
// is driven by its own handler.
|
||||
go func() {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
stopAgent()
|
||||
}()
|
||||
|
||||
<-agentStopCh
|
||||
|
||||
fmt.Println("Shutting down Theta Agent...")
|
||||
}
|
||||
|
||||
@@ -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,294 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Idempotent setup of the Theta Agent Windows dev/build environment.
|
||||
|
||||
.DESCRIPTION
|
||||
Ensures everything needed to build and package the Windows agent, tray,
|
||||
session helper, and the fully-offline Inno installer is present:
|
||||
|
||||
* Go toolchain (pinned version, user-space, no admin required)
|
||||
* Inno Setup compiler (pinned version, per-user install, no admin required)
|
||||
* vendor assets (WireGuard MSI, VC++ redist, OpenCredential CP),
|
||||
fetched into installer/windows/vendor/ and verified against the pinned
|
||||
sha256 in installer/windows/vendor-manifest.json
|
||||
|
||||
Safe to run repeatedly: each component is skipped when already installed
|
||||
and valid, so `powershell -File scripts/setup-build-env.ps1` is a no-op on a
|
||||
ready machine. Pass -Build to also compile the binaries and the installer.
|
||||
|
||||
.PARAMETER ToolDir
|
||||
Where to install toolchains. Default: %LOCALAPPDATA%\Theta42\buildtools
|
||||
|
||||
.PARAMETER RepoRoot
|
||||
Repository root. Default: the parent of this script's directory.
|
||||
|
||||
.PARAMETER SkipGo
|
||||
Do not install/verify the Go toolchain.
|
||||
|
||||
.PARAMETER SkipInno
|
||||
Do not install/verify Inno Setup.
|
||||
|
||||
.PARAMETER SkipVendor
|
||||
Do not fetch/verify vendor assets.
|
||||
|
||||
.PARAMETER Build
|
||||
After setup, build the agent/tray/helper for windows (amd64+arm64), run
|
||||
`go test ./...`, and compile the installer with ISCC.
|
||||
|
||||
.PARAMETER CI
|
||||
Non-interactive/CI mode: exit non-zero on any failure. (Used by the
|
||||
GitHub Actions workflow so the runner fails loudly on a broken env.)
|
||||
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File scripts\setup-build-env.ps1 -Build
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ToolDir = (Join-Path $env:LOCALAPPDATA 'Theta42\buildtools'),
|
||||
[string]$RepoRoot = '',
|
||||
[switch]$SkipGo,
|
||||
[switch]$SkipInno,
|
||||
[switch]$SkipVendor,
|
||||
[switch]$Build,
|
||||
[switch]$CI
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$script:anyFailed = $false
|
||||
|
||||
# $PSScriptRoot is not populated inside the param() defaults on PowerShell 5.1.
|
||||
if (-not $RepoRoot) {
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
}
|
||||
|
||||
function Write-Step($msg) { Write-Host "== $msg" -ForegroundColor Cyan }
|
||||
function Write-OK($msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||
function Write-Skip($msg) { Write-Host " [skip] $msg" -ForegroundColor DarkGray }
|
||||
function Write-Fail($msg) { Write-Host " [FAIL] $msg" -ForegroundColor Red; $script:anyFailed = $true }
|
||||
function Write-Info($msg) { Write-Host " [info] $msg" -ForegroundColor Gray }
|
||||
|
||||
# ---------------------------------------------------------------- manifest ----
|
||||
$manifestPath = Join-Path $RepoRoot 'installer\windows\vendor-manifest.json'
|
||||
$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json
|
||||
$vendorDir = Join-Path $RepoRoot 'installer\windows\vendor'
|
||||
|
||||
# -------------------------------------------------------------- Go toolchain --
|
||||
function Get-GoVersion {
|
||||
$v = (go version 2>$null)
|
||||
if ($v -match 'go([0-9]+\.[0-9]+)') { return $matches[1] }
|
||||
return ''
|
||||
}
|
||||
|
||||
function Install-Go {
|
||||
$needGo = $manifest.toolchain.go.version
|
||||
$minGo = ($needGo -split '\.')[0] + '.' + ($needGo -split '\.')[1] # e.g. 1.22
|
||||
$have = Get-GoVersion
|
||||
|
||||
if ($have -ne '' -and ([version]$have -ge [version]$minGo)) {
|
||||
Write-OK "Go $have already available ($minGo+ required)"
|
||||
return
|
||||
}
|
||||
|
||||
$url = $manifest.toolchain.go.url
|
||||
$goRoot = Join-Path $ToolDir "go$needGo"
|
||||
# The official zip contains a top-level go/ folder -> goRoot\go\bin\go.exe.
|
||||
$goBin = Join-Path $goRoot 'go\bin'
|
||||
if (Test-Path (Join-Path $goBin 'go.exe')) {
|
||||
Write-OK "Go $needGo found at $goRoot"
|
||||
} else {
|
||||
Write-Step "Installing Go $needGo (no admin required, zip extract)"
|
||||
$zip = Join-Path $env:TEMP "go$needGo.windows-amd64.zip"
|
||||
if (-not (Test-Path $zip)) {
|
||||
Write-Info "Downloading $url"
|
||||
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
|
||||
}
|
||||
$staging = Join-Path $goRoot 'staging'
|
||||
New-Item -ItemType Directory -Force -Path $staging | Out-Null
|
||||
Expand-Archive -Path $zip -DestinationPath $staging -Force
|
||||
# staging\go -> goRoot\go
|
||||
Move-Item -Force (Join-Path $staging 'go') $goRoot
|
||||
Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path (Join-Path $goBin 'go.exe'))) {
|
||||
Write-Fail "Go extract produced no bin\go.exe at $goBin"
|
||||
return
|
||||
}
|
||||
Write-OK "Go $needGo installed at $goRoot"
|
||||
}
|
||||
|
||||
Add-ToUserPath $goBin
|
||||
$env:Path = "$goBin;$env:Path"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- Inno Setup ---
|
||||
function Find-Iscc {
|
||||
$candidates = @(
|
||||
(Join-Path $ToolDir 'InnoSetup7\ISCC.exe'),
|
||||
"$env:ProgramFiles\Inno Setup 7\ISCC.exe",
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 7\ISCC.exe",
|
||||
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
if ($c -and (Test-Path $c)) { return $c }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function Install-Inno {
|
||||
$iscc = Find-Iscc
|
||||
if ($iscc) {
|
||||
Write-OK "Inno Setup ISCC found at $iscc"
|
||||
return $iscc
|
||||
}
|
||||
|
||||
$inno = $manifest.toolchain.inno
|
||||
$exe = Join-Path $env:TEMP "innosetup-$($inno.version)-x64.exe"
|
||||
if (-not (Test-Path $exe)) {
|
||||
Write-Step "Downloading Inno Setup $($inno.version)"
|
||||
Invoke-WebRequest -Uri $inno.url -OutFile $exe -UseBasicParsing
|
||||
$h = (Get-FileHash $exe -Algorithm SHA256).Hash.ToUpper()
|
||||
if ($h -ne $inno.sha256) {
|
||||
Remove-Item $exe -Force
|
||||
Write-Fail "Inno Setup installer sha256 mismatch: $h"
|
||||
return ''
|
||||
}
|
||||
Write-OK "Downloaded and verified Inno Setup installer"
|
||||
}
|
||||
|
||||
$dest = Join-Path $ToolDir 'InnoSetup7'
|
||||
Write-Step "Installing Inno Setup per-user to $dest"
|
||||
# /CURRENTUSER installs without admin; /DIR is honored in that mode.
|
||||
$p = Start-Process -FilePath $exe -ArgumentList @(
|
||||
'/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/CURRENTUSER',"/DIR=$dest"
|
||||
) -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0 -or -not (Test-Path (Join-Path $dest 'ISCC.exe'))) {
|
||||
Write-Fail "Inno Setup install failed (exit $($p.ExitCode)); ISCC not found at $dest"
|
||||
return ''
|
||||
}
|
||||
Write-OK "Inno Setup installed; ISCC at $(Join-Path $dest 'ISCC.exe')"
|
||||
Add-ToUserPath $dest
|
||||
return (Join-Path $dest 'ISCC.exe')
|
||||
}
|
||||
|
||||
# ------------------------------------------------- PATH (idempotent) ----------
|
||||
function Add-ToUserPath($dir) {
|
||||
if (-not $dir -or -not (Test-Path $dir)) { return }
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path','User')
|
||||
if ($userPath -and ($userPath -split ';' -contains $dir)) {
|
||||
Write-Skip "$dir already on user PATH"
|
||||
return
|
||||
}
|
||||
$newPath = if ($userPath) { "$userPath;$dir" } else { $dir }
|
||||
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
||||
Write-OK "Added $dir to user PATH"
|
||||
}
|
||||
|
||||
# ------------------------------------------------ Vendor assets (idempotent) --
|
||||
function Fetch-Asset($asset) {
|
||||
$dest = Join-Path $vendorDir $asset.name
|
||||
$ok = $false
|
||||
if (Test-Path $dest) {
|
||||
$h = (Get-FileHash $dest -Algorithm SHA256).Hash.ToUpper()
|
||||
if ($h -eq $asset.sha256) {
|
||||
$ok = $true
|
||||
Write-Skip "$($asset.name) present and checksum verified"
|
||||
} else {
|
||||
Write-Info "$($asset.name): stale or corrupt (got $h), re-downloading"
|
||||
}
|
||||
}
|
||||
if (-not $ok) {
|
||||
Write-Info "Downloading $($asset.name)"
|
||||
Invoke-WebRequest -Uri $asset.url -OutFile $dest -UseBasicParsing
|
||||
$h = (Get-FileHash $dest -Algorithm SHA256).Hash.ToUpper()
|
||||
if ($h -ne $asset.sha256) {
|
||||
Remove-Item $dest -Force
|
||||
Write-Fail "$($asset.name): sha256 mismatch ($h) - expected $($asset.sha256)"
|
||||
return
|
||||
}
|
||||
Write-OK "$($asset.name) downloaded and checksum verified"
|
||||
}
|
||||
Set-Content -Path "$dest.sha256" -Value $asset.sha256 -NoNewline
|
||||
}
|
||||
|
||||
function Ensure-Vendor {
|
||||
Write-Step "Vendor assets (pinned in vendor-manifest.json)"
|
||||
New-Item -ItemType Directory -Force -Path $vendorDir | Out-Null
|
||||
foreach ($a in $manifest.assets) {
|
||||
Fetch-Asset $a
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------- Verify -----
|
||||
function Assert-BuildReady {
|
||||
Write-Step "Verification"
|
||||
$go = Get-GoVersion
|
||||
if ($go) { Write-OK "go $go" } elseif (-not $SkipGo) { Write-Fail 'go not found' }
|
||||
|
||||
$iscc = Find-Iscc
|
||||
if ($iscc) { Write-OK "ISCC $iscc" } elseif (-not $SkipInno) { Write-Fail 'ISCC not found' }
|
||||
|
||||
foreach ($a in $manifest.assets) {
|
||||
$dest = Join-Path $vendorDir $a.name
|
||||
if (Test-Path $dest) {
|
||||
$h = (Get-FileHash $dest -Algorithm SHA256).Hash.ToUpper()
|
||||
if ($h -eq $a.sha256) { Write-OK "$($a.name) verified" }
|
||||
else { Write-Fail "$($a.name) checksum mismatch" }
|
||||
} elseif (-not $SkipVendor) {
|
||||
Write-Fail "$($a.name) missing"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:anyFailed) {
|
||||
if ($CI) { throw 'Build environment setup failed' }
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Build environment ready." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- Build ----
|
||||
function Invoke-Build {
|
||||
Write-Step "Building agent, tray, helper (windows amd64+arm64)"
|
||||
$dist = Join-Path $RepoRoot 'dist'
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
$flags = '-s -w'
|
||||
# The tray and helper are GUI-subsystem binaries: no console window pops up
|
||||
# when the installer starts the tray or the service spawns the helper.
|
||||
$guiFlags = '-s -w -H=windowsgui'
|
||||
foreach ($arch in @('amd64','arm64')) {
|
||||
$env:GOOS='windows'; $env:GOARCH=$arch; $env:CGO_ENABLED='0'
|
||||
go build "-ldflags=$flags" -o (Join-Path $dist "theta-agent-windows-$arch.exe") $RepoRoot
|
||||
if ($LASTEXITCODE -ne 0) { Write-Fail "build agent windows/$arch failed"; return }
|
||||
go build "-ldflags=$guiFlags" -o (Join-Path $dist "theta-agent-tray-windows-$arch.exe") (Join-Path $RepoRoot 'cmd\theta-agent-tray')
|
||||
if ($LASTEXITCODE -ne 0) { Write-Fail "build tray windows/$arch failed"; return }
|
||||
go build "-ldflags=$guiFlags" -o (Join-Path $dist "theta-agent-helper-windows-$arch.exe") (Join-Path $RepoRoot 'cmd\theta-agent-helper')
|
||||
if ($LASTEXITCODE -ne 0) { Write-Fail "build helper windows/$arch failed"; return }
|
||||
}
|
||||
Remove-Item Env:GOOS,Env:GOARCH,Env:CGO_ENABLED -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Step "Running go test ./..."
|
||||
Push-Location $RepoRoot
|
||||
go test ./...
|
||||
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Fail 'go test failed'; return }
|
||||
Pop-Location
|
||||
|
||||
$iscc = Find-Iscc
|
||||
if (-not $iscc) { Write-Fail 'ISCC not found; cannot build installer'; return }
|
||||
Write-Step "Compiling installer with ISCC"
|
||||
& $iscc (Join-Path $RepoRoot 'installer\windows\installer.iss')
|
||||
if ($LASTEXITCODE -ne 0) { Write-Fail 'ISCC compile failed' }
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- Main -----
|
||||
New-Item -ItemType Directory -Force -Path $ToolDir | Out-Null
|
||||
|
||||
if (-not $SkipGo) { Install-Go }
|
||||
if (-not $SkipInno) { $null = Install-Inno }
|
||||
if (-not $SkipVendor) { Ensure-Vendor }
|
||||
|
||||
Assert-BuildReady
|
||||
if ($Build) { Invoke-Build }
|
||||
|
||||
if ($script:anyFailed) {
|
||||
if ($CI) { throw 'Setup failed' }
|
||||
exit 1
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Secrets engine (DESIGN.md §5). The agent renders local templates that embed
|
||||
// OpenBao secrets, e.g.:
|
||||
//
|
||||
// # /etc/theta/templates/db.env.tpl
|
||||
// DB_USER="{{ bao "secret/data/nodes/node-42/db#username" }}"
|
||||
// DB_PASS="{{ bao "secret/data/nodes/node-42/db#password" }}"
|
||||
//
|
||||
// The agent parses the `{{ bao "path#key" }}` placeholders, fetches the secret
|
||||
// values from the SSO (which holds the OpenBao access), renders each template to
|
||||
// its target atomically (0600), and runs the configured reload. The agent never
|
||||
// holds a Vault token.
|
||||
|
||||
var baoRe = regexp.MustCompile(`\{\{\s*bao\s+"([^"]+)"\s*\}\}`)
|
||||
|
||||
// renderSecrets renders every configured secret template. Called on a
|
||||
// `render_secrets` command (signed) and on boot.
|
||||
func renderSecrets(cfg *Config, exec Executor) error {
|
||||
if len(cfg.Secrets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect the unique secret paths referenced across all templates.
|
||||
pathSet := map[string]bool{}
|
||||
var paths []string
|
||||
for _, t := range cfg.Secrets {
|
||||
content, err := os.ReadFile(t.Template)
|
||||
if err != nil {
|
||||
log.Printf("Secrets: cannot read template %s: %v", t.Template, err)
|
||||
continue
|
||||
}
|
||||
for _, m := range baoRe.FindAllStringSubmatch(string(content), -1) {
|
||||
path := refPath(m[1])
|
||||
if !pathSet[path] {
|
||||
pathSet[path] = true
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
secrets, err := fetchSecrets(cfg, paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, t := range cfg.Secrets {
|
||||
if err := renderOne(t, secrets, exec); err != nil {
|
||||
log.Printf("Secrets: render %s failed: %v", t.Template, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderOne(t SecretTarget, secrets map[string]map[string]interface{}, exec Executor) error {
|
||||
content, err := os.ReadFile(t.Template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := baoRe.ReplaceAllStringFunc(string(content), func(match string) string {
|
||||
ref := baoRe.FindStringSubmatch(match)[1]
|
||||
path, key := refPath(ref), refKey(ref)
|
||||
if v, ok := secrets[path][key]; ok {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// Atomic write: temp file in the target's directory, then rename. 0600 — the
|
||||
// rendered file holds secrets.
|
||||
tmp, err := os.CreateTemp(filepath.Dir(t.Target), ".theta-secret-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := io.WriteString(tmp, out); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
tmp.Close()
|
||||
os.Chmod(tmpName, 0600)
|
||||
if err := os.Rename(tmpName, t.Target); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
|
||||
if t.Reload != "" {
|
||||
if _, err := exec.Execute("sh", "-c", t.Reload); err != nil {
|
||||
log.Printf("Secrets: reload %q failed: %v", t.Reload, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchSecrets asks the SSO for the given node-scoped secret paths.
|
||||
func fetchSecrets(cfg *Config, paths []string) (map[string]map[string]interface{}, error) {
|
||||
base := strings.Replace(cfg.ServerURL, "wss://", "https://", 1)
|
||||
base = strings.Replace(base, "ws://", "http://", 1)
|
||||
url := strings.TrimRight(base, "/") + "/api/v1/agent/secrets"
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"paths": paths})
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Credential())
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("secrets fetch failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Secrets map[string]map[string]interface{} `json:"secrets"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Secrets, nil
|
||||
}
|
||||
|
||||
// refPath returns the secret path from a `path#key` reference.
|
||||
func refPath(ref string) string {
|
||||
path := ref
|
||||
if i := strings.Index(ref, "#"); i >= 0 {
|
||||
path = ref[:i]
|
||||
}
|
||||
if strings.HasPrefix(path, "resource/") {
|
||||
return "secret/data/resources/" + strings.TrimPrefix(path, "resource/") + "/conf"
|
||||
}
|
||||
if strings.HasPrefix(path, "resources/") {
|
||||
return "secret/data/resources/" + strings.TrimPrefix(path, "resources/") + "/conf"
|
||||
}
|
||||
if !strings.HasPrefix(path, "secret/") {
|
||||
return "secret/data/resources/" + path + "/conf"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// refKey returns the key from a `path#key` reference.
|
||||
func refKey(ref string) string {
|
||||
if i := strings.Index(ref, "#"); i >= 0 {
|
||||
return ref[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRenderSecrets verifies the agent parses `{{ bao "path#key" }}` placeholders,
|
||||
// fetches the secrets from the SSO, renders the template to its target
|
||||
// atomically, and runs the reload.
|
||||
func TestRenderSecrets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tpl := filepath.Join(dir, "db.env.tpl")
|
||||
target := filepath.Join(dir, "db.env")
|
||||
os.WriteFile(tpl, []byte("DB_USER=\"{{ bao \"secret/data/nodes/n1/db#username\" }}\"\nDB_PASS=\"{{ bao \"secret/data/nodes/n1/db#password\" }}\"\n"), 0600)
|
||||
|
||||
// Fake SSO secrets endpoint.
|
||||
var gotPaths []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/agent/secrets" {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
var req struct{ Paths []string `json:"paths"` }
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
gotPaths = req.Paths
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"secrets": map[string]interface{}{
|
||||
"secret/data/nodes/n1/db": map[string]interface{}{
|
||||
"username": "alice",
|
||||
"password": "s3cret",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &Config{
|
||||
ServerURL: srv.URL,
|
||||
AuthToken: "tok",
|
||||
Secrets: []SecretTarget{
|
||||
{Template: tpl, Target: target, Reload: ""},
|
||||
},
|
||||
}
|
||||
|
||||
exec := &MockExecutor{}
|
||||
if err := renderSecrets(cfg, exec); err != nil {
|
||||
t.Fatalf("renderSecrets: %v", err)
|
||||
}
|
||||
|
||||
// The requested path should be the one in the template.
|
||||
if len(gotPaths) != 1 || gotPaths[0] != "secret/data/nodes/n1/db" {
|
||||
t.Fatalf("expected to request secret/data/nodes/n1/db, got %v", gotPaths)
|
||||
}
|
||||
|
||||
// The target should be rendered with the secret values.
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("read target: %v", err)
|
||||
}
|
||||
expected := "DB_USER=\"alice\"\nDB_PASS=\"s3cret\"\n"
|
||||
if string(content) != expected {
|
||||
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
|
||||
}
|
||||
|
||||
// The target should be 0600 (holds secrets). Windows has no POSIX modes and
|
||||
// reports 0666 regardless; the intent there is covered by the ACLs the
|
||||
// installer sets on the target directory.
|
||||
if runtime.GOOS != "windows" {
|
||||
info, _ := os.Stat(target)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSecretsReload verifies the reload command runs after rendering.
|
||||
func TestRenderSecretsReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tpl := filepath.Join(dir, "app.tpl")
|
||||
target := filepath.Join(dir, "app.conf")
|
||||
os.WriteFile(tpl, []byte("KEY={{ bao \"secret/data/nodes/n1/app#key\" }}"), 0600)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"secrets": map[string]interface{}{
|
||||
"secret/data/nodes/n1/app": map[string]interface{}{"key": "v"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &Config{
|
||||
ServerURL: srv.URL,
|
||||
AuthToken: "tok",
|
||||
Secrets: []SecretTarget{
|
||||
{Template: tpl, Target: target, Reload: "systemctl reload app"},
|
||||
},
|
||||
}
|
||||
exec := &MockExecutor{}
|
||||
if err := renderSecrets(cfg, exec); err != nil {
|
||||
t.Fatalf("renderSecrets: %v", err)
|
||||
}
|
||||
if len(exec.ExecutedCommands) != 1 {
|
||||
t.Fatalf("expected 1 reload command, got %v", exec.ExecutedCommands)
|
||||
}
|
||||
cmd := exec.ExecutedCommands[0]
|
||||
if len(cmd) != 3 || cmd[0] != "sh" || cmd[2] != "systemctl reload app" {
|
||||
t.Fatalf("expected reload 'sh -c systemctl reload app', got %v", cmd)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+504
-52
@@ -3,8 +3,13 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,26 +20,413 @@ import (
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
)
|
||||
|
||||
type CPUDetails struct {
|
||||
Model string `json:"model"`
|
||||
Cores int `json:"cores"`
|
||||
Threads int `json:"threads"`
|
||||
MHz float64 `json:"mhz"`
|
||||
}
|
||||
|
||||
type RAMDetails struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
BuffersCacheBytes uint64 `json:"buffers_cache_bytes"`
|
||||
FreeBytes uint64 `json:"free_bytes"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
BuffersCachePercent float64 `json:"buffers_cache_percent"`
|
||||
FreePercent float64 `json:"free_percent"`
|
||||
}
|
||||
|
||||
type DiskItem struct {
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Device string `json:"device"`
|
||||
FSType string `json:"fstype"`
|
||||
DriveType string `json:"drivetype"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
FreeBytes uint64 `json:"free_bytes"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
type LoggedUser struct {
|
||||
User string `json:"user"`
|
||||
Terminal string `json:"terminal"`
|
||||
Host string `json:"host"`
|
||||
Started int64 `json:"started"`
|
||||
}
|
||||
|
||||
type HostDetails struct {
|
||||
StaticHostname string `json:"static_hostname"`
|
||||
IconName string `json:"icon_name"`
|
||||
Chassis string `json:"chassis"`
|
||||
MachineID string `json:"machine_id"`
|
||||
BootID string `json:"boot_id"`
|
||||
OS string `json:"os"`
|
||||
Kernel string `json:"kernel"`
|
||||
Arch string `json:"arch"`
|
||||
HardwareVendor string `json:"hardware_vendor"`
|
||||
HardwareModel string `json:"hardware_model"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
FirmwareDate string `json:"firmware_date"`
|
||||
}
|
||||
|
||||
type DiscoveryData struct {
|
||||
Hostname string `json:"hostname"`
|
||||
IPs []string `json:"ip_addresses"`
|
||||
OS string `json:"os"`
|
||||
Kernel string `json:"kernel"`
|
||||
CPUModel string `json:"cpu"`
|
||||
RAMTotalGB float64 `json:"ram_total_gb"`
|
||||
DiskTotalGB float64 `json:"disk_total_gb"`
|
||||
Location string `json:"location"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPs []string `json:"ip_addresses"`
|
||||
PublicIP string `json:"public_ip"`
|
||||
OS string `json:"os"`
|
||||
Kernel string `json:"kernel"`
|
||||
CPUModel string `json:"cpu"`
|
||||
CPUDetails CPUDetails `json:"cpu_details"`
|
||||
RAMTotalGB float64 `json:"ram_total_gb"`
|
||||
RAMDetails RAMDetails `json:"ram_details"`
|
||||
DiskTotalGB float64 `json:"disk_total_gb"`
|
||||
Disks []DiskItem `json:"disks"`
|
||||
LoggedUsers []LoggedUser `json:"logged_users"`
|
||||
HostDetails HostDetails `json:"host_details"`
|
||||
Version string `json:"version"`
|
||||
Location string `json:"location"`
|
||||
Capabilities map[string]interface{} `json:"capabilities"`
|
||||
}
|
||||
|
||||
type TelemetryData struct {
|
||||
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
||||
RAMUsagePercent float64 `json:"ram_usage_percent"`
|
||||
DiskUsagePercent float64 `json:"disk_usage_percent"`
|
||||
ZFSHealth string `json:"zfs_health,omitempty"`
|
||||
GPUUsage float64 `json:"gpu_usage_percent,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
||||
CPUDetails CPUDetails `json:"cpu_details"`
|
||||
RAMUsagePercent float64 `json:"ram_usage_percent"`
|
||||
RAMDetails RAMDetails `json:"ram_details"`
|
||||
DiskUsagePercent float64 `json:"disk_usage_percent"`
|
||||
Disks []DiskItem `json:"disks"`
|
||||
LoggedUsers []LoggedUser `json:"logged_users"`
|
||||
HostDetails HostDetails `json:"host_details"`
|
||||
Version string `json:"version"`
|
||||
ZFSHealth string `json:"zfs_health,omitempty"`
|
||||
GPUUsage float64 `json:"gpu_usage_percent,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
func getPublicIP() string {
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
endpoints := []string{
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
}
|
||||
for _, ep := range endpoints {
|
||||
resp, err := client.Get(ep)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err == nil {
|
||||
ip := strings.TrimSpace(string(body))
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func collectCPUDetails() CPUDetails {
|
||||
cpuInfo, _ := cpu.Info()
|
||||
model := "Unknown"
|
||||
cores := 0
|
||||
mhz := 0.0
|
||||
if len(cpuInfo) > 0 {
|
||||
model = cpuInfo[0].ModelName
|
||||
if model == "" || strings.TrimSpace(model) == "154" || len(model) < 4 {
|
||||
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "model name") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
model = strings.TrimSpace(parts[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if model == "" {
|
||||
model = cpuInfo[0].Model
|
||||
}
|
||||
cores = int(cpuInfo[0].Cores)
|
||||
mhz = cpuInfo[0].Mhz
|
||||
}
|
||||
threads := runtime.NumCPU()
|
||||
if t, err := cpu.Counts(true); err == nil && t > 0 {
|
||||
threads = t
|
||||
}
|
||||
if cores <= 0 {
|
||||
if c, err := cpu.Counts(false); err == nil && c > 0 {
|
||||
cores = c
|
||||
} else {
|
||||
cores = threads
|
||||
}
|
||||
}
|
||||
return CPUDetails{
|
||||
Model: model,
|
||||
Cores: cores,
|
||||
Threads: threads,
|
||||
MHz: mhz,
|
||||
}
|
||||
}
|
||||
|
||||
func collectRAMDetails() RAMDetails {
|
||||
vm, err := mem.VirtualMemory()
|
||||
if err != nil || vm == nil {
|
||||
return RAMDetails{}
|
||||
}
|
||||
bufCache := vm.Buffers + vm.Cached
|
||||
total := float64(vm.Total)
|
||||
usedPct := 0.0
|
||||
bufPct := 0.0
|
||||
freePct := 0.0
|
||||
if total > 0 {
|
||||
usedPct = (float64(vm.Used) / total) * 100.0
|
||||
bufPct = (float64(bufCache) / total) * 100.0
|
||||
freePct = (float64(vm.Free) / total) * 100.0
|
||||
}
|
||||
return RAMDetails{
|
||||
TotalBytes: vm.Total,
|
||||
UsedBytes: vm.Used,
|
||||
BuffersCacheBytes: bufCache,
|
||||
FreeBytes: vm.Free,
|
||||
UsedPercent: usedPct,
|
||||
BuffersCachePercent: bufPct,
|
||||
FreePercent: freePct,
|
||||
}
|
||||
}
|
||||
|
||||
func getDriveType(device string) string {
|
||||
devName := filepath.Base(device)
|
||||
devName = strings.TrimRight(devName, "0123456789p")
|
||||
if strings.HasPrefix(devName, "nvme") {
|
||||
return "NVMe"
|
||||
}
|
||||
rotPath := filepath.Join("/sys/block", devName, "queue/rotational")
|
||||
data, err := os.ReadFile(rotPath)
|
||||
if err == nil {
|
||||
val := strings.TrimSpace(string(data))
|
||||
if val == "0" {
|
||||
return "SSD"
|
||||
} else if val == "1" {
|
||||
return "HDD"
|
||||
}
|
||||
}
|
||||
return "SSD/HDD"
|
||||
}
|
||||
|
||||
func collectLoggedUsers() []LoggedUser {
|
||||
var list []LoggedUser
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// 1. Try loginctl list-sessions --no-legend (systemd logind)
|
||||
exec := SystemExecutor{}
|
||||
if out, err := exec.Execute("loginctl", "list-sessions", "--no-legend"); err == nil && len(out) > 0 {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
// Format: SESSION UID USER SEAT TTY STATE IDLE SINCE
|
||||
// e.g. c2 1000 william seat0 tty7 active no -
|
||||
if len(fields) >= 3 {
|
||||
user := fields[2]
|
||||
term := ""
|
||||
if len(fields) >= 5 && fields[4] != "-" {
|
||||
term = fields[4]
|
||||
}
|
||||
key := fmt.Sprintf("%s@%s", user, term)
|
||||
if !seen[key] && user != "" {
|
||||
seen[key] = true
|
||||
list = append(list, LoggedUser{
|
||||
User: user,
|
||||
Terminal: term,
|
||||
Host: "localhost",
|
||||
Started: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to gopsutil / who if loginctl returned nothing
|
||||
if len(list) == 0 {
|
||||
users, err := host.Users()
|
||||
if err == nil {
|
||||
for _, u := range users {
|
||||
key := fmt.Sprintf("%s@%s:%s", u.User, u.Terminal, u.Host)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
list = append(list, LoggedUser{
|
||||
User: u.User,
|
||||
Terminal: u.Terminal,
|
||||
Host: u.Host,
|
||||
Started: int64(u.Started),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
out, err := exec.Execute("who")
|
||||
if err == nil && len(out) > 0 {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
user := fields[0]
|
||||
term := fields[1]
|
||||
hostStr := ""
|
||||
if len(fields) >= 5 {
|
||||
hostStr = strings.Trim(fields[4], "()")
|
||||
}
|
||||
key := fmt.Sprintf("%s@%s:%s", user, term, hostStr)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
list = append(list, LoggedUser{
|
||||
User: user,
|
||||
Terminal: term,
|
||||
Host: hostStr,
|
||||
Started: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func collectDiskItems() []DiskItem {
|
||||
var items []DiskItem
|
||||
partitions, err := disk.Partitions(true)
|
||||
if err != nil || len(partitions) == 0 {
|
||||
d, err2 := disk.Usage("/")
|
||||
if err2 == nil {
|
||||
items = append(items, DiskItem{
|
||||
Mountpoint: "/",
|
||||
Device: d.Path,
|
||||
FSType: d.Fstype,
|
||||
DriveType: getDriveType(d.Path),
|
||||
TotalBytes: d.Total,
|
||||
UsedBytes: d.Used,
|
||||
FreeBytes: d.Free,
|
||||
UsagePercent: d.UsedPercent,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, p := range partitions {
|
||||
if !strings.HasPrefix(p.Device, "/dev/") || strings.HasPrefix(p.Device, "/dev/loop") {
|
||||
continue
|
||||
}
|
||||
if seen[p.Mountpoint] {
|
||||
continue
|
||||
}
|
||||
seen[p.Mountpoint] = true
|
||||
u, err := disk.Usage(p.Mountpoint)
|
||||
if err != nil || u.Total == 0 {
|
||||
continue
|
||||
}
|
||||
fstype := p.Fstype
|
||||
if fstype == "" {
|
||||
fstype = u.Fstype
|
||||
}
|
||||
items = append(items, DiskItem{
|
||||
Mountpoint: p.Mountpoint,
|
||||
Device: p.Device,
|
||||
FSType: fstype,
|
||||
DriveType: getDriveType(p.Device),
|
||||
TotalBytes: u.Total,
|
||||
UsedBytes: u.Used,
|
||||
FreeBytes: u.Free,
|
||||
UsagePercent: u.UsedPercent,
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
d, err2 := disk.Usage("/")
|
||||
if err2 == nil {
|
||||
items = append(items, DiskItem{
|
||||
Mountpoint: "/",
|
||||
Device: d.Path,
|
||||
FSType: d.Fstype,
|
||||
DriveType: getDriveType(d.Path),
|
||||
TotalBytes: d.Total,
|
||||
UsedBytes: d.Used,
|
||||
FreeBytes: d.Free,
|
||||
UsagePercent: d.UsedPercent,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func collectHostDetails() HostDetails {
|
||||
details := HostDetails{}
|
||||
exec := SystemExecutor{}
|
||||
out, err := exec.Execute("hostnamectl")
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
switch key {
|
||||
case "Static hostname":
|
||||
details.StaticHostname = val
|
||||
case "Icon name":
|
||||
details.IconName = val
|
||||
case "Chassis":
|
||||
details.Chassis = val
|
||||
case "Machine ID":
|
||||
details.MachineID = val
|
||||
case "Boot ID":
|
||||
details.BootID = val
|
||||
case "Operating System":
|
||||
details.OS = val
|
||||
case "Kernel":
|
||||
details.Kernel = val
|
||||
case "Architecture":
|
||||
details.Arch = val
|
||||
case "Hardware Vendor":
|
||||
details.HardwareVendor = val
|
||||
case "Hardware Model":
|
||||
details.HardwareModel = val
|
||||
case "Firmware Version":
|
||||
details.FirmwareVersion = val
|
||||
case "Firmware Date":
|
||||
details.FirmwareDate = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if details.HardwareVendor == "" {
|
||||
if d, err := os.ReadFile("/sys/class/dmi/id/sys_vendor"); err == nil {
|
||||
details.HardwareVendor = strings.TrimSpace(string(d))
|
||||
}
|
||||
}
|
||||
if details.HardwareModel == "" {
|
||||
if d, err := os.ReadFile("/sys/class/dmi/id/product_name"); err == nil {
|
||||
details.HardwareModel = strings.TrimSpace(string(d))
|
||||
}
|
||||
}
|
||||
if details.FirmwareVersion == "" {
|
||||
if d, err := os.ReadFile("/sys/class/dmi/id/bios_version"); err == nil {
|
||||
details.FirmwareVersion = strings.TrimSpace(string(d))
|
||||
}
|
||||
}
|
||||
if details.FirmwareDate == "" {
|
||||
if d, err := os.ReadFile("/sys/class/dmi/id/bios_date"); err == nil {
|
||||
details.FirmwareDate = strings.TrimSpace(string(d))
|
||||
}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
const AgentVersion = "v2.1.3"
|
||||
|
||||
// CollectDiscoveryData gathers static host information.
|
||||
func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
||||
h, _ := host.Info()
|
||||
@@ -49,45 +441,98 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
||||
}
|
||||
}
|
||||
|
||||
vm, _ := mem.VirtualMemory()
|
||||
d, _ := disk.Usage("/")
|
||||
vm := collectRAMDetails()
|
||||
disks := collectDiskItems()
|
||||
cpuDet := collectCPUDetails()
|
||||
loggedUsers := collectLoggedUsers()
|
||||
|
||||
cpuInfo, _ := cpu.Info()
|
||||
cpuModel := "Unknown"
|
||||
if len(cpuInfo) > 0 {
|
||||
cpuModel = cpuInfo[0].Model
|
||||
pubIP := ""
|
||||
if cfg.DetectPublicIP() {
|
||||
pubIP = getPublicIP()
|
||||
}
|
||||
|
||||
diskTotalGB := 0.0
|
||||
for _, d := range disks {
|
||||
if d.Mountpoint == "/" {
|
||||
diskTotalGB = float64(d.TotalBytes) / (1024 * 1024 * 1024)
|
||||
break
|
||||
}
|
||||
}
|
||||
if diskTotalGB == 0 && len(disks) > 0 {
|
||||
diskTotalGB = float64(disks[0].TotalBytes) / (1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
hostDet := collectHostDetails()
|
||||
|
||||
return DiscoveryData{
|
||||
Hostname: h.Hostname,
|
||||
IPs: ips,
|
||||
OS: fmt.Sprintf("%s %s", h.OS, h.Platform),
|
||||
Kernel: h.KernelVersion,
|
||||
CPUModel: cpuModel,
|
||||
RAMTotalGB: float64(vm.Total) / (1024 * 1024 * 1024),
|
||||
DiskTotalGB: float64(d.Total) / (1024 * 1024 * 1024),
|
||||
Location: cfg.Location,
|
||||
Hostname: h.Hostname,
|
||||
IPs: ips,
|
||||
PublicIP: pubIP,
|
||||
OS: fmt.Sprintf("%s %s", h.OS, h.Platform),
|
||||
Kernel: h.KernelVersion,
|
||||
CPUModel: cpuDet.Model,
|
||||
CPUDetails: cpuDet,
|
||||
RAMTotalGB: float64(vm.TotalBytes) / (1024 * 1024 * 1024),
|
||||
RAMDetails: vm,
|
||||
DiskTotalGB: diskTotalGB,
|
||||
Disks: disks,
|
||||
LoggedUsers: loggedUsers,
|
||||
HostDetails: hostDet,
|
||||
Version: AgentVersion,
|
||||
Location: cfg.Location,
|
||||
Capabilities: map[string]interface{}{
|
||||
"telemetry": cfg.Capabilities.Telemetry,
|
||||
"configure_ldap": cfg.Capabilities.ConfigureLDAP,
|
||||
"ldap_tunnel": cfg.Capabilities.LdapTunnel,
|
||||
"secrets": cfg.Capabilities.Secrets,
|
||||
"iam": cfg.Capabilities.IAM,
|
||||
"reboot": cfg.Capabilities.Reboot,
|
||||
"shutdown": true,
|
||||
"desktop_controls": true,
|
||||
"service_control": cfg.Capabilities.ServiceControl,
|
||||
"arbitrary_bash": cfg.Capabilities.ArbitraryBash,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CollectTelemetryData gathers real-time performance metrics including ZFS and GPU.
|
||||
func CollectTelemetryData(exec Executor) TelemetryData {
|
||||
cpuPerc, _ := cpu.Percent(time.Second, false)
|
||||
vm, _ := mem.VirtualMemory()
|
||||
d, _ := disk.Usage("/")
|
||||
vm := collectRAMDetails()
|
||||
disks := collectDiskItems()
|
||||
cpuDet := collectCPUDetails()
|
||||
loggedUsers := collectLoggedUsers()
|
||||
hostDet := collectHostDetails()
|
||||
|
||||
cpuVal := 0.0
|
||||
if len(cpuPerc) > 0 {
|
||||
cpuVal = cpuPerc[0]
|
||||
}
|
||||
|
||||
diskVal := 0.0
|
||||
for _, d := range disks {
|
||||
if d.Mountpoint == "/" {
|
||||
diskVal = d.UsagePercent
|
||||
break
|
||||
}
|
||||
}
|
||||
if diskVal == 0 && len(disks) > 0 {
|
||||
diskVal = disks[0].UsagePercent
|
||||
}
|
||||
|
||||
return TelemetryData{
|
||||
CPUUsagePercent: cpuVal,
|
||||
CPUDetails: cpuDet,
|
||||
RAMUsagePercent: vm.UsedPercent,
|
||||
DiskUsagePercent: d.UsedPercent,
|
||||
ZFSHealth: collectZFSHealth(exec),
|
||||
GPUUsage: collectGPUUsage(exec),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
RAMDetails: vm,
|
||||
DiskUsagePercent: diskVal,
|
||||
Disks: disks,
|
||||
LoggedUsers: loggedUsers,
|
||||
HostDetails: hostDet,
|
||||
Version: AgentVersion,
|
||||
ZFSHealth: collectZFSHealth(exec),
|
||||
GPUUsage: collectGPUUsage(exec),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +562,9 @@ func collectGPUUsage(exec Executor) float64 {
|
||||
func StartTelemetryLoop(c MessageWriter, cm *ConfigManager, exec Executor, stopCh <-chan struct{}) {
|
||||
cfg := cm.Get()
|
||||
|
||||
// 1. Immediate Discovery Push
|
||||
// 1. Immediate Discovery Push & Initial Telemetry Frame
|
||||
pushDiscovery(c, cfg)
|
||||
pushTelemetry(c, exec)
|
||||
|
||||
// If telemetry capability is disabled in agent.yml, return early after discovery
|
||||
if !cfg.Capabilities.Telemetry {
|
||||
@@ -149,27 +595,33 @@ func StartTelemetryLoop(c MessageWriter, cm *ConfigManager, exec Executor, stopC
|
||||
lastIPs = currentIPs
|
||||
}
|
||||
|
||||
telemetry := CollectTelemetryData(exec)
|
||||
payload, _ := json.Marshal(WSMessage{
|
||||
Type: "telemetry",
|
||||
Payload: map[string]interface{}{
|
||||
"cpu_usage_percent": telemetry.CPUUsagePercent,
|
||||
"ram_usage_percent": telemetry.RAMUsagePercent,
|
||||
"disk_usage_percent": telemetry.DiskUsagePercent,
|
||||
"zfs_health": telemetry.ZFSHealth,
|
||||
"gpu_usage_percent": telemetry.GPUUsage,
|
||||
"timestamp": telemetry.Timestamp,
|
||||
},
|
||||
})
|
||||
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
log.Printf("Failed to stream telemetry: %v", err)
|
||||
return
|
||||
}
|
||||
pushTelemetry(c, exec)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func pushTelemetry(c MessageWriter, exec Executor) {
|
||||
telemetry := CollectTelemetryData(exec)
|
||||
payload, _ := json.Marshal(WSMessage{
|
||||
Type: "telemetry",
|
||||
Payload: map[string]interface{}{
|
||||
"cpu_usage_percent": telemetry.CPUUsagePercent,
|
||||
"cpu_details": telemetry.CPUDetails,
|
||||
"ram_usage_percent": telemetry.RAMUsagePercent,
|
||||
"ram_details": telemetry.RAMDetails,
|
||||
"disk_usage_percent": telemetry.DiskUsagePercent,
|
||||
"disks": telemetry.Disks,
|
||||
"logged_users": telemetry.LoggedUsers,
|
||||
"host_details": telemetry.HostDetails,
|
||||
"zfs_health": telemetry.ZFSHealth,
|
||||
"gpu_usage_percent": telemetry.GPUUsage,
|
||||
"timestamp": telemetry.Timestamp,
|
||||
},
|
||||
})
|
||||
_ = c.WriteMessage(websocket.TextMessage, payload)
|
||||
}
|
||||
|
||||
func collectIPs() []string {
|
||||
var ips []string
|
||||
addrs, _ := net.InterfaceAddrs()
|
||||
@@ -211,6 +663,6 @@ func pushDiscovery(c MessageWriter, cfg *Config) {
|
||||
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
log.Printf("Failed to send discovery data: %v", err)
|
||||
} else {
|
||||
log.Println("Discovery data pushed to SSO Manager.")
|
||||
log.Println("Discovery data pushed to Theta Directory.")
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+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
|
||||
}
|
||||
+224
-52
@@ -12,7 +12,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -147,7 +146,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
||||
// credential every 5s just floods the SSO and its audit log
|
||||
// forever, so back off hard and say plainly what is wrong.
|
||||
if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
|
||||
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the SSO Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
|
||||
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the Theta Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
|
||||
time.Sleep(authRetryInterval)
|
||||
continue
|
||||
}
|
||||
@@ -156,12 +155,31 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println("Successfully connected to SSO Manager.")
|
||||
log.Println("Successfully connected to Theta Directory.")
|
||||
wsConnected.Store(true)
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
|
||||
// All outbound writes go through the safe writer: gorilla allows only one
|
||||
// concurrent writer, and telemetry, heartbeat, the LDAP tunnel and command
|
||||
// responses all write to the same socket.
|
||||
sw := &safeWriter{c: c}
|
||||
|
||||
// Local LDAP byte-pump tunnel (DESIGN.md §4). The agent never parses LDAP;
|
||||
// it forwards raw bytes to the SSO and writes the responses back.
|
||||
tunnel := newLdapTunnel(func(msg WSMessage) error {
|
||||
return sendTunnelMessage(sw, msg)
|
||||
})
|
||||
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
|
||||
socketPath := cfg.LdapSocket
|
||||
if socketPath == "" {
|
||||
socketPath = defaultLdapSocketPath()
|
||||
}
|
||||
go tunnel.start(socketPath, stopCh)
|
||||
}
|
||||
|
||||
// Start telemetry and discovery with stopCh lifecycle control
|
||||
StartTelemetryLoop(c, cm, exec, stopCh)
|
||||
StartTelemetryLoop(sw, cm, exec, stopCh)
|
||||
|
||||
// Heartbeat loop
|
||||
go func() {
|
||||
@@ -174,7 +192,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
||||
case <-ticker.C:
|
||||
hb := WSMessage{Type: "heartbeat", Payload: map[string]interface{}{"timestamp": time.Now().Format(time.RFC3339)}}
|
||||
payload, _ := json.Marshal(hb)
|
||||
if err := c.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
if err := sw.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -195,7 +213,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
||||
// than at Dial.
|
||||
if websocket.IsCloseError(err, closeUnauthorized, closeRevoked, closeTokenRotated) {
|
||||
authRejected = true
|
||||
log.Printf("Server closed the connection: %v. This agent's token is not valid for that SSO — re-enroll it and update agent.yml.", err)
|
||||
log.Printf("Server closed the connection: %v. This agent's token is not valid for that Theta Directory — re-enroll it and update agent.yml.", err)
|
||||
} else {
|
||||
log.Println("WebSocket read error:", err)
|
||||
}
|
||||
@@ -208,10 +226,11 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
||||
continue
|
||||
}
|
||||
|
||||
handleCommand(cm, msg, c, exec)
|
||||
handleCommand(cm, msg, sw, exec, tunnel)
|
||||
}
|
||||
|
||||
// Cleanup on disconnect
|
||||
wsConnected.Store(false)
|
||||
close(stopCh)
|
||||
c.Close()
|
||||
|
||||
@@ -226,11 +245,13 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Executor) {
|
||||
func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Executor, tunnel *ldapTunnel) {
|
||||
cfg := cm.Get()
|
||||
// Don't log the server's fire-and-forget heartbeat ack — it arrives every
|
||||
// 60s and is not a command to act on; logging it is pure per-minute noise.
|
||||
if msg.Type != "heartbeat_ack" {
|
||||
// The LDAP tunnel is high-frequency (every chunk of a bind/search), so it is
|
||||
// not logged either.
|
||||
if msg.Type != "heartbeat_ack" && msg.Type != "ldap_tunnel" {
|
||||
log.Printf("Received command: %s", msg.Type)
|
||||
}
|
||||
|
||||
@@ -240,6 +261,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "ldap_tunnel":
|
||||
// SSO→agent direction of the LDAP byte pump: write the response bytes to
|
||||
// the matching local socket.
|
||||
if tunnel != nil {
|
||||
tunnel.handleMessage(msg.Payload)
|
||||
}
|
||||
case "reload_config":
|
||||
if err := cm.Reload(); err != nil {
|
||||
log.Printf("Reload failed: %v", err)
|
||||
@@ -263,10 +290,13 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
linesCount := 100
|
||||
if l, ok := msg.Payload["lines"].(float64); ok && l > 0 {
|
||||
linesCount = int(l)
|
||||
if linesCount > 2000 {
|
||||
linesCount = 2000
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
|
||||
out, err := exec.Execute("journalctl", "-u", serviceName, "-n", fmt.Sprintf("%d", linesCount), "--no-pager")
|
||||
out, err := defaultPlatformOps.FetchLogs(serviceName, linesCount)
|
||||
if err != nil {
|
||||
log.Printf("Log fetch failed: %v", err)
|
||||
sendResponse("error", "failed to fetch logs")
|
||||
@@ -298,20 +328,16 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
|
||||
log.Printf("Updating binary from %s...", urlStr)
|
||||
if err := downloadAndUpdateBinary(urlStr, checksum); err != nil {
|
||||
if err := defaultPlatformOps.ApplyUpdate(urlStr, checksum); err != nil {
|
||||
log.Printf("Update failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("update failed: %v", err))
|
||||
return
|
||||
}
|
||||
sendResponse("ok", "update applied successfully; restarting agent...")
|
||||
os.Exit(0)
|
||||
defaultPlatformOps.SelfRestart()
|
||||
case "config":
|
||||
// A config frame carrying credentials means the server accepted our
|
||||
// join key and enrolled this host. Persist what it issued -- our own
|
||||
// per-agent token and the public key to pin -- so the next connection
|
||||
// authenticates as this agent rather than re-enrolling, and so signed
|
||||
// commands can be verified. This is what lets an install ship with only
|
||||
// a join key and still end up fully configured.
|
||||
// join key and enrolled this host.
|
||||
if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled {
|
||||
token, _ := msg.Payload["auth_token"].(string)
|
||||
pubKey, _ := msg.Payload["public_key"].(string)
|
||||
@@ -319,11 +345,17 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
log.Printf("Enrolled, but could not persist credentials: %v", err)
|
||||
log.Printf("This agent will re-enroll on every reconnect until %s is writable.", cm.configPath)
|
||||
} else {
|
||||
log.Printf("Enrolled with the SSO. Credentials written to %s; the join key is no longer needed.", cm.configPath)
|
||||
log.Printf("Enrolled with Theta Directory. Credentials written to %s; the join key is no longer needed.", cm.configPath)
|
||||
}
|
||||
sendResponse("ok", "enrollment stored")
|
||||
return
|
||||
}
|
||||
// Extract the home site's public IP if the server pushes it, so the
|
||||
// tray icon can determine whether we are on the home LAN.
|
||||
if sitePublicIP, ok := msg.Payload["site_public_ip"].(string); ok && sitePublicIP != "" {
|
||||
SetHomePublicIP(sitePublicIP)
|
||||
log.Printf("[home-detect] home site public IP: %s", sitePublicIP)
|
||||
}
|
||||
log.Printf("Received config payload: %v", msg.Payload)
|
||||
sendResponse("ok", "Configuration received")
|
||||
case "reboot":
|
||||
@@ -337,12 +369,88 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
log.Printf("Executing reboot...")
|
||||
if _, err := exec.Execute("reboot"); err != nil {
|
||||
if _, err := defaultPlatformOps.Reboot(); err != nil {
|
||||
log.Printf("Reboot failed: %v", err)
|
||||
sendResponse("error", "reboot failed")
|
||||
return
|
||||
}
|
||||
sendResponse("ok", "system rebooting")
|
||||
case "shutdown":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
if !cfg.Capabilities.Reboot {
|
||||
log.Println("Shutdown rejected: capability disabled in agent.yml")
|
||||
sendResponse("error", "shutdown capability disabled")
|
||||
return
|
||||
}
|
||||
log.Printf("Executing shutdown...")
|
||||
sendResponse("ok", "system shutting down")
|
||||
if _, err := defaultPlatformOps.Shutdown(); err != nil {
|
||||
log.Printf("Shutdown failed: %v", err)
|
||||
}
|
||||
return
|
||||
case "desktop_control", "lock_session", "logout_user", "display_off", "sleep_host":
|
||||
subAction, _ := msg.Payload["subAction"].(string)
|
||||
if subAction == "" {
|
||||
subAction = msg.Type
|
||||
}
|
||||
targetUser, _ := msg.Payload["user"].(string)
|
||||
log.Printf("Executing desktop control action '%s' for user '%s'...", subAction, targetUser)
|
||||
|
||||
switch subAction {
|
||||
case "lock_session", "lock", "logout_user", "logout", "display_off", "sleep_host", "sleep":
|
||||
default:
|
||||
sendResponse("error", fmt.Sprintf("unknown desktop action '%s'", subAction))
|
||||
return
|
||||
}
|
||||
|
||||
out, err := defaultPlatformOps.DesktopControl(subAction, targetUser)
|
||||
|
||||
errMsg := ""
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
respMap := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"subAction": subAction,
|
||||
"output": string(out),
|
||||
"error": errMsg,
|
||||
}
|
||||
respPayload, _ := json.Marshal(respMap)
|
||||
c.WriteMessage(websocket.TextMessage, respPayload)
|
||||
return
|
||||
case "systemd_action":
|
||||
serviceName, _ := msg.Payload["service"].(string)
|
||||
action, _ := msg.Payload["action"].(string)
|
||||
if serviceName == "" {
|
||||
sendResponse("error", "service name required")
|
||||
return
|
||||
}
|
||||
if action == "" {
|
||||
action = "status"
|
||||
}
|
||||
if action != "status" && !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
log.Printf("Executing systemctl %s %s...", action, serviceName)
|
||||
out, err := defaultPlatformOps.ServiceControl(serviceName, action)
|
||||
errMsg := ""
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
respMap := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"service": serviceName,
|
||||
"action": action,
|
||||
"output": string(out),
|
||||
"error": errMsg,
|
||||
}
|
||||
respPayload, _ := json.Marshal(respMap)
|
||||
c.WriteMessage(websocket.TextMessage, respPayload)
|
||||
return
|
||||
case "service_restart":
|
||||
serviceName, ok := msg.Payload["service"].(string)
|
||||
if !ok || !cfg.Capabilities.CanManageService(serviceName) {
|
||||
@@ -351,7 +459,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
log.Printf("Restarting service %s...", serviceName)
|
||||
if _, err := exec.Execute("systemctl", "restart", serviceName); err != nil {
|
||||
if _, err := defaultPlatformOps.ServiceControl(serviceName, "restart"); err != nil {
|
||||
log.Printf("Service restart failed: %v", err)
|
||||
sendResponse("error", "restart failed")
|
||||
return
|
||||
@@ -375,20 +483,94 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Pushing updated SSSD configuration...")
|
||||
if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
|
||||
log.Printf("Failed to write SSSD config: %v", err)
|
||||
sendResponse("error", "failed to write config")
|
||||
if err := defaultPlatformOps.ConfigureLDAP(configData); err != nil {
|
||||
log.Printf("LDAP configuration failed: %v", err)
|
||||
sendResponse("error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Restarting SSSD service...")
|
||||
if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil {
|
||||
log.Printf("SSSD restart failed: %v", err)
|
||||
sendResponse("error", "failed to restart sssd")
|
||||
sendResponse("ok", "LDAP configuration updated")
|
||||
case "render_secrets":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
sendResponse("ok", "LDAP configuration updated")
|
||||
if !cfg.Capabilities.Secrets {
|
||||
log.Println("Secrets render rejected: capability disabled in agent.yml")
|
||||
sendResponse("error", "secrets capability disabled")
|
||||
return
|
||||
}
|
||||
log.Println("Rendering secret templates...")
|
||||
if err := renderSecrets(cfg, exec); err != nil {
|
||||
log.Printf("Secrets render failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("secrets render failed: %v", err))
|
||||
return
|
||||
}
|
||||
sendResponse("ok", "secrets rendered")
|
||||
case "iam_apply":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
if !cfg.Capabilities.IAM {
|
||||
log.Println("IAM apply rejected: capability disabled in agent.yml")
|
||||
sendResponse("error", "iam capability disabled")
|
||||
return
|
||||
}
|
||||
payload, err := parseIAMPayload(msg.Payload)
|
||||
if err != nil {
|
||||
log.Printf("IAM apply: bad payload: %v", err)
|
||||
sendResponse("error", "invalid IAM payload")
|
||||
return
|
||||
}
|
||||
log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID)
|
||||
if err := defaultPlatformOps.ApplyIAM(payload); err != nil {
|
||||
log.Printf("IAM apply failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("iam apply failed: %v", err))
|
||||
return
|
||||
}
|
||||
sendResponse("ok", "iam applied")
|
||||
case "wireguard_apply":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
if !cfg.Capabilities.WireGuard {
|
||||
log.Println("WireGuard apply rejected: capability disabled in agent.yml")
|
||||
sendResponse("error", "wireguard capability disabled")
|
||||
return
|
||||
}
|
||||
conf, _ := msg.Payload["config"].(string)
|
||||
if conf == "" {
|
||||
sendResponse("error", "missing wireguard config")
|
||||
return
|
||||
}
|
||||
log.Printf("Applying WireGuard peer config...")
|
||||
if err := defaultPlatformOps.ApplyWireGuard(conf); err != nil {
|
||||
log.Printf("WireGuard apply failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("wireguard apply failed: %v", err))
|
||||
return
|
||||
}
|
||||
SetVPNActive(true)
|
||||
sendResponse("ok", "wireguard applied")
|
||||
case "wireguard_remove":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
if !cfg.Capabilities.WireGuard {
|
||||
log.Println("WireGuard remove rejected: capability disabled in agent.yml")
|
||||
sendResponse("error", "wireguard capability disabled")
|
||||
return
|
||||
}
|
||||
log.Printf("Removing WireGuard tunnel...")
|
||||
if err := defaultPlatformOps.RemoveWireGuard(); err != nil {
|
||||
log.Printf("WireGuard remove failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("wireguard remove failed: %v", err))
|
||||
return
|
||||
}
|
||||
SetVPNActive(false)
|
||||
sendResponse("ok", "wireguard removed")
|
||||
case "arbitrary_bash":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
@@ -408,7 +590,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
|
||||
log.Printf("Executing remote script: %s", script)
|
||||
out, err := exec.Execute("bash", "-c", script)
|
||||
out, err := defaultPlatformOps.RunScript(script)
|
||||
if err != nil {
|
||||
log.Printf("Script execution failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("execution failed: %v", err))
|
||||
@@ -435,20 +617,24 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
}
|
||||
|
||||
func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
|
||||
// downloadBinary fetches the new binary, verifies its SHA-256, and returns the
|
||||
// path of a temp file holding it. The platform's ApplyUpdate decides how to
|
||||
// install it (Linux renames over the running exe; Windows stages a `.new` and
|
||||
// swaps via the helper once the service stops).
|
||||
func downloadBinary(downloadURL string, expectedSHA256 string) (string, error) {
|
||||
resp, err := http.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http fetch failed: %w", err)
|
||||
return "", fmt.Errorf("http fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected http status: %s", resp.Status)
|
||||
return "", fmt.Errorf("unexpected http status: %s", resp.Status)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "theta-agent-update-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
@@ -458,32 +644,18 @@ func downloadAndUpdateBinary(downloadURL string, expectedSHA256 string) error {
|
||||
|
||||
if _, err := io.Copy(writer, resp.Body); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to save binary: %w", err)
|
||||
return "", fmt.Errorf("failed to save binary: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
actualSHA256 := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
if !strings.EqualFold(actualSHA256, strings.TrimSpace(expectedSHA256)) {
|
||||
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, actualSHA256)
|
||||
return "", fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, actualSHA256)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to set executable permissions: %w", err)
|
||||
return "", fmt.Errorf("failed to set executable permissions: %w", err)
|
||||
}
|
||||
|
||||
selfPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve current binary path: %w", err)
|
||||
}
|
||||
|
||||
resolvedPath, err := filepath.EvalSymlinks(selfPath)
|
||||
if err == nil {
|
||||
selfPath = resolvedPath
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, selfPath); err != nil {
|
||||
return fmt.Errorf("failed to replace binary: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
+56
-1
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -218,6 +219,47 @@ func TestHandleCommand(t *testing.T) {
|
||||
expectedStatus: "error",
|
||||
expectedCmd: nil,
|
||||
},
|
||||
{
|
||||
name: "wireguard_apply allowed",
|
||||
cfg: &Config{
|
||||
PublicKey: testPubKeyB64(),
|
||||
Capabilities: Capabilities{WireGuard: true},
|
||||
},
|
||||
msg: WSMessage{
|
||||
Type: "wireguard_apply",
|
||||
Payload: map[string]interface{}{
|
||||
"config": "[Interface]\nAddress = 10.0.0.2/32\n",
|
||||
},
|
||||
},
|
||||
signed: true,
|
||||
expectedStatus: "ok",
|
||||
expectedCmd: []string{"wg-quick", "up", "theta-mesh"},
|
||||
},
|
||||
{
|
||||
name: "wireguard_apply denied",
|
||||
cfg: &Config{
|
||||
PublicKey: testPubKeyB64(),
|
||||
Capabilities: Capabilities{WireGuard: false},
|
||||
},
|
||||
msg: WSMessage{
|
||||
Type: "wireguard_apply",
|
||||
Payload: map[string]interface{}{"config": "[Interface]\n"},
|
||||
},
|
||||
signed: true,
|
||||
expectedStatus: "error",
|
||||
expectedCmd: nil,
|
||||
},
|
||||
{
|
||||
name: "wireguard_remove allowed",
|
||||
cfg: &Config{
|
||||
PublicKey: testPubKeyB64(),
|
||||
Capabilities: Capabilities{WireGuard: true},
|
||||
},
|
||||
msg: WSMessage{Type: "wireguard_remove"},
|
||||
signed: true,
|
||||
expectedStatus: "ok",
|
||||
expectedCmd: []string{"wg-quick", "down", "theta-mesh"},
|
||||
},
|
||||
{
|
||||
name: "heartbeat_ack is silently ignored",
|
||||
cfg: &Config{
|
||||
@@ -245,11 +287,24 @@ func TestHandleCommand(t *testing.T) {
|
||||
mockConn := &MockConn{}
|
||||
mockExec := &MockExecutor{}
|
||||
cm := &ConfigManager{current: tc.cfg}
|
||||
|
||||
// The dispatch tests assert the exact command lines the Linux
|
||||
// executor produces; pin the platform ops so they behave the same
|
||||
// on any CI host (Windows included). A temp dir holds the WireGuard
|
||||
// config so wireguard_apply's persistence step is writable.
|
||||
prevOps := defaultPlatformOps
|
||||
defaultPlatformOps = &linuxPlatformOps{
|
||||
exec: mockExec,
|
||||
tunnelName: "theta-mesh",
|
||||
confPath: filepath.Join(t.TempDir(), "theta-mesh.conf"),
|
||||
}
|
||||
defer func() { defaultPlatformOps = prevOps }()
|
||||
|
||||
msg := tc.msg
|
||||
if tc.signed {
|
||||
msg.Payload = sign(t, msg.Payload)
|
||||
}
|
||||
handleCommand(cm, msg, mockConn, mockExec)
|
||||
handleCommand(cm, msg, mockConn, mockExec, nil)
|
||||
|
||||
if tc.expectedNoResponse {
|
||||
if len(mockConn.Messages) != 0 {
|
||||
|
||||
Reference in New Issue
Block a user