4a619f7adc
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).
112 lines
2.9 KiB
Go
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)
|
|
}
|