Files
theta-agent/home_detect.go
T
wmantly 4a619f7adc feat(windows): platform ops, service wrapper, helper, and air-gap paths
First Windows parity milestone (DESIGN-WINDOWS.md §13 build order item 1).

- Add a PlatformOps abstraction so command dispatch is OS-neutral:
  - linuxPlatformOps keeps today's systemctl/journalctl/bash behavior (deliberately
    untagged so shared dispatch tests run on Windows CI)
  - windowsPlatformOps maps reboot/shutdown to shutdown.exe, service control to
    sc.exe (stop+start for restart), fetch_logs to Get-WinEvent, arbitrary_bash to
    powershell -EncodedCommand (byte-exact under arbitrary quoting), and declines
    configure_ldap (Windows logon goes through OpenCredential)
- Run theta-agent as a Windows service (x/sys/windows/svc): SYSTEM auto-start,
    SCM stop/shutdown handling; CLI install-service/remove-service via svc/mgr
- Add theta-agent-helper (session-0 companion): lock/display_off/logout via
    user32/wtsapi32, and staged self-update (wait for service stop, swap the
    locked exe, sc start)
- Self-update becomes platform-aware: Linux renames over the running binary;
    Windows stages .new and hands the swap to the helper (running exe is locked)
- Platform paths: agent.yml and tray.sock under %ProgramData%\Theta42 (the
    service runs as SYSTEM while the tray runs as the user, so the per-user temp
    dir no longer works for tray IPC); LDAP byte-pump falls back to TCP loopback
- config: service_name, desktop_helper, public_ip_detect (air-gap: skips
    external public-IP lookups in telemetry + home monitor), wireguard block
- cli: platform-aware config path + self-update artifact name + service restart
- tests: dispatch tests pin linuxPlatformOps; 0600 mode assertions gated to
    POSIX so the suite is green on Windows

Rebuilds all tracked dist binaries (v2.1.0).
2026-08-09 17:10:07 -07:00

112 lines
2.9 KiB
Go

package main
// Home detection: compares this agent's current public IP with the home
// site's public IP as reported by the directory.
//
// "Home" is site-relative: each theta-suite deployment has a site name and a
// public-facing IP. When this agent's egress IP matches, the user is on that
// site's LAN (or behind its NAT). The directory reports each site's public IP
// through its telemetry data; we get it on the WebSocket config push.
import (
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
var homeState struct {
mu sync.RWMutex
agentPublicIP string
homePublicIP string // set by directory config push
vpnActive bool
autoVPN bool
}
// publicIPProviders are tried in order until one succeeds.
var publicIPProviders = []string{
"https://api4.my-ip.io/ip",
"https://ipv4.icanhazip.com",
"https://api.ipify.org",
}
// fetchPublicIP tries each provider and returns the first clean response.
func fetchPublicIP() string {
client := &http.Client{Timeout: 5 * time.Second}
for _, url := range publicIPProviders {
resp, err := client.Get(url)
if err != nil {
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
continue
}
ip := strings.TrimSpace(string(body))
if ip != "" && !strings.Contains(ip, "<") { // skip HTML error pages
return ip
}
}
return ""
}
// SetHomePublicIP is called when the directory pushes its site's public IP.
func SetHomePublicIP(ip string) {
homeState.mu.Lock()
homeState.homePublicIP = ip
homeState.mu.Unlock()
}
// SetVPNActive is called when WireGuard tunnel state changes.
func SetVPNActive(active bool) {
homeState.mu.Lock()
homeState.vpnActive = active
homeState.mu.Unlock()
}
// StartHomeMonitor periodically refreshes the agent's public IP and pushes
// updated tray status. Call as a goroutine from main().
func StartHomeMonitor(cfg *Config, connectedFn func() bool) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
// Run immediately on start.
checkAndPush(cfg, connectedFn)
for range ticker.C {
checkAndPush(cfg, connectedFn)
}
}
func checkAndPush(cfg *Config, connectedFn func() bool) {
// An air-gapped host cannot reach the public-IP providers; when
// public_ip_detect is false we skip the network calls entirely and report
// no public IP rather than flap the home/away state (DESIGN-WINDOWS.md §10).
var ip string
if cfg.DetectPublicIP() {
ip = fetchPublicIP()
}
if ip == "" {
log.Println("[home-detect] could not determine public IP")
}
homeState.mu.Lock()
homeState.agentPublicIP = ip
agentIP := homeState.agentPublicIP
homeIP := homeState.homePublicIP
vpn := homeState.vpnActive
autoVPN := homeState.autoVPN
homeState.mu.Unlock()
connected := connectedFn()
siteName := cfg.Location
if siteName == "" {
siteName = "home"
}
UpdateTrayStatus(connected, agentIP, homeIP, vpn, autoVPN, siteName, cfg.ServerURL)
}