Merge feat/windows-agent: Windows agent (platform ops, service wrapper, WireGuard, IAM, tray, installer, CI)
This commit is contained in:
@@ -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
|
||||
@@ -4,3 +4,9 @@ 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/
|
||||
|
||||
+24
-16
@@ -183,13 +183,19 @@ 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 binaries + **VC++ v14 redistributable** (its native runtime; .NET
|
||||
- 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
|
||||
@@ -200,22 +206,24 @@ Install-time behavior:
|
||||
|
||||
## 9. Build & release (GitHub Actions + Azure Trusted Signing)
|
||||
|
||||
- **Local dev builds** are self-signed / unsigned.
|
||||
- **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:
|
||||
- Build matrix: agent `windows-amd64`/`arm64`, tray, helper, OpenCredential CP
|
||||
(MSBuild/.NET 4.8), then the Inno installer.
|
||||
- **Azure Trusted Signing** signs the agent, tray, helper, CP DLL, and installer
|
||||
(workflow federated identity → AzureSignTool). Authenticode chains verify offline,
|
||||
which suits air-gap; SmartScreen reputation simply won't accumulate, which is expected.
|
||||
- Release tags (e.g. `v2.0.1`) name the artifacts; `SHA256SUMS` manifest is generated.
|
||||
- **The SSO holds all resources** (client installer is the deliverable; the server stack
|
||||
is assumed to already run inside the air-gap):
|
||||
- New resource tree: `/resources/theta-agent/windows/...` for the installer, loose
|
||||
binaries, and `SHA256SUMS`.
|
||||
- Release workflow uploads artifacts (admin-gated publish endpoint or mounted resource
|
||||
dir); SSO pins a "latest" pointer.
|
||||
- Self-update feed uses the existing signed `update_binary` flow; the agent's fetch is
|
||||
made platform-aware (currently `cli.go` hardcodes the Linux artifact name).
|
||||
- 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
|
||||
|
||||
|
||||
+30
-10
@@ -1,5 +1,5 @@
|
||||
# theta-agent configuration file
|
||||
# Default location: /etc/theta42/agent.yml
|
||||
# Default location: /etc/theta42/agent.yml (Linux) or %ProgramData%\Theta42\agent.yml (Windows)
|
||||
|
||||
server_url: "https://sso.example.com"
|
||||
|
||||
@@ -26,33 +26,53 @@ public_key: ""
|
||||
|
||||
location: "default" # Location identifier (e.g., site, datacenter) for naming
|
||||
|
||||
# Local LDAP byte-pump socket (DESIGN.md §4). The agent forwards raw LDAP bytes
|
||||
# Local LDAP byte-pump socket (DESIGN.md ??4). The agent forwards raw LDAP bytes
|
||||
# from this socket to the SSO, which relays them into its OpenLDAP. The agent
|
||||
# never parses LDAP. Point SSSD at it with:
|
||||
# ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock
|
||||
# (Windows relies on the TCP loopback listener 127.0.0.1:389 instead.)
|
||||
ldap_socket: "/run/theta/ldap.sock"
|
||||
|
||||
# Auto-connect the WireGuard tunnel when this host is away from home and the
|
||||
# directory WebSocket is up. The tray checkbox persists here too.
|
||||
auto_vpn: false
|
||||
|
||||
# Windows-specific (DESIGN-WINDOWS.md ??11). Ignored on Linux.
|
||||
service_name: "theta-agent" # Windows service name
|
||||
desktop_helper: "" # theta-agent-helper.exe path (session-0 ops)
|
||||
public_ip_detect: true # false = air-gap: never call external IP services
|
||||
|
||||
# WireGuard mesh client (DESIGN-WINDOWS.md ??5). The signed wireguard_apply
|
||||
# command pushes the peer config down the WSS channel; these are local paths.
|
||||
wireguard:
|
||||
tunnel_name: "theta-mesh"
|
||||
conf: "" # "" = platform default (/etc/wireguard/... or %ProgramData%\Theta42\wg\...)
|
||||
executable: "" # wireguard.exe path (Windows; "" = PATH/default install)
|
||||
|
||||
capabilities:
|
||||
# ---------------------------------------------------------
|
||||
# Basic Capabilities (Safe, read-only or infrastructure management)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
# Push CPU, RAM, GPU, and ZFS metrics to the SSO Manager
|
||||
# Push CPU, RAM, GPU, and ZFS metrics to Theta Directory
|
||||
telemetry: true
|
||||
|
||||
# Allow the SSO Manager to push down SSSD and SSH keys configuration
|
||||
# Allow Theta Directory to push down SSSD and SSH keys configuration
|
||||
configure_ldap: true
|
||||
|
||||
# Serve the local LDAP byte-pump socket for SSSD/PAM (DESIGN.md §4)
|
||||
# Serve the local LDAP byte-pump socket for SSSD/PAM (DESIGN.md ??4)
|
||||
ldap_tunnel: true
|
||||
|
||||
# Render OpenBao secrets to local files from /etc/theta/templates (DESIGN.md §5)
|
||||
# Render OpenBao secrets to local files from /etc/theta/templates (DESIGN.md ??5)
|
||||
secrets: true
|
||||
|
||||
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md §6)
|
||||
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md ??6)
|
||||
iam: true
|
||||
|
||||
# Secret templates to render (DESIGN.md §5). Each maps a local template to a
|
||||
# Accept signed wireguard_apply/wireguard_remove commands (DESIGN-WINDOWS.md ??5)
|
||||
wireguard: false
|
||||
|
||||
# Secret templates to render (DESIGN.md ??5). Each maps a local template to a
|
||||
# target file and an optional post-render reload. The template embeds secrets as
|
||||
# {{ bao "secret/data/nodes/<node-id>/<name>#<key>" }}.
|
||||
# secrets:
|
||||
@@ -64,7 +84,7 @@ capabilities:
|
||||
# Advanced Capabilities (High risk, remote operations)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
# Allow remote system reboots via the SSO Manager
|
||||
# Allow remote system reboots via Theta Directory
|
||||
reboot: false
|
||||
|
||||
# Allow restarting, starting, or stopping specific systemd services.
|
||||
@@ -73,6 +93,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
|
||||
|
||||
@@ -7,6 +7,11 @@ 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..."
|
||||
|
||||
@@ -25,6 +30,12 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_D
|
||||
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"
|
||||
|
||||
@@ -38,6 +49,12 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR
|
||||
echo " -> linux/arm64 tray..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-arm64" ./cmd/theta-agent-tray/
|
||||
|
||||
echo " -> windows/amd64 tray..."
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$GUI_LDFLAGS" -o "$DIST_DIR/theta-agent-tray-windows-amd64.exe" ./cmd/theta-agent-tray/
|
||||
|
||||
echo " -> windows/arm64 tray..."
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$GUI_LDFLAGS" -o "$DIST_DIR/theta-agent-tray-windows-arm64.exe" ./cmd/theta-agent-tray/
|
||||
|
||||
echo ""
|
||||
echo "Build complete! Artifacts in $DIST_DIR:"
|
||||
ls -lh "$DIST_DIR"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -31,6 +32,12 @@ func handleCLI(args []string) bool {
|
||||
case "--reinitialize", "reinitialize", "--reinit", "reinit":
|
||||
runReinitialize(args[1:])
|
||||
return true
|
||||
case "install-service":
|
||||
handleServiceCommand(args[1:])
|
||||
return true
|
||||
case "remove-service", "uninstall-service":
|
||||
handleServiceCommand(append([]string{"remove"}, args[1:]...))
|
||||
return true
|
||||
case "--version", "version", "-v":
|
||||
fmt.Println("Theta Agent " + AgentVersion)
|
||||
return true
|
||||
@@ -48,8 +55,10 @@ func printUsage() {
|
||||
fmt.Println(" theta-agent Run agent daemon in foreground")
|
||||
fmt.Println(" theta-agent get-secret <key> Fetch single secret value from OpenBao")
|
||||
fmt.Println(" theta-agent get-secrets [flags] Fetch all host/resource secrets (flags: --json, --env)")
|
||||
fmt.Println(" theta-agent update Self-update binary from SSO Manager")
|
||||
fmt.Println(" theta-agent update Self-update binary from Theta Directory")
|
||||
fmt.Println(" theta-agent reinitialize [flags] Reset enrollment credentials & re-register")
|
||||
fmt.Println(" theta-agent install-service (Windows) register the agent as a service")
|
||||
fmt.Println(" theta-agent remove-service (Windows) unregister the agent service")
|
||||
fmt.Println(" theta-agent version Show version info")
|
||||
fmt.Println()
|
||||
fmt.Println("Reinitialize Flags:")
|
||||
@@ -58,7 +67,7 @@ func printUsage() {
|
||||
}
|
||||
|
||||
func runSelfUpdate(args []string) {
|
||||
configPath := "/etc/theta42/agent.yml"
|
||||
configPath := defaultConfigPath()
|
||||
cm, err := NewConfigManager(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[!] Update failed: cannot read config from %s: %v", configPath, err)
|
||||
@@ -69,7 +78,16 @@ func runSelfUpdate(args []string) {
|
||||
log.Fatalf("[!] Update failed: server_url is empty in %s", configPath)
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("%s/resources/theta-agent/theta-agent-linux-amd64", serverURL)
|
||||
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 := fmt.Sprintf("%s/resources/theta-agent/%s", serverURL, artifact)
|
||||
log.Printf("[+] Downloading latest Theta Agent binary from %s...", downloadURL)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
@@ -106,7 +124,7 @@ func runSelfUpdate(args []string) {
|
||||
}
|
||||
|
||||
func runReinitialize(args []string) {
|
||||
configPath := "/etc/theta42/agent.yml"
|
||||
configPath := defaultConfigPath()
|
||||
joinKey := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
if (args[i] == "--join-key" || args[i] == "-j") && i+1 < len(args) {
|
||||
@@ -146,6 +164,12 @@ func runReinitialize(args []string) {
|
||||
|
||||
func restartAffectedServices(exec Executor) {
|
||||
log.Printf("[+] Restarting theta-agent service...")
|
||||
if runtime.GOOS == "windows" {
|
||||
// sc.exe has no one-shot restart.
|
||||
_, _ = exec.Execute("sc", "stop", "theta-agent")
|
||||
_, _ = exec.Execute("sc", "start", "theta-agent")
|
||||
return
|
||||
}
|
||||
_, _ = exec.Execute("systemctl", "restart", "theta-agent")
|
||||
|
||||
if _, err := exec.Execute("systemctl", "is-active", "sssd"); err == nil {
|
||||
@@ -235,7 +259,7 @@ func runGetSecrets(args []string) {
|
||||
}
|
||||
|
||||
func fetchAgentSecrets() (map[string]string, error) {
|
||||
configPath := "/etc/theta42/agent.yml"
|
||||
configPath := defaultConfigPath()
|
||||
cm, err := NewConfigManager(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read config %s: %w", configPath, err)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+129
-13
@@ -21,8 +21,11 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
@@ -36,12 +39,17 @@ import (
|
||||
// ── IPC types (duplicated from the main agent package; tray is its own binary) ──
|
||||
|
||||
// traySocketPaths returns the daemon IPC socket paths for this platform, in
|
||||
// the same order the daemon tries to bind them. Windows has no /run or /tmp,
|
||||
// so it uses a Unix socket under the per-user temp dir; Linux keeps the
|
||||
// original pair.
|
||||
// the same order the daemon tries to bind them. Windows has no /run or /tmp and
|
||||
// the daemon runs as a SYSTEM service, so both sides use the shared
|
||||
// %ProgramData%\Theta42\tray.sock (installer creates the dir with a
|
||||
// Users-writable ACL); Linux keeps the original pair.
|
||||
func traySocketPaths() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{filepath.Join(os.TempDir(), "theta-tray.sock")}
|
||||
pd := os.Getenv("ProgramData")
|
||||
if pd == "" {
|
||||
pd = `C:\ProgramData`
|
||||
}
|
||||
return []string{filepath.Join(pd, "Theta42", "tray.sock")}
|
||||
}
|
||||
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
|
||||
}
|
||||
@@ -106,6 +114,8 @@ var (
|
||||
mAutoVPN *systray.MenuItem
|
||||
mVPNToggle *systray.MenuItem
|
||||
mSeparator *systray.MenuItem
|
||||
mOpenConfig *systray.MenuItem
|
||||
mReinit *systray.MenuItem
|
||||
mQuit *systray.MenuItem
|
||||
|
||||
currentStatus TrayStatus
|
||||
@@ -114,7 +124,7 @@ var (
|
||||
|
||||
func onReady() {
|
||||
// Initial icon — red until we hear from the daemon.
|
||||
systray.SetIcon(iconRed)
|
||||
systray.SetIcon(toWindowsIcon(iconRed))
|
||||
systray.SetTitle("Theta Agent")
|
||||
systray.SetTooltip("Theta Agent — connecting…")
|
||||
|
||||
@@ -125,7 +135,9 @@ func onReady() {
|
||||
mAutoVPN = systray.AddMenuItemCheckbox("Auto-connect VPN when away", "Automatically connect to home via WireGuard when not on the home LAN", false)
|
||||
mVPNToggle = systray.AddMenuItem("Connect VPN", "Manually connect or disconnect the WireGuard tunnel")
|
||||
systray.AddSeparator()
|
||||
mQuit = systray.AddMenuItem("Quit Tray", "Exit the tray icon (daemon keeps running)")
|
||||
mOpenConfig = systray.AddMenuItem("Open Config", "Open agent.yml in the default editor")
|
||||
mReinit = systray.AddMenuItem("Clear enrollment…", "Blank auth_token/public_key so the agent re-enrolls on reconnect")
|
||||
mQuit = systray.AddMenuItem("Quit Tray", "Exit the tray icon (daemon keeps running)")
|
||||
|
||||
// ── IPC loop — connect with retry ──
|
||||
go connectWithRetry()
|
||||
@@ -151,6 +163,12 @@ func onReady() {
|
||||
sendCmd(TrayCommand{Command: "vpn_connect"})
|
||||
}
|
||||
|
||||
case <-mOpenConfig.ClickedCh:
|
||||
sendCmd(TrayCommand{Command: "open_config"})
|
||||
|
||||
case <-mReinit.ClickedCh:
|
||||
sendCmd(TrayCommand{Command: "reinit"})
|
||||
|
||||
case <-mQuit.ClickedCh:
|
||||
systray.Quit()
|
||||
}
|
||||
@@ -201,19 +219,20 @@ func streamStatus(conn net.Conn) {
|
||||
func updateUI(s TrayStatus) {
|
||||
currentStatus = s
|
||||
|
||||
// Icon color.
|
||||
// Icon color. fyne.io/systray needs .ico on Windows; the PNG icons are
|
||||
// wrapped in an ICO container (Windows Vista+ supports PNG-in-ICO).
|
||||
icon := iconRed
|
||||
switch s.Color {
|
||||
case ColorRed:
|
||||
systray.SetIcon(iconRed)
|
||||
icon = iconRed
|
||||
case ColorYellow:
|
||||
systray.SetIcon(iconYellow)
|
||||
icon = iconYellow
|
||||
case ColorGreen:
|
||||
systray.SetIcon(iconGreen)
|
||||
icon = iconGreen
|
||||
case ColorBlue:
|
||||
systray.SetIcon(iconBlue)
|
||||
default:
|
||||
systray.SetIcon(iconRed)
|
||||
icon = iconBlue
|
||||
}
|
||||
systray.SetIcon(toWindowsIcon(icon))
|
||||
|
||||
// Tooltip.
|
||||
tooltip := s.StatusText
|
||||
@@ -258,3 +277,100 @@ func sendCmd(cmd TrayCommand) {
|
||||
log.Printf("theta-agent-tray: send command error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// toWindowsIcon converts PNG bytes into a Windows .ico for fyne.io/systray,
|
||||
// which requires .ico content on Windows (LoadImage cannot read PNG-in-ICO).
|
||||
// On non-Windows the PNG is returned untouched.
|
||||
func toWindowsIcon(pngBytes []byte) []byte {
|
||||
if runtime.GOOS != "windows" {
|
||||
return pngBytes
|
||||
}
|
||||
return pngToIco(pngBytes)
|
||||
}
|
||||
|
||||
// pngToIco decodes a PNG and re-encodes it as classic BMP (XOR + AND mask)
|
||||
// entries at 16/32/48px — the format LoadImage has always supported.
|
||||
func pngToIco(pngBytes []byte) []byte {
|
||||
src, err := png.Decode(bytes.NewReader(pngBytes))
|
||||
if err != nil {
|
||||
return pngBytes // give systray the raw bytes; it will log and continue
|
||||
}
|
||||
|
||||
sizes := []int{16, 32, 48}
|
||||
var dir []byte
|
||||
var payload []byte
|
||||
offset := 6 + len(sizes)*16 // ICONDIR + all ICONDIRENTRYs
|
||||
|
||||
// ICONDIR: reserved(2)=0 type(2)=1 count(2)
|
||||
dir = append(dir, 0, 0, 1, 0, byte(len(sizes)), 0)
|
||||
|
||||
for _, s := range sizes {
|
||||
bmp := rgbaToDIB(scaleNearest(src, s, s))
|
||||
dw, dh := byte(s), byte(s)
|
||||
if s >= 256 {
|
||||
dw, dh = 0, 0
|
||||
}
|
||||
entry := []byte{dw, dh, 0, 0, 1, 0, 32, 0}
|
||||
entry = append(entry, putU32le(len(bmp))...)
|
||||
entry = append(entry, putU32le(offset+len(payload))...)
|
||||
dir = append(dir, entry...)
|
||||
payload = append(payload, bmp...)
|
||||
}
|
||||
return append(dir, payload...)
|
||||
}
|
||||
|
||||
// scaleNearest resizes src to w x h with nearest-neighbour sampling.
|
||||
func scaleNearest(src image.Image, w, h int) image.Image {
|
||||
b := src.Bounds()
|
||||
if b.Dx() == w && b.Dy() == h {
|
||||
return src
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
sy := b.Min.Y + y*b.Dy()/h
|
||||
for x := 0; x < w; x++ {
|
||||
sx := b.Min.X + x*b.Dx()/w
|
||||
dst.Set(x, y, src.At(sx, sy))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// rgbaToDIB encodes an image as a 32-bit bottom-up DIB with an all-transparent
|
||||
// AND mask — the classic icon bitmap LoadImage understands.
|
||||
func rgbaToDIB(img image.Image) []byte {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
|
||||
hdr := make([]byte, 40)
|
||||
copy(hdr, putU32le(40)) // biSize
|
||||
copy(hdr[4:], putU32le(w)) // biWidth
|
||||
copy(hdr[8:], putU32le(h*2)) // biHeight (XOR + AND)
|
||||
copy(hdr[12:], putU16le(1)) // biPlanes
|
||||
copy(hdr[14:], putU16le(32)) // biBitCount
|
||||
andRow := ((w + 31) / 32) * 4 // AND mask row, padded to 32 bits
|
||||
copy(hdr[20:], putU32le(w*h*4+andRow*h)) // biSizeImage
|
||||
|
||||
xor := make([]byte, w*h*4)
|
||||
for y := 0; y < h; y++ {
|
||||
srcY := b.Min.Y + (h - 1 - y) // DIB rows are bottom-up
|
||||
for x := 0; x < w; x++ {
|
||||
r, g, bl, a := img.At(b.Min.X+x, srcY).RGBA()
|
||||
o := y*w*4 + x*4
|
||||
xor[o+0] = byte(bl >> 8) // B
|
||||
xor[o+1] = byte(g >> 8) // G
|
||||
xor[o+2] = byte(r >> 8) // R
|
||||
xor[o+3] = byte(a >> 8) // A
|
||||
}
|
||||
}
|
||||
and := make([]byte, andRow*h) // all zeros: no transparency holes
|
||||
return append(append(hdr, xor...), and...)
|
||||
}
|
||||
|
||||
func putU32le(v int) []byte {
|
||||
return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}
|
||||
}
|
||||
|
||||
func putU16le(v int) []byte {
|
||||
return []byte{byte(v), byte(v >> 8)}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Capabilities struct {
|
||||
LdapTunnel bool `yaml:"ldap_tunnel"`
|
||||
Secrets bool `yaml:"secrets"`
|
||||
IAM bool `yaml:"iam"`
|
||||
WireGuard bool `yaml:"wireguard"`
|
||||
}
|
||||
|
||||
// SecretTarget maps a local template to a rendered target file and an optional
|
||||
@@ -29,6 +30,17 @@ type SecretTarget struct {
|
||||
Reload string `yaml:"reload"`
|
||||
}
|
||||
|
||||
// WireGuardConfig holds the mesh client settings (DESIGN-WINDOWS.md §5).
|
||||
type WireGuardConfig struct {
|
||||
// TunnelName is the WireGuard interface (Linux) / service name (Windows).
|
||||
TunnelName string `yaml:"tunnel_name"`
|
||||
// Conf is where the pushed peer config is persisted on disk.
|
||||
Conf string `yaml:"conf"`
|
||||
// Executable is the wireguard.exe path (Windows); "" = PATH or default
|
||||
// install location.
|
||||
Executable string `yaml:"executable"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `yaml:"server_url"`
|
||||
AuthToken string `yaml:"auth_token"`
|
||||
@@ -36,12 +48,38 @@ type Config struct {
|
||||
// the server exchanges it for a per-agent AuthToken (written back to this
|
||||
// file), so it is a bootstrap value, not a long-term credential. Used only
|
||||
// when AuthToken is empty.
|
||||
JoinKey string `yaml:"join_key"`
|
||||
Location string `yaml:"location"`
|
||||
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
|
||||
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
|
||||
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
|
||||
Capabilities Capabilities `yaml:"capabilities"`
|
||||
JoinKey string `yaml:"join_key"`
|
||||
Location string `yaml:"location"`
|
||||
PublicKey string `yaml:"public_key"` // Ed25519 public key for signed commands
|
||||
LdapSocket string `yaml:"ldap_socket"` // local LDAP tunnel socket (DESIGN.md §4)
|
||||
Secrets []SecretTarget `yaml:"secrets"` // secret templates to render (DESIGN.md §5)
|
||||
Capabilities Capabilities `yaml:"capabilities"`
|
||||
|
||||
// Windows-specific (DESIGN-WINDOWS.md §11).
|
||||
ServiceName string `yaml:"service_name"` // Windows service name
|
||||
DesktopHelper string `yaml:"desktop_helper"` // theta-agent-helper.exe path
|
||||
PublicIPDetect *bool `yaml:"public_ip_detect"` // false disables external lookups (air-gap)
|
||||
AutoVPN bool `yaml:"auto_vpn"` // auto-connect WireGuard when away
|
||||
WireGuard WireGuardConfig `yaml:"wireguard"`
|
||||
}
|
||||
|
||||
// DetectPublicIP reports whether the agent may perform external public-IP
|
||||
// lookups. Defaults to true; an air-gapped host sets public_ip_detect: false so
|
||||
// the agent never tries to reach ipify/icanhazip/etc.
|
||||
func (c *Config) DetectPublicIP() bool {
|
||||
if c.PublicIPDetect == nil {
|
||||
return true
|
||||
}
|
||||
return *c.PublicIPDetect
|
||||
}
|
||||
|
||||
// ServiceNameOrDefault returns the Windows service name, defaulting to
|
||||
// theta-agent when unset.
|
||||
func (c *Config) ServiceNameOrDefault() string {
|
||||
if c.ServiceName != "" {
|
||||
return c.ServiceName
|
||||
}
|
||||
return "theta-agent"
|
||||
}
|
||||
|
||||
// Credential returns the value to present when connecting: our own token once
|
||||
@@ -131,12 +169,69 @@ func (cm *ConfigManager) PersistEnrollment(token, publicKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistAutoVPN writes the tray's auto-VPN preference back into agent.yml so
|
||||
// it survives a restart. Same line-preserving edit as PersistEnrollment.
|
||||
func (cm *ConfigManager) PersistAutoVPN(value bool) error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
raw, err := os.ReadFile(cm.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", cm.configPath, err)
|
||||
}
|
||||
out := setYamlScalarValue(string(raw), "auto_vpn", fmt.Sprintf("%t", value), false)
|
||||
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", cm.configPath, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(cm.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload after auto_vpn: %w", err)
|
||||
}
|
||||
cm.current = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearEnrollment blanks the auth_token and public_key so the agent re-enrolls
|
||||
// with whatever join_key is configured. Triggered by the tray's "re-enroll".
|
||||
func (cm *ConfigManager) ClearEnrollment() error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
raw, err := os.ReadFile(cm.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", cm.configPath, err)
|
||||
}
|
||||
out := setYamlScalar(string(raw), "auth_token", "")
|
||||
out = setYamlScalar(out, "public_key", "")
|
||||
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", cm.configPath, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(cm.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload after enrollment clear: %w", err)
|
||||
}
|
||||
cm.current = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// setYamlScalar replaces the value of a top-level `key: "..."` line, or appends
|
||||
// the key when it is absent. Deliberately line-based rather than a YAML
|
||||
// round-trip so comments and formatting survive.
|
||||
func setYamlScalar(doc, key, value string) string {
|
||||
return setYamlScalarValue(doc, key, value, true)
|
||||
}
|
||||
|
||||
// setYamlScalarValue is setYamlScalar with control over quoting. Numeric/bool
|
||||
// scalars (e.g. auto_vpn: true) must stay unquoted or YAML decodes them as
|
||||
// strings.
|
||||
func setYamlScalarValue(doc, key, value string, quote bool) string {
|
||||
line := fmt.Sprintf("%s: %s", key, value)
|
||||
if quote {
|
||||
line = fmt.Sprintf("%s: %q", key, value)
|
||||
}
|
||||
re := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*:.*$`)
|
||||
line := fmt.Sprintf("%s: %q", key, value)
|
||||
if re.MatchString(doc) {
|
||||
return re.ReplaceAllString(doc, line)
|
||||
}
|
||||
|
||||
+60
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -3,13 +3,14 @@ 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/shirou/gopsutil/v3 v3.24.5
|
||||
golang.org/x/sys v0.20.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
@@ -18,5 +19,4 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
@@ -37,8 +37,6 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/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=
|
||||
|
||||
+79
-1
@@ -67,6 +67,21 @@ func SetVPNActive(active bool) {
|
||||
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) {
|
||||
@@ -82,7 +97,13 @@ func StartHomeMonitor(cfg *Config, connectedFn func() bool) {
|
||||
}
|
||||
|
||||
func checkAndPush(cfg *Config, connectedFn func() bool) {
|
||||
ip := fetchPublicIP()
|
||||
// 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")
|
||||
}
|
||||
@@ -101,5 +122,62 @@ func checkAndPush(cfg *Config, connectedFn func() bool) {
|
||||
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,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")
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
; 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 instead of waiting for 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 skipifsilent
|
||||
|
||||
[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; silent installs keep the command-line params.
|
||||
procedure CurPageChanged(CurPageID: Integer);
|
||||
begin
|
||||
if CurPageID = AgentConfigPage.ID 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-12
@@ -39,19 +39,22 @@ func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
|
||||
|
||||
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
|
||||
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
||||
os.Remove(socketPath)
|
||||
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
||||
os.MkdirAll(dir, 0755)
|
||||
}
|
||||
// 1. UNIX Domain Socket Listener. Windows passes an empty path and relies
|
||||
// on the TCP loopback listener below.
|
||||
if socketPath != "" {
|
||||
os.Remove(socketPath)
|
||||
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
||||
os.MkdirAll(dir, 0755)
|
||||
}
|
||||
|
||||
// 1. UNIX Domain Socket Listener
|
||||
lnUnix, err := net.Listen("unix", socketPath)
|
||||
if err == nil {
|
||||
os.Chmod(socketPath, 0666)
|
||||
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
|
||||
go t.acceptLoop(lnUnix, stopCh)
|
||||
} else {
|
||||
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
|
||||
lnUnix, err := net.Listen("unix", socketPath)
|
||||
if err == nil {
|
||||
os.Chmod(socketPath, 0666)
|
||||
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
|
||||
go t.acceptLoop(lnUnix, stopCh)
|
||||
} else {
|
||||
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
)
|
||||
@@ -14,15 +15,44 @@ import (
|
||||
// comes up and drops, so StartHomeMonitor can read it without a mutex.
|
||||
var wsConnected atomic.Bool
|
||||
|
||||
// agentStopCh closes once the agent should exit: a SIGINT/SIGTERM in the
|
||||
// foreground, or the SCM stop request when running as a Windows service. Both
|
||||
// runAgent (foreground) and the service handler wait on it.
|
||||
var (
|
||||
agentStopOnce sync.Once
|
||||
agentStopCh = make(chan struct{})
|
||||
)
|
||||
|
||||
// currentCM lets the tray IPC server persist preferences (auto_vpn) and reset
|
||||
// enrollment into the live config file. Set once in runAgent.
|
||||
var currentCM *ConfigManager
|
||||
|
||||
// stopAgent signals the running agent to shut down. Idempotent.
|
||||
func stopAgent() {
|
||||
agentStopOnce.Do(func() { close(agentStopCh) })
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
|
||||
return
|
||||
}
|
||||
|
||||
// Windows: when installed as a service the SCM starts us and svc.Run takes
|
||||
// over the process lifecycle. Foreground (or any other OS) falls through to
|
||||
// runAgent, which blocks until a signal arrives.
|
||||
if maybeRunAsService() {
|
||||
return
|
||||
}
|
||||
|
||||
runAgent()
|
||||
}
|
||||
|
||||
// runAgent runs the agent daemon until stopAgent is called.
|
||||
func runAgent() {
|
||||
log.Println("Starting Theta Agent...")
|
||||
|
||||
// Attempt to load configuration
|
||||
configPath := "/etc/theta42/agent.yml"
|
||||
configPath := defaultConfigPath()
|
||||
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
||||
configPath = os.Args[1]
|
||||
}
|
||||
@@ -33,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,
|
||||
@@ -41,22 +71,34 @@ 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
|
||||
|
||||
// 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 SSO Manager
|
||||
// WebSocket connection to Theta Directory
|
||||
go connectWebSocket(cm, exec)
|
||||
|
||||
// Home detection + tray status push (polls public IP every 60s).
|
||||
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() })
|
||||
|
||||
// Block until signal is received
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
// 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
|
||||
}
|
||||
+9
-4
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -68,10 +69,14 @@ func TestRenderSecrets(t *testing.T) {
|
||||
t.Fatalf("rendered content mismatch:\n got: %q\nwant: %q", content, expected)
|
||||
}
|
||||
|
||||
// The target should be 0600 (holds secrets).
|
||||
info, _ := os.Stat(target)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
|
||||
// The target should be 0600 (holds secrets). Windows has no POSIX modes and
|
||||
// reports 0666 regardless; the intent there is covered by the ACLs the
|
||||
// installer sets on the target directory.
|
||||
if runtime.GOOS != "windows" {
|
||||
info, _ := os.Stat(target)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("expected 0600, got %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -425,7 +425,7 @@ func collectHostDetails() HostDetails {
|
||||
return details
|
||||
}
|
||||
|
||||
const AgentVersion = "v2.0.0"
|
||||
const AgentVersion = "v2.1.0"
|
||||
|
||||
// CollectDiscoveryData gathers static host information.
|
||||
func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
||||
@@ -446,7 +446,10 @@ func CollectDiscoveryData(cfg *Config) DiscoveryData {
|
||||
cpuDet := collectCPUDetails()
|
||||
loggedUsers := collectLoggedUsers()
|
||||
|
||||
pubIP := getPublicIP()
|
||||
pubIP := ""
|
||||
if cfg.DetectPublicIP() {
|
||||
pubIP = getPublicIP()
|
||||
}
|
||||
|
||||
diskTotalGB := 0.0
|
||||
for _, d := range disks {
|
||||
@@ -660,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.")
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -9,19 +9,19 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// TraySocketPaths are the paths the daemon tries to bind, in order.
|
||||
//
|
||||
// Windows has no /run or /tmp; a Unix socket in the per-user temp dir works
|
||||
// there (AF_UNIX is supported since Windows 10 1803) and needs no admin
|
||||
// rights. Linux keeps the original /run/theta path with a /tmp fallback.
|
||||
// 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{filepath.Join(os.TempDir(), "theta-tray.sock")}
|
||||
return []string{windowsTraySocketPath()}
|
||||
}
|
||||
return []string{"/run/theta/tray.sock", "/tmp/theta-tray.sock"}
|
||||
}()
|
||||
@@ -30,7 +30,7 @@ var TraySocketPaths = func() []string {
|
||||
// entry in TraySocketPaths on each platform.
|
||||
var TraySocket = func() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(os.TempDir(), "theta-tray.sock")
|
||||
return windowsTraySocketPath()
|
||||
}
|
||||
return "/tmp/theta-tray.sock"
|
||||
}()
|
||||
|
||||
+37
-3
@@ -15,6 +15,9 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -37,6 +40,7 @@ func (ts *trayServer) Start() {
|
||||
|
||||
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
|
||||
@@ -93,19 +97,49 @@ func (ts *trayServer) handleCommand(cmd TrayCommand) {
|
||||
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)
|
||||
// TODO: persist to agent.yml
|
||||
case "vpn_connect":
|
||||
log.Printf("[tray-ipc] VPN connect requested")
|
||||
// TODO: invoke WireGuard connect
|
||||
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")
|
||||
// TODO: invoke WireGuard disconnect
|
||||
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()
|
||||
|
||||
+74
-122
@@ -12,7 +12,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -147,7 +146,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
||||
// credential every 5s just floods the SSO and its audit log
|
||||
// forever, so back off hard and say plainly what is wrong.
|
||||
if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) {
|
||||
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the SSO Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
|
||||
log.Printf("Server rejected our token (HTTP %d). Enroll this agent in the Theta Directory and put the issued token in agent.yml. Retrying in %s.", resp.StatusCode, authRetryInterval)
|
||||
time.Sleep(authRetryInterval)
|
||||
continue
|
||||
}
|
||||
@@ -156,7 +155,7 @@ 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{})
|
||||
@@ -174,7 +173,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
|
||||
if cfg.Capabilities.LdapTunnel || cfg.Capabilities.ConfigureLDAP {
|
||||
socketPath := cfg.LdapSocket
|
||||
if socketPath == "" {
|
||||
socketPath = "/run/theta/ldap.sock"
|
||||
socketPath = defaultLdapSocketPath()
|
||||
}
|
||||
go tunnel.start(socketPath, stopCh)
|
||||
}
|
||||
@@ -214,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)
|
||||
}
|
||||
@@ -297,7 +296,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
|
||||
log.Printf("Fetching logs for service %s (%d lines)...", serviceName, linesCount)
|
||||
out, err := exec.Execute("journalctl", "-u", serviceName, "-n", fmt.Sprintf("%d", linesCount), "--no-pager")
|
||||
out, err := defaultPlatformOps.FetchLogs(serviceName, linesCount)
|
||||
if err != nil {
|
||||
log.Printf("Log fetch failed: %v", err)
|
||||
sendResponse("error", "failed to fetch logs")
|
||||
@@ -329,13 +328,13 @@ 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.
|
||||
@@ -346,7 +345,7 @@ 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
|
||||
@@ -370,7 +369,7 @@ 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
|
||||
@@ -388,8 +387,8 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
log.Printf("Executing shutdown...")
|
||||
sendResponse("ok", "system shutting down")
|
||||
if _, err := exec.Execute("shutdown", "-h", "now"); err != nil {
|
||||
exec.Execute("poweroff")
|
||||
if _, err := defaultPlatformOps.Shutdown(); err != nil {
|
||||
log.Printf("Shutdown failed: %v", err)
|
||||
}
|
||||
return
|
||||
case "desktop_control", "lock_session", "logout_user", "display_off", "sleep_host":
|
||||
@@ -399,36 +398,16 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
}
|
||||
targetUser, _ := msg.Payload["user"].(string)
|
||||
log.Printf("Executing desktop control action '%s' for user '%s'...", subAction, targetUser)
|
||||
var out []byte
|
||||
var err error
|
||||
|
||||
switch subAction {
|
||||
case "lock_session", "lock":
|
||||
out, err = exec.Execute("loginctl", "lock-sessions")
|
||||
if err != nil {
|
||||
out, err = exec.Execute("sh", "-c", "DISPLAY=:0 xdg-screensaver lock || DISPLAY=:0 xset dpms force off")
|
||||
}
|
||||
case "logout_user", "logout":
|
||||
if targetUser != "" {
|
||||
out, err = exec.Execute("loginctl", "terminate-user", targetUser)
|
||||
if err != nil {
|
||||
out, err = exec.Execute("pkill", "-KILL", "-u", targetUser)
|
||||
}
|
||||
} else {
|
||||
out, err = exec.Execute("loginctl", "terminate-session")
|
||||
if err != nil {
|
||||
out, err = exec.Execute("pkill", "-9", "-f", "session-child")
|
||||
}
|
||||
}
|
||||
case "display_off":
|
||||
out, err = exec.Execute("sh", "-c", "DISPLAY=:0 xset dpms force off || loginctl lock-sessions")
|
||||
case "sleep_host", "sleep":
|
||||
out, err = exec.Execute("systemctl", "suspend")
|
||||
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()
|
||||
@@ -457,7 +436,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
log.Printf("Executing systemctl %s %s...", action, serviceName)
|
||||
out, err := exec.Execute("systemctl", action, serviceName)
|
||||
out, err := defaultPlatformOps.ServiceControl(serviceName, action)
|
||||
errMsg := ""
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
@@ -480,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
|
||||
@@ -504,70 +483,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Pushing updated SSSD configuration...")
|
||||
_ = os.MkdirAll("/etc/sssd", 0755)
|
||||
if err := exec.WriteFile("/etc/sssd/sssd.conf", []byte(configData), 0600); err != nil {
|
||||
log.Printf("Failed to write SSSD config: %v", err)
|
||||
sendResponse("error", "failed to write config")
|
||||
if err := defaultPlatformOps.ConfigureLDAP(configData); err != nil {
|
||||
log.Printf("LDAP configuration failed: %v", err)
|
||||
sendResponse("error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure /etc/nsswitch.conf enables sss for passwd, group, shadow, sudoers
|
||||
if nssBytes, err := os.ReadFile("/etc/nsswitch.conf"); err == nil {
|
||||
nssContent := string(nssBytes)
|
||||
updatedNss := false
|
||||
lines := strings.Split(nssContent, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if (strings.HasPrefix(trimmed, "passwd:") || strings.HasPrefix(trimmed, "group:") || strings.HasPrefix(trimmed, "shadow:") || strings.HasPrefix(trimmed, "sudoers:")) && !strings.Contains(trimmed, "sss") {
|
||||
lines[i] = line + " sss"
|
||||
updatedNss = true
|
||||
}
|
||||
}
|
||||
if updatedNss {
|
||||
_ = os.WriteFile("/etc/nsswitch.conf", []byte(strings.Join(lines, "\n")), 0644)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Restarting SSSD service...")
|
||||
if _, err := exec.Execute("systemctl", "restart", "sssd"); err != nil {
|
||||
log.Printf("SSSD restart failed (%v), attempting auto-install of missing packages...", err)
|
||||
if _, err2 := exec.Execute("sh", "-c", "DEBIAN_FRONTEND=noninteractive apt-get update -y -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || dnf install -y sssd sssd-ldap sssd-tools || yum install -y sssd sssd-ldap sssd-tools"); err2 == nil {
|
||||
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
||||
if _, err3 := exec.Execute("systemctl", "restart", "sssd"); err3 == nil {
|
||||
// Configure SSH AuthorizedKeysCommand
|
||||
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
|
||||
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
|
||||
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
||||
_ = os.WriteFile(sshConfPath, []byte(sshConfContent), 0644)
|
||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
||||
sendResponse("ok", "LDAP configuration updated")
|
||||
return
|
||||
}
|
||||
}
|
||||
sendResponse("error", "failed to restart sssd")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure /etc/ssh/sshd_config.d/theta-sssd.conf is created for SSH AuthorizedKeysCommand
|
||||
_ = os.MkdirAll("/etc/ssh/sshd_config.d", 0755)
|
||||
sshConfPath := "/etc/ssh/sshd_config.d/theta-sssd.conf"
|
||||
sshConfContent := "AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
||||
if err := os.WriteFile(sshConfPath, []byte(sshConfContent), 0644); err == nil {
|
||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
||||
}
|
||||
if sshdBytes, err2 := os.ReadFile("/etc/ssh/sshd_config"); err2 == nil {
|
||||
sshdStr := string(sshdBytes)
|
||||
if !strings.Contains(sshdStr, "sss_ssh_authorizedkeys") {
|
||||
sshdStr += "\nAuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u\nAuthorizedKeysCommandUser nobody\n"
|
||||
_ = os.WriteFile("/etc/ssh/sshd_config", []byte(sshdStr), 0644)
|
||||
_, _ = exec.Execute("systemctl", "reload", "sshd")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure PAM mkhomedir is enabled
|
||||
_, _ = exec.Execute("sh", "-c", "pam-auth-update --package --enable mkhomedir sss || true")
|
||||
|
||||
sendResponse("ok", "LDAP configuration updated")
|
||||
case "render_secrets":
|
||||
if !verifySignature(cfg, msg) {
|
||||
@@ -603,12 +524,53 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
|
||||
return
|
||||
}
|
||||
log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID)
|
||||
if err := applyIAM(payload, exec); err != nil {
|
||||
if err := defaultPlatformOps.ApplyIAM(payload); err != nil {
|
||||
log.Printf("IAM apply failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("iam apply failed: %v", err))
|
||||
return
|
||||
}
|
||||
sendResponse("ok", "iam applied")
|
||||
case "wireguard_apply":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
if !cfg.Capabilities.WireGuard {
|
||||
log.Println("WireGuard apply rejected: capability disabled in agent.yml")
|
||||
sendResponse("error", "wireguard capability disabled")
|
||||
return
|
||||
}
|
||||
conf, _ := msg.Payload["config"].(string)
|
||||
if conf == "" {
|
||||
sendResponse("error", "missing wireguard config")
|
||||
return
|
||||
}
|
||||
log.Printf("Applying WireGuard peer config...")
|
||||
if err := defaultPlatformOps.ApplyWireGuard(conf); err != nil {
|
||||
log.Printf("WireGuard apply failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("wireguard apply failed: %v", err))
|
||||
return
|
||||
}
|
||||
SetVPNActive(true)
|
||||
sendResponse("ok", "wireguard applied")
|
||||
case "wireguard_remove":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
return
|
||||
}
|
||||
if !cfg.Capabilities.WireGuard {
|
||||
log.Println("WireGuard remove rejected: capability disabled in agent.yml")
|
||||
sendResponse("error", "wireguard capability disabled")
|
||||
return
|
||||
}
|
||||
log.Printf("Removing WireGuard tunnel...")
|
||||
if err := defaultPlatformOps.RemoveWireGuard(); err != nil {
|
||||
log.Printf("WireGuard remove failed: %v", err)
|
||||
sendResponse("error", fmt.Sprintf("wireguard remove failed: %v", err))
|
||||
return
|
||||
}
|
||||
SetVPNActive(false)
|
||||
sendResponse("ok", "wireguard removed")
|
||||
case "arbitrary_bash":
|
||||
if !verifySignature(cfg, msg) {
|
||||
sendResponse("error", "signature verification failed")
|
||||
@@ -628,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))
|
||||
@@ -655,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)
|
||||
@@ -678,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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -218,6 +219,47 @@ func TestHandleCommand(t *testing.T) {
|
||||
expectedStatus: "error",
|
||||
expectedCmd: nil,
|
||||
},
|
||||
{
|
||||
name: "wireguard_apply allowed",
|
||||
cfg: &Config{
|
||||
PublicKey: testPubKeyB64(),
|
||||
Capabilities: Capabilities{WireGuard: true},
|
||||
},
|
||||
msg: WSMessage{
|
||||
Type: "wireguard_apply",
|
||||
Payload: map[string]interface{}{
|
||||
"config": "[Interface]\nAddress = 10.0.0.2/32\n",
|
||||
},
|
||||
},
|
||||
signed: true,
|
||||
expectedStatus: "ok",
|
||||
expectedCmd: []string{"wg-quick", "up", "theta-mesh"},
|
||||
},
|
||||
{
|
||||
name: "wireguard_apply denied",
|
||||
cfg: &Config{
|
||||
PublicKey: testPubKeyB64(),
|
||||
Capabilities: Capabilities{WireGuard: false},
|
||||
},
|
||||
msg: WSMessage{
|
||||
Type: "wireguard_apply",
|
||||
Payload: map[string]interface{}{"config": "[Interface]\n"},
|
||||
},
|
||||
signed: true,
|
||||
expectedStatus: "error",
|
||||
expectedCmd: nil,
|
||||
},
|
||||
{
|
||||
name: "wireguard_remove allowed",
|
||||
cfg: &Config{
|
||||
PublicKey: testPubKeyB64(),
|
||||
Capabilities: Capabilities{WireGuard: true},
|
||||
},
|
||||
msg: WSMessage{Type: "wireguard_remove"},
|
||||
signed: true,
|
||||
expectedStatus: "ok",
|
||||
expectedCmd: []string{"wg-quick", "down", "theta-mesh"},
|
||||
},
|
||||
{
|
||||
name: "heartbeat_ack is silently ignored",
|
||||
cfg: &Config{
|
||||
@@ -245,6 +287,19 @@ func TestHandleCommand(t *testing.T) {
|
||||
mockConn := &MockConn{}
|
||||
mockExec := &MockExecutor{}
|
||||
cm := &ConfigManager{current: tc.cfg}
|
||||
|
||||
// The dispatch tests assert the exact command lines the Linux
|
||||
// executor produces; pin the platform ops so they behave the same
|
||||
// on any CI host (Windows included). A temp dir holds the WireGuard
|
||||
// config so wireguard_apply's persistence step is writable.
|
||||
prevOps := defaultPlatformOps
|
||||
defaultPlatformOps = &linuxPlatformOps{
|
||||
exec: mockExec,
|
||||
tunnelName: "theta-mesh",
|
||||
confPath: filepath.Join(t.TempDir(), "theta-mesh.conf"),
|
||||
}
|
||||
defer func() { defaultPlatformOps = prevOps }()
|
||||
|
||||
msg := tc.msg
|
||||
if tc.signed {
|
||||
msg.Payload = sign(t, msg.Payload)
|
||||
|
||||
Reference in New Issue
Block a user