feat(windows): WireGuard client, auto-VPN, IAM, tray enrichment, installer, CI

Second milestone of the Windows parity work (DESIGN-WINDOWS.md §13).

WireGuard mesh client (signed, WSS-delivered):
- wireguard_apply / wireguard_remove commands: Ed25519-verified, gated on a new
  wireguard capability. Linux applies via wg-quick up/down; Windows installs the
  peer config as a WireGuardTunnel service via wireguard.exe
  (/installtunnelservice, /uninstalltunnelservice)
- state polling in the home monitor drives the blue tray icon and auto-VPN:
  connect when away from home + auto_vpn, disconnect on return (2m cooldown)
- tray VPN toggle and the auto-VPN checkbox are now live; the preference
  persists to agent.yml (PersistAutoVPN)

IAM on Windows (iam_windows.go):
- allowed_login_groups -> net localgroup; ssh_keys -> per-profile
  authorized_keys + %ProgramData%\ssh\administrators_authorized_keys;
  revoke_users -> helper logs off all of the user's WTS sessions;
  sudo_rules logged as no direct equivalent

Tray enrichment:
- Open Config (opens agent.yml), Clear enrollment (re-enroll) menu items
- set_auto_vpn persists; vpn_connect/vpn_disconnect/reinit/open_config commands
  handled by the daemon (tray_server.go)

Packaging & release:
- installer/windows/installer.iss: fully-offline Inno Setup bundle (agent,
  tray, helper, vendor-signed WireGuard MSI, OpenCredential CP, VC++ redist),
  /SILENT /SERVER_URL /JOIN_KEY parameters, SYSTEM service + HKLM Run tray
  autostart, Users-writable %ProgramData%\Theta42 for the IPC socket
- .github/workflows/build-windows.yml: build + test, pinned vendor downloads,
  ISCC compile, Azure Trusted Signing (OIDC), SHA256SUMS, GH release attach,
  optional SSO resource publish
- agent.yml.example documents auto_vpn, wireguard, service_name,
  desktop_helper, public_ip_detect

Tests:
- wireguard_apply/remove dispatch (allowed + capability-denied), PersistAutoVPN,
  ClearEnrollment; dispatch tests pin linuxPlatformOps with a temp WireGuard conf
- end-to-end verified against a local mock SSO on Windows: join-key enrollment
  (token persisted, join key blanked), discovery/telemetry pushed, signed
  arbitrary_bash verified + executed via powershell -EncodedCommand; tray IPC
  socket binds %ProgramData%\Theta42; LDAP byte-pump binds 127.0.0.1:389;
  helper update swap verified

Rebuilds all tracked dist binaries (v2.1.0).
This commit is contained in:
2026-08-09 17:49:40 -07:00
parent 4a619f7adc
commit e613874de5
29 changed files with 920 additions and 22 deletions
+143
View File
@@ -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 }}
+21 -1
View File
@@ -1,5 +1,5 @@
# theta-agent configuration file # theta-agent configuration file
# Default location: /etc/theta42/agent.yml # Default location: /etc/theta42/agent.yml (Linux) or %ProgramData%\Theta42\agent.yml (Windows)
server_url: "https://sso.example.com" server_url: "https://sso.example.com"
@@ -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 # from this socket to the SSO, which relays them into its OpenLDAP. The agent
# never parses LDAP. Point SSSD at it with: # never parses LDAP. Point SSSD at it with:
# ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock # ldap_uri = ldapi://%2frun%2ftheta%2fldap.sock
# (Windows relies on the TCP loopback listener 127.0.0.1:389 instead.)
ldap_socket: "/run/theta/ldap.sock" ldap_socket: "/run/theta/ldap.sock"
# Auto-connect the WireGuard tunnel when this host is away from home and the
# directory WebSocket is up. The tray checkbox persists here too.
auto_vpn: false
# Windows-specific (DESIGN-WINDOWS.md §11). Ignored on Linux.
service_name: "theta-agent" # Windows service name
desktop_helper: "" # theta-agent-helper.exe path (session-0 ops)
public_ip_detect: true # false = air-gap: never call external IP services
# WireGuard mesh client (DESIGN-WINDOWS.md §5). The signed wireguard_apply
# command pushes the peer config down the WSS channel; these are local paths.
wireguard:
tunnel_name: "theta-mesh"
conf: "" # "" = platform default (/etc/wireguard/... or %ProgramData%\Theta42\wg\...)
executable: "" # wireguard.exe path (Windows; "" = PATH/default install)
capabilities: capabilities:
# --------------------------------------------------------- # ---------------------------------------------------------
# Basic Capabilities (Safe, read-only or infrastructure management) # Basic Capabilities (Safe, read-only or infrastructure management)
@@ -52,6 +69,9 @@ capabilities:
# Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md §6) # Apply node IAM (sudo rules, SSH keys, access control, revocation) (DESIGN.md §6)
iam: true iam: true
# 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 # Secret templates to render (DESIGN.md §5). Each maps a local template to a
# target file and an optional post-render reload. The template embeds secrets as # target file and an optional post-render reload. The template embeds secrets as
# {{ bao "secret/data/nodes/<node-id>/<name>#<key>" }}. # {{ bao "secret/data/nodes/<node-id>/<name>#<key>" }}.
+24 -10
View File
@@ -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). // given user (matched by WTS session enumeration).
func logoutSession(user string) { func logoutSession(user string) {
sessionID := activeConsoleSessionID() var ids []uint32
if user != "" { if user == "" {
id, err := sessionForUser(user) if id := activeConsoleSessionID(); id != 0 {
ids = []uint32{id}
}
} else {
var err error
ids, err = sessionsForUser(user)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "logout: %v\n", err) fmt.Fprintf(os.Stderr, "logout: %v\n", err)
os.Exit(1) os.Exit(1)
} }
sessionID = id
} }
if sessionID == 0 { if len(ids) == 0 {
fmt.Fprintln(os.Stderr, "logout: no active session to log off") fmt.Fprintln(os.Stderr, "logout: no active session to log off")
os.Exit(1) os.Exit(1)
} }
for _, id := range ids {
logoffSession(id)
}
}
func logoffSession(sessionID uint32) {
r, _, err := procWTSLogoffSession.Call(wtsCurrentServerHandle, uintptr(sessionID), 0) r, _, err := procWTSLogoffSession.Call(wtsCurrentServerHandle, uintptr(sessionID), 0)
if r == 0 { if r == 0 {
fmt.Fprintf(os.Stderr, "WTSLogoffSession(%d) failed: %v\n", sessionID, err) fmt.Fprintf(os.Stderr, "WTSLogoffSession(%d) failed: %v\n", sessionID, err)
@@ -146,25 +155,30 @@ func activeConsoleSessionID() uint32 {
return uint32(id) 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 pInfo *wtsSessionInfo
var count uint32 var count uint32
r, _, err := procWTSEnumerateSessionsW.Call(wtsCurrentServerHandle, 0, 1, uintptr(unsafe.Pointer(&pInfo)), uintptr(unsafe.Pointer(&count))) r, _, err := procWTSEnumerateSessionsW.Call(wtsCurrentServerHandle, 0, 1, uintptr(unsafe.Pointer(&pInfo)), uintptr(unsafe.Pointer(&count)))
if r == 0 { if r == 0 {
return 0, fmt.Errorf("WTSEnumerateSessionsW: %v", err) return nil, fmt.Errorf("WTSEnumerateSessionsW: %v", err)
} }
defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pInfo))) defer procWTSFreeMemory.Call(uintptr(unsafe.Pointer(pInfo)))
want := strings.ToLower(user) want := strings.ToLower(user)
var ids []uint32
for i := uint32(0); i < count; i++ { for i := uint32(0); i < count; i++ {
info := (*wtsSessionInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(pInfo)) + uintptr(i)*unsafe.Sizeof(*pInfo))) info := (*wtsSessionInfo)(unsafe.Pointer(uintptr(unsafe.Pointer(pInfo)) + uintptr(i)*unsafe.Sizeof(*pInfo)))
name := wtsSessionUsername(info.SessionID) name := wtsSessionUsername(info.SessionID)
if name != "" && strings.ToLower(name) == want { 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 { func wtsSessionUsername(sessionID uint32) string {
+11 -1
View File
@@ -111,6 +111,8 @@ var (
mAutoVPN *systray.MenuItem mAutoVPN *systray.MenuItem
mVPNToggle *systray.MenuItem mVPNToggle *systray.MenuItem
mSeparator *systray.MenuItem mSeparator *systray.MenuItem
mOpenConfig *systray.MenuItem
mReinit *systray.MenuItem
mQuit *systray.MenuItem mQuit *systray.MenuItem
currentStatus TrayStatus 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) 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") mVPNToggle = systray.AddMenuItem("Connect VPN", "Manually connect or disconnect the WireGuard tunnel")
systray.AddSeparator() 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 ── // ── IPC loop — connect with retry ──
go connectWithRetry() go connectWithRetry()
@@ -156,6 +160,12 @@ func onReady() {
sendCmd(TrayCommand{Command: "vpn_connect"}) sendCmd(TrayCommand{Command: "vpn_connect"})
} }
case <-mOpenConfig.ClickedCh:
sendCmd(TrayCommand{Command: "open_config"})
case <-mReinit.ClickedCh:
sendCmd(TrayCommand{Command: "reinit"})
case <-mQuit.ClickedCh: case <-mQuit.ClickedCh:
systray.Quit() systray.Quit()
} }
+64 -2
View File
@@ -19,6 +19,7 @@ type Capabilities struct {
LdapTunnel bool `yaml:"ldap_tunnel"` LdapTunnel bool `yaml:"ldap_tunnel"`
Secrets bool `yaml:"secrets"` Secrets bool `yaml:"secrets"`
IAM bool `yaml:"iam"` IAM bool `yaml:"iam"`
WireGuard bool `yaml:"wireguard"`
} }
// SecretTarget maps a local template to a rendered target file and an optional // SecretTarget maps a local template to a rendered target file and an optional
@@ -31,10 +32,13 @@ type SecretTarget struct {
// WireGuardConfig holds the mesh client settings (DESIGN-WINDOWS.md §5). // WireGuardConfig holds the mesh client settings (DESIGN-WINDOWS.md §5).
type WireGuardConfig struct { 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"` TunnelName string `yaml:"tunnel_name"`
// Conf is where the pushed peer config is persisted on disk. // Conf is where the pushed peer config is persisted on disk.
Conf string `yaml:"conf"` Conf string `yaml:"conf"`
// Executable is the wireguard.exe path (Windows); "" = PATH or default
// install location.
Executable string `yaml:"executable"`
} }
type Config struct { type Config struct {
@@ -55,6 +59,7 @@ type Config struct {
ServiceName string `yaml:"service_name"` // Windows service name ServiceName string `yaml:"service_name"` // Windows service name
DesktopHelper string `yaml:"desktop_helper"` // theta-agent-helper.exe path DesktopHelper string `yaml:"desktop_helper"` // theta-agent-helper.exe path
PublicIPDetect *bool `yaml:"public_ip_detect"` // false disables external lookups (air-gap) 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"` WireGuard WireGuardConfig `yaml:"wireguard"`
} }
@@ -164,12 +169,69 @@ func (cm *ConfigManager) PersistEnrollment(token, publicKey string) error {
return nil return nil
} }
// PersistAutoVPN writes the tray's auto-VPN preference back into agent.yml so
// it survives a restart. Same line-preserving edit as PersistEnrollment.
func (cm *ConfigManager) PersistAutoVPN(value bool) error {
cm.mu.Lock()
defer cm.mu.Unlock()
raw, err := os.ReadFile(cm.configPath)
if err != nil {
return fmt.Errorf("read %s: %w", cm.configPath, err)
}
out := setYamlScalarValue(string(raw), "auto_vpn", fmt.Sprintf("%t", value), false)
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
return fmt.Errorf("write %s: %w", cm.configPath, err)
}
cfg, err := LoadConfig(cm.configPath)
if err != nil {
return fmt.Errorf("reload after auto_vpn: %w", err)
}
cm.current = cfg
return nil
}
// ClearEnrollment blanks the auth_token and public_key so the agent re-enrolls
// with whatever join_key is configured. Triggered by the tray's "re-enroll".
func (cm *ConfigManager) ClearEnrollment() error {
cm.mu.Lock()
defer cm.mu.Unlock()
raw, err := os.ReadFile(cm.configPath)
if err != nil {
return fmt.Errorf("read %s: %w", cm.configPath, err)
}
out := setYamlScalar(string(raw), "auth_token", "")
out = setYamlScalar(out, "public_key", "")
if err := os.WriteFile(cm.configPath, []byte(out), 0600); err != nil {
return fmt.Errorf("write %s: %w", cm.configPath, err)
}
cfg, err := LoadConfig(cm.configPath)
if err != nil {
return fmt.Errorf("reload after enrollment clear: %w", err)
}
cm.current = cfg
return nil
}
// setYamlScalar replaces the value of a top-level `key: "..."` line, or appends // setYamlScalar replaces the value of a top-level `key: "..."` line, or appends
// the key when it is absent. Deliberately line-based rather than a YAML // the key when it is absent. Deliberately line-based rather than a YAML
// round-trip so comments and formatting survive. // round-trip so comments and formatting survive.
func setYamlScalar(doc, key, value string) string { func setYamlScalar(doc, key, value string) string {
return setYamlScalarValue(doc, key, value, true)
}
// setYamlScalarValue is setYamlScalar with control over quoting. Numeric/bool
// scalars (e.g. auto_vpn: true) must stay unquoted or YAML decodes them as
// strings.
func setYamlScalarValue(doc, key, value string, quote bool) string {
line := fmt.Sprintf("%s: %s", key, value)
if quote {
line = fmt.Sprintf("%s: %q", key, value)
}
re := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*:.*$`) re := regexp.MustCompile(`(?m)^[ \t]*` + regexp.QuoteMeta(key) + `[ \t]*:.*$`)
line := fmt.Sprintf("%s: %q", key, value)
if re.MatchString(doc) { if re.MatchString(doc) {
return re.ReplaceAllString(doc, line) return re.ReplaceAllString(doc, line)
} }
+52
View File
@@ -206,3 +206,55 @@ func TestPersistEnrollmentRejectsEmptyToken(t *testing.T) {
t.Error("expected an error when the server sends no token") t.Error("expected an error when the server sends no token")
} }
} }
// TestPersistAutoVPN writes the tray preference into the file and reloads it.
func TestPersistAutoVPN(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\njoin_key: \"tjk_abc123\"\n"), 0600)
cm, err := NewConfigManager(path)
if err != nil {
t.Fatal(err)
}
if err := cm.PersistAutoVPN(true); err != nil {
t.Fatalf("PersistAutoVPN: %v", err)
}
if !cm.Get().AutoVPN {
t.Errorf("AutoVPN should be true after persist")
}
out, _ := os.ReadFile(path)
if !strings.Contains(string(out), "auto_vpn: true") {
t.Errorf("expected auto_vpn: true in file, got:\n%s", out)
}
if err := cm.PersistAutoVPN(false); err != nil {
t.Fatalf("PersistAutoVPN(false): %v", err)
}
if cm.Get().AutoVPN {
t.Errorf("AutoVPN should be false after persist")
}
}
// TestClearEnrollment blanks credentials so the agent re-enrolls.
func TestClearEnrollment(t *testing.T) {
dir := t.TempDir()
path := dir + "/agent.yml"
os.WriteFile(path, []byte("server_url: \"https://sso.example.com\"\nauth_token: \"tok-abc\"\npublic_key: \"PK\"\njoin_key: \"tjk_keep\"\n"), 0600)
cm, err := NewConfigManager(path)
if err != nil {
t.Fatal(err)
}
if err := cm.ClearEnrollment(); err != nil {
t.Fatalf("ClearEnrollment: %v", err)
}
cfg := cm.Get()
if cfg.AuthToken != "" || cfg.PublicKey != "" {
t.Errorf("expected cleared credentials, got token=%q pub=%q", cfg.AuthToken, cfg.PublicKey)
}
out, _ := os.ReadFile(path)
if strings.Contains(string(out), "tok-abc") {
t.Errorf("old token should be gone from file, got:\n%s", out)
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+72
View File
@@ -67,6 +67,21 @@ func SetVPNActive(active bool) {
homeState.mu.Unlock() 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 // StartHomeMonitor periodically refreshes the agent's public IP and pushes
// updated tray status. Call as a goroutine from main(). // updated tray status. Call as a goroutine from main().
func StartHomeMonitor(cfg *Config, connectedFn func() bool) { func StartHomeMonitor(cfg *Config, connectedFn func() bool) {
@@ -107,5 +122,62 @@ func checkAndPush(cfg *Config, connectedFn func() bool) {
siteName = "home" 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) 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
}
}
+60
View File
@@ -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")
}
+125
View File
@@ -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;
+8
View File
@@ -23,6 +23,10 @@ var (
agentStopCh = make(chan struct{}) 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. // stopAgent signals the running agent to shut down. Idempotent.
func stopAgent() { func stopAgent() {
agentStopOnce.Do(func() { close(agentStopCh) }) agentStopOnce.Do(func() { close(agentStopCh) })
@@ -71,6 +75,10 @@ func runAgent() {
// dispatcher runs behind. // dispatcher runs behind.
exec := &SystemExecutor{} exec := &SystemExecutor{}
defaultPlatformOps = NewPlatformOps(cfg, exec) 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. // Tray IPC server — desktop tray connects here for status updates.
go globalTrayServer.Start() go globalTrayServer.Start()
+10
View File
@@ -45,3 +45,13 @@ func defaultLdapSocketPath() string {
func windowsTraySocketPath() string { func windowsTraySocketPath() string {
return filepath.Join(windowsDataDir(), "tray.sock") 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")
}
+13 -1
View File
@@ -5,5 +5,17 @@ package main
// NewPlatformOps returns the Linux/POSIX implementation (systemctl, journalctl, // NewPlatformOps returns the Linux/POSIX implementation (systemctl, journalctl,
// bash). It is the fallback for any non-Windows host. // bash). It is the fallback for any non-Windows host.
func NewPlatformOps(cfg *Config, exec Executor) PlatformOps { 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,
}
} }
+11
View File
@@ -4,9 +4,20 @@ package main
// NewPlatformOps returns the Windows implementation. // NewPlatformOps returns the Windows implementation.
func NewPlatformOps(cfg *Config, exec Executor) PlatformOps { 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{ return &windowsPlatformOps{
exec: exec, exec: exec,
helperPath: cfg.DesktopHelper, helperPath: cfg.DesktopHelper,
serviceName: cfg.ServiceName, serviceName: cfg.ServiceName,
tunnelName: name,
confPath: conf,
wgExe: cfg.WireGuard.Executable,
} }
} }
+58 -1
View File
@@ -15,7 +15,9 @@ import (
) )
type linuxPlatformOps struct { 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) { func (p *linuxPlatformOps) Reboot() ([]byte, error) {
@@ -156,3 +158,58 @@ func (p *linuxPlatformOps) ApplyUpdate(downloadURL, checksum string) error {
func (p *linuxPlatformOps) SelfRestart() { func (p *linuxPlatformOps) SelfRestart() {
os.Exit(0) os.Exit(0)
} }
// ApplyWireGuard persists the peer config and brings the tunnel up with
// wg-quick (wg-quick reads /etc/wireguard/<name>.conf by interface name).
func (p *linuxPlatformOps) ApplyWireGuard(conf string) error {
if err := os.MkdirAll(filepath.Dir(p.confPath), 0700); err != nil {
return fmt.Errorf("wireguard: create config dir: %w", err)
}
if err := os.WriteFile(p.confPath, []byte(conf), 0600); err != nil {
return fmt.Errorf("wireguard: persist config: %w", err)
}
out, err := p.exec.Execute("wg-quick", "up", p.tunnelName)
if err != nil {
return fmt.Errorf("wireguard: wg-quick up %s: %v: %s", p.tunnelName, err, out)
}
return nil
}
func (p *linuxPlatformOps) RemoveWireGuard() error {
out, err := p.exec.Execute("wg-quick", "down", p.tunnelName)
if err != nil {
return fmt.Errorf("wireguard: wg-quick down %s: %v: %s", p.tunnelName, err, out)
}
return nil
}
// WireGuardState reports whether the tunnel interface exists.
func (p *linuxPlatformOps) WireGuardState() bool {
out, err := p.exec.Execute("ip", "link", "show", p.tunnelName)
return err == nil && len(out) > 0
}
// ConnectWireGuard brings the persisted config up unless already active.
func (p *linuxPlatformOps) ConnectWireGuard() error {
if p.WireGuardState() {
return nil
}
conf, err := os.ReadFile(p.confPath)
if err != nil {
return fmt.Errorf("wireguard: no persisted config at %s: %w", p.confPath, err)
}
return p.ApplyWireGuard(string(conf))
}
func (p *linuxPlatformOps) DisconnectWireGuard() error {
if !p.WireGuardState() {
return nil
}
return p.RemoveWireGuard()
}
// ApplyIAM is the Linux node-identity engine (sudoers.d, SSH keys, PAM access,
// SSSD cache flush + session kill).
func (p *linuxPlatformOps) ApplyIAM(payload IAMPayload) error {
return applyIAM(payload, p.exec)
}
+13
View File
@@ -45,6 +45,19 @@ type PlatformOps interface {
// SelfRestart terminates the agent so a staged update takes effect. Linux // SelfRestart terminates the agent so a staged update takes effect. Linux
// exits; the Windows service handler stops itself (the helper restarts it). // exits; the Windows service handler stops itself (the helper restarts it).
SelfRestart() 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. // defaultPlatformOps is the ops implementation the command dispatcher uses.
+109
View File
@@ -23,6 +23,7 @@ import (
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"syscall" "syscall"
"unicode/utf16" "unicode/utf16"
@@ -34,6 +35,9 @@ type windowsPlatformOps struct {
exec Executor exec Executor
helperPath string // theta-agent-helper.exe (DESIGN-WINDOWS.md §8) helperPath string // theta-agent-helper.exe (DESIGN-WINDOWS.md §8)
serviceName string // Windows service name (defaults to theta-agent) 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) { func (p *windowsPlatformOps) Reboot() ([]byte, error) {
@@ -176,6 +180,111 @@ func (p *windowsPlatformOps) SelfRestart() {
stopAgent() stopAgent()
} }
// wireGuardExe resolves the WireGuard client executable: explicit config path,
// PATH lookup, or the default install location.
func (p *windowsPlatformOps) wireGuardExe() string {
if p.wgExe != "" {
return p.wgExe
}
const defaultInstall = `C:\Program Files\WireGuard\wireguard.exe`
if _, err := os.Stat(defaultInstall); err == nil {
return defaultInstall
}
return "wireguard.exe"
}
// ApplyWireGuard persists the peer config and installs it as a WireGuard
// service via the official client (wireguard.exe /installtunnelservice).
func (p *windowsPlatformOps) ApplyWireGuard(conf string) error {
if err := os.MkdirAll(filepath.Dir(p.confPath), 0700); err != nil {
return fmt.Errorf("wireguard: create config dir: %w", err)
}
if err := os.WriteFile(p.confPath, []byte(conf), 0600); err != nil {
return fmt.Errorf("wireguard: persist config: %w", err)
}
out, err := p.exec.Execute(p.wireGuardExe(), "/installtunnelservice", p.tunnelName, p.confPath)
if err != nil {
return fmt.Errorf("wireguard: installtunnelservice %s: %v: %s", p.tunnelName, err, out)
}
return nil
}
func (p *windowsPlatformOps) RemoveWireGuard() error {
out, err := p.exec.Execute(p.wireGuardExe(), "/uninstalltunnelservice", p.tunnelName)
if err != nil {
return fmt.Errorf("wireguard: uninstalltunnelservice %s: %v: %s", p.tunnelName, err, out)
}
return nil
}
// WireGuardState reports whether the WireGuardTunnel$<name> service is running.
func (p *windowsPlatformOps) WireGuardState() bool {
out, err := p.exec.Execute("sc.exe", "query", "WireGuardTunnel$"+p.tunnelName)
if err != nil {
return false
}
return strings.Contains(strings.ToUpper(string(out)), "RUNNING")
}
// ConnectWireGuard brings the persisted config up unless already active.
func (p *windowsPlatformOps) ConnectWireGuard() error {
if p.WireGuardState() {
return nil
}
conf, err := os.ReadFile(p.confPath)
if err != nil {
return fmt.Errorf("wireguard: no persisted config at %s: %w", p.confPath, err)
}
return p.ApplyWireGuard(string(conf))
}
func (p *windowsPlatformOps) DisconnectWireGuard() error {
if !p.WireGuardState() {
return nil
}
return p.RemoveWireGuard()
}
// ApplyIAM maps node identity onto local Windows security (DESIGN-WINDOWS.md
// §4): local groups for allowed_login_groups, per-user authorized_keys for
// OpenSSH, and session logoff via the helper for revocation.
func (p *windowsPlatformOps) ApplyIAM(payload IAMPayload) error {
ac := payload.AccessControl
for _, g := range ac.AllowedLoginGroups {
if g == "" {
continue
}
if _, err := p.exec.Execute("net", "localgroup", g, "/add"); err != nil {
log.Printf("[iam] net localgroup %s /add: %v", g, err)
}
}
if len(ac.SSHKeys) > 0 {
if err := applyWindowsSSHKeys(ac.SSHKeys); err != nil {
log.Printf("[iam] ssh keys: %v", err)
}
}
for _, u := range ac.RevokeUsers {
if u == "" {
continue
}
if p.helperPath != "" {
if _, err := p.exec.Execute(p.helperPath, "logout", u); err != nil {
log.Printf("[iam] revoke %s: %v", u, err)
}
} else {
log.Printf("[iam] revoke %s: desktop_helper not configured; no sessions logged off", u)
}
}
if len(ac.SudoRules) > 0 {
log.Println("[iam] sudo_rules have no direct Windows equivalent; mapped to local group membership (UAC elevation policy)")
}
return nil
}
// spawnDetached launches exe as a background process that survives this one. // spawnDetached launches exe as a background process that survives this one.
func spawnDetached(exe string, args ...string) error { func spawnDetached(exe string, args ...string) error {
cmd := execCommand(exe, args...) cmd := execCommand(exe, args...)
+35 -3
View File
@@ -15,7 +15,9 @@ import (
"log" "log"
"net" "net"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"sync" "sync"
) )
@@ -95,19 +97,49 @@ func (ts *trayServer) handleCommand(cmd TrayCommand) {
ts.mu.Lock() ts.mu.Lock()
ts.status.AutoVPN = cmd.Value ts.status.AutoVPN = cmd.Value
ts.mu.Unlock() 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) log.Printf("[tray-ipc] auto_vpn set to %v", cmd.Value)
// TODO: persist to agent.yml
case "vpn_connect": case "vpn_connect":
log.Printf("[tray-ipc] VPN connect requested") 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": case "vpn_disconnect":
log.Printf("[tray-ipc] VPN disconnect requested") 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: default:
log.Printf("[tray-ipc] unknown command: %q", cmd.Command) log.Printf("[tray-ipc] unknown command: %q", cmd.Command)
} }
} }
// openInDefaultViewer opens a file/folder with the platform default handler.
func openInDefaultViewer(path string) {
if runtime.GOOS == "windows" {
// explorer /select,<path> opens the parent folder with the file
// selected. explorer is a GUI app, so no console window appears.
_ = exec.Command("explorer", "/select,"+path).Start()
return
}
_ = exec.Command("xdg-open", path).Start()
}
// Push broadcasts an updated status to all connected tray clients. // Push broadcasts an updated status to all connected tray clients.
func (ts *trayServer) Push(status TrayStatus) { func (ts *trayServer) Push(status TrayStatus) {
ts.mu.Lock() ts.mu.Lock()
+42 -1
View File
@@ -524,12 +524,53 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
return return
} }
log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID) log.Printf("Applying IAM revision %d for node %s...", payload.Revision, payload.NodeID)
if err := applyIAM(payload, exec); err != nil { if err := defaultPlatformOps.ApplyIAM(payload); err != nil {
log.Printf("IAM apply failed: %v", err) log.Printf("IAM apply failed: %v", err)
sendResponse("error", fmt.Sprintf("iam apply failed: %v", err)) sendResponse("error", fmt.Sprintf("iam apply failed: %v", err))
return return
} }
sendResponse("ok", "iam applied") sendResponse("ok", "iam applied")
case "wireguard_apply":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
if !cfg.Capabilities.WireGuard {
log.Println("WireGuard apply rejected: capability disabled in agent.yml")
sendResponse("error", "wireguard capability disabled")
return
}
conf, _ := msg.Payload["config"].(string)
if conf == "" {
sendResponse("error", "missing wireguard config")
return
}
log.Printf("Applying WireGuard peer config...")
if err := defaultPlatformOps.ApplyWireGuard(conf); err != nil {
log.Printf("WireGuard apply failed: %v", err)
sendResponse("error", fmt.Sprintf("wireguard apply failed: %v", err))
return
}
SetVPNActive(true)
sendResponse("ok", "wireguard applied")
case "wireguard_remove":
if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed")
return
}
if !cfg.Capabilities.WireGuard {
log.Println("WireGuard remove rejected: capability disabled in agent.yml")
sendResponse("error", "wireguard capability disabled")
return
}
log.Printf("Removing WireGuard tunnel...")
if err := defaultPlatformOps.RemoveWireGuard(); err != nil {
log.Printf("WireGuard remove failed: %v", err)
sendResponse("error", fmt.Sprintf("wireguard remove failed: %v", err))
return
}
SetVPNActive(false)
sendResponse("ok", "wireguard removed")
case "arbitrary_bash": case "arbitrary_bash":
if !verifySignature(cfg, msg) { if !verifySignature(cfg, msg) {
sendResponse("error", "signature verification failed") sendResponse("error", "signature verification failed")
+49 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath"
"testing" "testing"
) )
@@ -218,6 +219,47 @@ func TestHandleCommand(t *testing.T) {
expectedStatus: "error", expectedStatus: "error",
expectedCmd: nil, expectedCmd: nil,
}, },
{
name: "wireguard_apply allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{WireGuard: true},
},
msg: WSMessage{
Type: "wireguard_apply",
Payload: map[string]interface{}{
"config": "[Interface]\nAddress = 10.0.0.2/32\n",
},
},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"wg-quick", "up", "theta-mesh"},
},
{
name: "wireguard_apply denied",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{WireGuard: false},
},
msg: WSMessage{
Type: "wireguard_apply",
Payload: map[string]interface{}{"config": "[Interface]\n"},
},
signed: true,
expectedStatus: "error",
expectedCmd: nil,
},
{
name: "wireguard_remove allowed",
cfg: &Config{
PublicKey: testPubKeyB64(),
Capabilities: Capabilities{WireGuard: true},
},
msg: WSMessage{Type: "wireguard_remove"},
signed: true,
expectedStatus: "ok",
expectedCmd: []string{"wg-quick", "down", "theta-mesh"},
},
{ {
name: "heartbeat_ack is silently ignored", name: "heartbeat_ack is silently ignored",
cfg: &Config{ cfg: &Config{
@@ -248,9 +290,14 @@ func TestHandleCommand(t *testing.T) {
// The dispatch tests assert the exact command lines the Linux // The dispatch tests assert the exact command lines the Linux
// executor produces; pin the platform ops so they behave the same // 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 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 }() defer func() { defaultPlatformOps = prevOps }()
msg := tc.msg msg := tc.msg