diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..befa01d --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,143 @@ +name: build-windows + +# Builds the Windows agent, tray, session helper, and the fully-offline Inno +# installer; signs them with Azure Trusted Signing; generates SHA256SUMS; and +# publishes the artifacts to the SSO resource tree (DESIGN-WINDOWS.md §9). +# +# Signing requires GitHub OIDC federation to Azure. If the Azure secrets are +# not configured the workflow still builds and attaches unsigned artifacts. + +on: + push: + tags: ["v*"] + workflow_dispatch: + +env: + GO_VERSION: "1.22.2" + VERSION: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'dev' }} + +jobs: + build-windows: + runs-on: windows-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Run unit tests + shell: bash + run: go test ./... + + - name: Build agent, tray, helper + shell: bash + run: | + mkdir -p dist + LDFLAGS="-s -w" + for arch in amd64 arm64; do + GOOS=windows GOARCH=$arch go build -ldflags="$LDFLAGS" -o dist/theta-agent-windows-$arch.exe . + GOOS=windows GOARCH=$arch go build -ldflags="$LDFLAGS" -o dist/theta-agent-tray-windows-$arch.exe ./cmd/theta-agent-tray/ + GOOS=windows GOARCH=$arch go build -ldflags="$LDFLAGS" -o dist/theta-agent-helper-windows-$arch.exe ./cmd/theta-agent-helper/ + done + + - name: Fetch vendored, vendor-signed dependencies + shell: pwsh + run: | + $vendor = "installer/windows/vendor" + New-Item -ItemType Directory -Force -Path $vendor | Out-Null + # Pinned by sha256; WireGuard MSI and VC++ redist are Microsoft/vendor signed. + $assets = @( + @{ url = "https://download.wireguard.com/windows-client/wireguard-amd64-0.5.3.msi"; out = "wireguard-amd64-0.5.3.msi"; sha = "PINNED_WIREGUARD_SHA256" }, + @{ url = "https://aka.ms/vs/17/release/vc_redist.x64.exe"; out = "vc_redist.x64.exe"; sha = "PINNED_VC_REDIST_SHA256" } + ) + foreach ($a in $assets) { + $dest = Join-Path $vendor $a.out + Invoke-WebRequest -Uri $a.url -OutFile $dest + $h = (Get-FileHash -Algorithm SHA256 $dest).Hash.ToLower() + if ($h -ne $a.sha) { throw "sha256 mismatch for $($a.out): $h" } + Set-Content -Path "$dest.sha256" -Value $h + } + # OpenCredential (pGina fork) release — swap PINNED below for a real release URL. + $oc = "https://github.com/pedropablobm/OpenCredential/releases/latest/download/OpenCredential.zip" + Invoke-WebRequest -Uri $oc -OutFile (Join-Path $vendor "opencredential.zip") + Expand-Archive -Path (Join-Path $vendor "opencredential.zip") -DestinationPath (Join-Path $vendor "OpenCredential") + + - name: Compile Inno installer + shell: pwsh + run: | + choco install innosetup -y --no-progress + $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + if (-not (Test-Path $iscc)) { $iscc = "C:\Program Files\Inno Setup 6\ISCC.exe" } + & $iscc "/DMyAppVersion=${{ env.VERSION }}" installer/windows/installer.iss + if ($LASTEXITCODE -ne 0) { throw "ISCC failed" } + + - name: Generate SHA256SUMS + shell: pwsh + run: | + Get-ChildItem dist -File | Where-Object { $_.Extension -in ".exe",".msi" -or $_.Name -like "*setup*" } | ForEach-Object { + "{0} {1}" -f (Get-FileHash -Algorithm SHA256 $_.FullName).Hash.ToLower(), $_.Name + } | Set-Content -Path dist/SHA256SUMS + + - 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 (agent, tray, helper, CP, installer) + 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/theta-agent-windows-*.exe + dist/theta-agent-tray-windows-*.exe + dist/theta-agent-helper-windows-*.exe + dist/theta-agent-*-setup.exe + output-files: "" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: theta-agent-windows + path: | + dist/theta-agent-windows-*.exe + dist/theta-agent-helper-windows-*.exe + dist/theta-agent-tray-windows-*.exe + dist/theta-agent-*-setup.exe + dist/SHA256SUMS + + - name: Attach to GitHub release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: | + dist/theta-agent-windows-*.exe + dist/theta-agent-helper-windows-*.exe + dist/theta-agent-tray-windows-*.exe + dist/theta-agent-*-setup.exe + dist/SHA256SUMS + + # DESIGN-WINDOWS.md §9: the SSO holds the release resources. Publish when + # an admin-gated upload endpoint is configured; otherwise the artifacts + # are available from the GitHub release above. + - name: Publish to SSO resource tree + if: env.SSO_RESOURCE_PUBLISH_URL != '' + shell: pwsh + run: | + $headers = @{ Authorization = "Bearer $env:SSO_RESOURCE_PUBLISH_TOKEN" } + foreach ($f in Get-ChildItem dist -File) { + if ($f.Name -notmatch "setup|windows-|SHA256SUMS") { continue } + Invoke-RestMethod -Method Post -Uri "$env:SSO_RESOURCE_PUBLISH_URL/$($f.Name)" -InFile $f.FullName -Headers $headers + } + env: + SSO_RESOURCE_PUBLISH_URL: ${{ secrets.SSO_RESOURCE_PUBLISH_URL }} + SSO_RESOURCE_PUBLISH_TOKEN: ${{ secrets.SSO_RESOURCE_PUBLISH_TOKEN }} diff --git a/agent.yml.example b/agent.yml.example index d8874dc..acc4606 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" @@ -30,8 +30,25 @@ location: "default" # Location identifier (e.g., site, datacenter) for naming # 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) @@ -52,6 +69,9 @@ capabilities: # Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md §6) iam: true + # Accept signed wireguard_apply/wireguard_remove commands (DESIGN-WINDOWS.md §5) + wireguard: false + # Secret templates to render (DESIGN.md §5). Each maps a local template to a # target file and an optional post-render reload. The template embeds secrets as # {{ bao "secret/data/nodes//#" }}. diff --git a/cmd/theta-agent-helper/main.go b/cmd/theta-agent-helper/main.go index 5a1bff4..bd83265 100644 --- a/cmd/theta-agent-helper/main.go +++ b/cmd/theta-agent-helper/main.go @@ -116,23 +116,32 @@ func sleepHost() { } } -// logoutSession logs off the active console session, or the session of the +// logoutSession logs off the active console session, or every session of the // given user (matched by WTS session enumeration). func logoutSession(user string) { - sessionID := activeConsoleSessionID() - if user != "" { - id, err := sessionForUser(user) + 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) } - sessionID = id } - if sessionID == 0 { + 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) @@ -146,25 +155,30 @@ func activeConsoleSessionID() uint32 { return uint32(id) } -func sessionForUser(user string) (uint32, error) { +// 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 0, fmt.Errorf("WTSEnumerateSessionsW: %v", err) + 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 { - return info.SessionID, nil + ids = append(ids, info.SessionID) } } - return 0, fmt.Errorf("no active session for user %q", user) + if len(ids) == 0 { + return nil, fmt.Errorf("no active session for user %q", user) + } + return ids, nil } func wtsSessionUsername(sessionID uint32) string { diff --git a/cmd/theta-agent-tray/main.go b/cmd/theta-agent-tray/main.go index f5d8c8f..ff8ab34 100644 --- a/cmd/theta-agent-tray/main.go +++ b/cmd/theta-agent-tray/main.go @@ -111,6 +111,8 @@ var ( mAutoVPN *systray.MenuItem mVPNToggle *systray.MenuItem mSeparator *systray.MenuItem + mOpenConfig *systray.MenuItem + mReinit *systray.MenuItem mQuit *systray.MenuItem currentStatus TrayStatus @@ -130,7 +132,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() @@ -156,6 +160,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() } diff --git a/config.go b/config.go index 97115b1..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 @@ -31,10 +32,13 @@ type SecretTarget struct { // WireGuardConfig holds the mesh client settings (DESIGN-WINDOWS.md §5). type WireGuardConfig struct { - // TunnelName is the Windows WireGuard tunnel/service name. + // 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 { @@ -55,6 +59,7 @@ type Config struct { 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"` } @@ -164,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 0837b7f..f6da234 100644 --- a/config_test.go +++ b/config_test.go @@ -206,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 index d5dbdbb..7fcf5dc 100755 Binary files a/dist/theta-agent-darwin-amd64 and b/dist/theta-agent-darwin-amd64 differ diff --git a/dist/theta-agent-darwin-arm64 b/dist/theta-agent-darwin-arm64 index d5dbdbb..7fcf5dc 100755 Binary files a/dist/theta-agent-darwin-arm64 and b/dist/theta-agent-darwin-arm64 differ diff --git a/dist/theta-agent-linux-amd64 b/dist/theta-agent-linux-amd64 index d5dbdbb..7fcf5dc 100755 Binary files a/dist/theta-agent-linux-amd64 and b/dist/theta-agent-linux-amd64 differ diff --git a/dist/theta-agent-linux-arm64 b/dist/theta-agent-linux-arm64 index d5dbdbb..7fcf5dc 100755 Binary files a/dist/theta-agent-linux-arm64 and b/dist/theta-agent-linux-arm64 differ diff --git a/dist/theta-agent-linux-armv7 b/dist/theta-agent-linux-armv7 index d5dbdbb..7fcf5dc 100755 Binary files a/dist/theta-agent-linux-armv7 and b/dist/theta-agent-linux-armv7 differ diff --git a/dist/theta-agent-tray-linux-amd64 b/dist/theta-agent-tray-linux-amd64 index 28adce0..f1fb139 100755 Binary files a/dist/theta-agent-tray-linux-amd64 and b/dist/theta-agent-tray-linux-amd64 differ diff --git a/dist/theta-agent-tray-linux-arm64 b/dist/theta-agent-tray-linux-arm64 index 28adce0..f1fb139 100755 Binary files a/dist/theta-agent-tray-linux-arm64 and b/dist/theta-agent-tray-linux-arm64 differ diff --git a/dist/theta-agent-tray-windows-amd64.exe b/dist/theta-agent-tray-windows-amd64.exe index 28adce0..f1fb139 100755 Binary files a/dist/theta-agent-tray-windows-amd64.exe and b/dist/theta-agent-tray-windows-amd64.exe differ diff --git a/dist/theta-agent-windows-amd64.exe b/dist/theta-agent-windows-amd64.exe index d5dbdbb..7fcf5dc 100755 Binary files a/dist/theta-agent-windows-amd64.exe and b/dist/theta-agent-windows-amd64.exe differ diff --git a/dist/theta-agent-windows-arm64.exe b/dist/theta-agent-windows-arm64.exe index d5dbdbb..7fcf5dc 100755 Binary files a/dist/theta-agent-windows-arm64.exe and b/dist/theta-agent-windows-arm64.exe differ diff --git a/home_detect.go b/home_detect.go index 442b8ed..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) { @@ -107,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..8bf2921 --- /dev/null +++ b/installer/windows/installer.iss @@ -0,0 +1,125 @@ +; 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_... +; +; /SERVER_URL and /JOIN_KEY are written into agent.yml so the installed service +; enrolls on first start (no UI, no extra click). + +#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. Install happens offline +; (the driver is signed; no signature phone-home). +Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi"; DestDir: "{app}\vendor"; Flags: ignoreversion +Source: "{#VendorDir}\wireguard-amd64-0.5.3.msi.sha256"; DestDir: "{app}\vendor"; Flags: ignoreversion + +; OpenCredential credential provider (BSD-3 pGina fork) + VC++ runtime it needs. +Source: "{#VendorDir}\OpenCredential\*"; DestDir: "{app}\OpenCredential"; Flags: ignoreversion recursesubdirs +Source: "{#VendorDir}\vc_redist.x64.exe"; DestDir: "{app}\vendor"; Flags: ignoreversion +Source: "{#VendorDir}\vc_redist.x64.exe.sha256"; DestDir: "{app}\vendor"; Flags: ignoreversion + +[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 +; 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 +; Register the agent as a SYSTEM auto-start service. +Filename: "{app}\{#MyAppExeName}"; Parameters: "install-service"; StatusMsg: "Registering theta-agent service..."; Flags: runhidden waituntilterminated +; Credential provider registration + agent.yml are handled in [Code] so we can +; feed SERVER_URL/JOIN_KEY in and sequence the CP install before logon. +Filename: "{app}\OpenCredential\OpenCredentialInstaller.exe"; Parameters: "/S"; StatusMsg: "Installing OpenCredential credential provider..."; Flags: runhidden waituntilterminated skipifsilent + +[Code] +var + ServerURL: String; + JoinKey: String; + +function InitializeSetup(): Boolean; +begin + ServerURL := GetCmdLineParam('/SERVER_URL', ''); + JoinKey := GetCmdLineParam('/JOIN_KEY', ''); + Result := True; +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; +begin + SetArrayLength(Lines, 13); + Lines[0] := '# theta-agent configuration (written by installer)'; + Lines[1] := 'server_url: "' + ServerURL + '"'; + Lines[2] := 'auth_token: ""'; + Lines[3] := 'join_key: "' + JoinKey + '"'; + Lines[4] := 'public_key: ""'; + 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'; + SaveStringsToUTF8File(ConfigPath, Lines, False); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + WriteAgentConfig(ExpandConstant('{commonappdata}\Theta42\agent.yml')); +end; diff --git a/main.go b/main.go index 7328c4a..50026ce 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,10 @@ var ( 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) }) @@ -71,6 +75,10 @@ func runAgent() { // 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() diff --git a/paths.go b/paths.go index 920c457..cf326e6 100644 --- a/paths.go +++ b/paths.go @@ -45,3 +45,13 @@ func defaultLdapSocketPath() string { 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 index ce86adb..dd6f288 100644 --- a/platform_factory_other.go +++ b/platform_factory_other.go @@ -5,5 +5,17 @@ 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 { - return &linuxPlatformOps{exec: exec} + 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 index 3b00f01..53163e7 100644 --- a/platform_factory_windows.go +++ b/platform_factory_windows.go @@ -4,9 +4,20 @@ 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 index cc19cd4..a5b58cd 100644 --- a/platform_linuxops.go +++ b/platform_linuxops.go @@ -15,7 +15,9 @@ import ( ) type linuxPlatformOps struct { - exec Executor + exec Executor + tunnelName string // WireGuard interface/tunnel name + confPath string // persisted peer config path } func (p *linuxPlatformOps) Reboot() ([]byte, error) { @@ -156,3 +158,58 @@ func (p *linuxPlatformOps) ApplyUpdate(downloadURL, checksum string) error { 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 index 3e357f6..efc2a8d 100644 --- a/platform_ops.go +++ b/platform_ops.go @@ -45,6 +45,19 @@ type PlatformOps interface { // 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. diff --git a/platform_windows.go b/platform_windows.go index 2e7f479..3943724 100644 --- a/platform_windows.go +++ b/platform_windows.go @@ -23,6 +23,7 @@ import ( "log" "os" "os/exec" + "path/filepath" "strings" "syscall" "unicode/utf16" @@ -34,6 +35,9 @@ 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) { @@ -176,6 +180,111 @@ 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...) diff --git a/tray_server.go b/tray_server.go index 4233d40..e728487 100644 --- a/tray_server.go +++ b/tray_server.go @@ -15,7 +15,9 @@ import ( "log" "net" "os" + "os/exec" "path/filepath" + "runtime" "strings" "sync" ) @@ -95,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 f3f12e1..e073f72 100644 --- a/websocket.go +++ b/websocket.go @@ -524,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") diff --git a/websocket_test.go b/websocket_test.go index 33f4f13..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{ @@ -248,9 +290,14 @@ func TestHandleCommand(t *testing.T) { // 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). + // 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} + defaultPlatformOps = &linuxPlatformOps{ + exec: mockExec, + tunnelName: "theta-mesh", + confPath: filepath.Join(t.TempDir(), "theta-mesh.conf"), + } defer func() { defaultPlatformOps = prevOps }() msg := tc.msg