diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5b2a02a --- /dev/null +++ b/.github/workflows/release.yml @@ -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/. +# +# 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 diff --git a/.gitignore b/.gitignore index 3727a71..33a420a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/DESIGN-WINDOWS.md b/DESIGN-WINDOWS.md index 1bac02b..d68cbcd 100644 --- a/DESIGN-WINDOWS.md +++ b/DESIGN-WINDOWS.md @@ -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/`) — 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 diff --git a/agent.yml.example b/agent.yml.example index d8874dc..198df26 100644 --- a/agent.yml.example +++ b/agent.yml.example @@ -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//#" }}. # 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 diff --git a/build_all.sh b/build_all.sh index a75598a..00233d6 100755 --- a/build_all.sh +++ b/build_all.sh @@ -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" diff --git a/cli.go b/cli.go index 3209923..b5f850a 100644 --- a/cli.go +++ b/cli.go @@ -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 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) diff --git a/cmd/theta-agent-helper/main.go b/cmd/theta-agent-helper/main.go new file mode 100644 index 0000000..bd83265 --- /dev/null +++ b/cmd/theta-agent-helper/main.go @@ -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 +// +// 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 [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 ") + 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") +} diff --git a/cmd/theta-agent-tray/main.go b/cmd/theta-agent-tray/main.go index 7cb6661..db7fd45 100644 --- a/cmd/theta-agent-tray/main.go +++ b/cmd/theta-agent-tray/main.go @@ -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)} +} diff --git a/cmd/theta-agent-tray/main_test.go b/cmd/theta-agent-tray/main_test.go new file mode 100644 index 0000000..a579f48 --- /dev/null +++ b/cmd/theta-agent-tray/main_test.go @@ -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") + } + } +} + diff --git a/config.go b/config.go index 7fd8c60..17654ea 100644 --- a/config.go +++ b/config.go @@ -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) } diff --git a/config_test.go b/config_test.go index f6dd76e..f6da234 100644 --- a/config_test.go +++ b/config_test.go @@ -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) + } +} diff --git a/dist/theta-agent-darwin-amd64 b/dist/theta-agent-darwin-amd64 deleted file mode 100755 index 16c6c62..0000000 Binary files a/dist/theta-agent-darwin-amd64 and /dev/null differ diff --git a/dist/theta-agent-darwin-arm64 b/dist/theta-agent-darwin-arm64 deleted file mode 100755 index ca11ced..0000000 Binary files a/dist/theta-agent-darwin-arm64 and /dev/null differ diff --git a/dist/theta-agent-linux-amd64 b/dist/theta-agent-linux-amd64 deleted file mode 100755 index ec46c76..0000000 Binary files a/dist/theta-agent-linux-amd64 and /dev/null differ diff --git a/dist/theta-agent-linux-arm64 b/dist/theta-agent-linux-arm64 deleted file mode 100755 index 91ae337..0000000 Binary files a/dist/theta-agent-linux-arm64 and /dev/null differ diff --git a/dist/theta-agent-linux-armv7 b/dist/theta-agent-linux-armv7 deleted file mode 100755 index b72de73..0000000 Binary files a/dist/theta-agent-linux-armv7 and /dev/null differ diff --git a/dist/theta-agent-tray-linux-amd64 b/dist/theta-agent-tray-linux-amd64 deleted file mode 100755 index 14e8cb0..0000000 Binary files a/dist/theta-agent-tray-linux-amd64 and /dev/null differ diff --git a/dist/theta-agent-tray-linux-arm64 b/dist/theta-agent-tray-linux-arm64 deleted file mode 100755 index 4d9eeec..0000000 Binary files a/dist/theta-agent-tray-linux-arm64 and /dev/null differ diff --git a/dist/theta-agent-tray-windows-amd64.exe b/dist/theta-agent-tray-windows-amd64.exe deleted file mode 100755 index b87a843..0000000 Binary files a/dist/theta-agent-tray-windows-amd64.exe and /dev/null differ diff --git a/dist/theta-agent-windows-amd64.exe b/dist/theta-agent-windows-amd64.exe deleted file mode 100755 index bf17c7e..0000000 Binary files a/dist/theta-agent-windows-amd64.exe and /dev/null differ diff --git a/dist/theta-agent-windows-arm64.exe b/dist/theta-agent-windows-arm64.exe deleted file mode 100755 index 3993c1a..0000000 Binary files a/dist/theta-agent-windows-arm64.exe and /dev/null differ diff --git a/go.mod b/go.mod index f714a8c..5c3e09f 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 8f64063..ae35a08 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/home_detect.go b/home_detect.go index e55e7cc..ee7f44e 100644 --- a/home_detect.go +++ b/home_detect.go @@ -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 + } +} diff --git a/iam_windows.go b/iam_windows.go new file mode 100644 index 0000000..d0489c0 --- /dev/null +++ b/iam_windows.go @@ -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") +} diff --git a/installer/windows/installer.iss b/installer/windows/installer.iss new file mode 100644 index 0000000..06a737a --- /dev/null +++ b/installer/windows/installer.iss @@ -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= 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; diff --git a/installer/windows/vendor-manifest.json b/installer/windows/vendor-manifest.json new file mode 100644 index 0000000..813fa0a --- /dev/null +++ b/installer/windows/vendor-manifest.json @@ -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" + } + } +} diff --git a/ldap_tunnel.go b/ldap_tunnel.go index b82d823..45339ac 100644 --- a/ldap_tunnel.go +++ b/ldap_tunnel.go @@ -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) diff --git a/main.go b/main.go index f1ef901..437796b 100644 --- a/main.go +++ b/main.go @@ -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...") } diff --git a/paths.go b/paths.go new file mode 100644 index 0000000..cf326e6 --- /dev/null +++ b/paths.go @@ -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") +} diff --git a/platform_factory_other.go b/platform_factory_other.go new file mode 100644 index 0000000..dd6f288 --- /dev/null +++ b/platform_factory_other.go @@ -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, + } +} diff --git a/platform_factory_windows.go b/platform_factory_windows.go new file mode 100644 index 0000000..53163e7 --- /dev/null +++ b/platform_factory_windows.go @@ -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, + } +} diff --git a/platform_linuxops.go b/platform_linuxops.go new file mode 100644 index 0000000..a5b58cd --- /dev/null +++ b/platform_linuxops.go @@ -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/.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) +} diff --git a/platform_ops.go b/platform_ops.go new file mode 100644 index 0000000..efc2a8d --- /dev/null +++ b/platform_ops.go @@ -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{}) diff --git a/platform_windows.go b/platform_windows.go new file mode 100644 index 0000000..3943724 --- /dev/null +++ b/platform_windows.go @@ -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 `.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$ 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) +} diff --git a/scripts/setup-build-env.ps1 b/scripts/setup-build-env.ps1 new file mode 100644 index 0000000..483b305 --- /dev/null +++ b/scripts/setup-build-env.ps1 @@ -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 +} diff --git a/secrets_test.go b/secrets_test.go index 6ae8c30..e5de530 100644 --- a/secrets_test.go +++ b/secrets_test.go @@ -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()) + } } } diff --git a/service_cli_other.go b/service_cli_other.go new file mode 100644 index 0000000..146b522 --- /dev/null +++ b/service_cli_other.go @@ -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) {} diff --git a/service_cli_windows.go b/service_cli_windows.go new file mode 100644 index 0000000..7e6d963 --- /dev/null +++ b/service_cli_windows.go @@ -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) +} diff --git a/service_other.go b/service_other.go new file mode 100644 index 0000000..aa4d716 --- /dev/null +++ b/service_other.go @@ -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 +} diff --git a/service_windows.go b/service_windows.go new file mode 100644 index 0000000..f88b51f --- /dev/null +++ b/service_windows.go @@ -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 + } + } +} diff --git a/telemetry.go b/telemetry.go index c83b941..edebd86 100644 --- a/telemetry.go +++ b/telemetry.go @@ -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.") } } diff --git a/tray_ipc.go b/tray_ipc.go index bab09e0..d02b13b 100644 --- a/tray_ipc.go +++ b/tray_ipc.go @@ -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" }() diff --git a/tray_server.go b/tray_server.go index c54466d..e728487 100644 --- a/tray_server.go +++ b/tray_server.go @@ -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, 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() diff --git a/websocket.go b/websocket.go index 6fce044..1f1045f 100644 --- a/websocket.go +++ b/websocket.go @@ -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 } diff --git a/websocket_test.go b/websocket_test.go index 19a91c2..bbe7ed3 100644 --- a/websocket_test.go +++ b/websocket_test.go @@ -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)