9fdbb8aab0
Implements the Linux half of AGENT_LOCAL_DISCOVERY_SPEC.md: when a local theta-gateway/theta-proxy announces itself via mDNS as fronting this agent's ServerURL host, skip the relay/WAN path and talk to it directly. Off by default (config.PreferLocalDirectory / prefer_local_directory) since it changes host name resolution. - local_discovery.go: polls for _theta-suite._tcp every 30s via hashicorp/mdns, matches the TXT "hosts" field against the agent's own target host, applies/clears a hosts-file override on change. Presence/ absence of the mDNS announcement IS the "on this LAN or not" signal -- no separate network detection needed, since multicast doesn't cross routers/VLANs. - hosts_override.go: writes a single marked, idempotent block into /etc/hosts (never touches anything else in the file); clearing removes the block entirely rather than leaving empty markers. - HARD RULE preserved: this only ever changes DNS resolution, never TLS trust -- nothing here touches certificate validation, so a spoofed rogue mDNS announcement produces a TLS failure against the real hostname's cert, not a silent MITM. Verified end-to-end with real containers (Node mDNS announcer + this actual Go binary, not mocked), which caught two real bugs neither showed up in code review: 1. mdns.Lookup()'s DefaultParams() requests both IPv4 and IPv6. The underlying client sends the v4 query (which got a real, valid response per a packet capture), then sends the v6 query, and if THAT send fails (no IPv6 route -- common on plain v4 hosts/containers) the whole Query() returns that error synchronously, before ever entering the response-listening loop. The v4 response was silently discarded. Fixed by building QueryParam manually with DisableIPv6: true instead of using the Lookup() convenience wrapper. 2. The original hosts-file writer used write-tmp-then-rename for atomicity. /etc/hosts is frequently a bind mount (every container runtime does this) -- rename() onto a bind-mounted file fails with EBUSY, since you can't atomically replace a mountpoint. Switched to truncate-and-rewrite in place; the process-local mutex already serializes writers, so the lost atomicity is a small, acceptable tradeoff against a confirmed hard failure. Full cycle verified: announcer starts -> agent discovers it -> hosts override applied -> announcer stops -> override cleanly reverts, no stale entry, no discovery trace left. Windows/macOS remain unbuilt -- need platform-native testing this environment can't do (see AGENT_LOCAL_DISCOVERY_SPEC.md §3's open question: hosts-file edits vs. a local stub resolver, per-OS elevation and DNS-cache behavior).
109 lines
2.9 KiB
Go
109 lines
2.9 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
)
|
|
|
|
// wsConnected is flipped atomically by connectWebSocket as the connection
|
|
// comes up and drops, so StartHomeMonitor can read it without a mutex.
|
|
var wsConnected atomic.Bool
|
|
|
|
// agentStopCh closes once the agent should exit: a SIGINT/SIGTERM in the
|
|
// foreground, or the SCM stop request when running as a Windows service. Both
|
|
// runAgent (foreground) and the service handler wait on it.
|
|
var (
|
|
agentStopOnce sync.Once
|
|
agentStopCh = make(chan struct{})
|
|
)
|
|
|
|
// currentCM lets the tray IPC server persist preferences (auto_vpn) and reset
|
|
// enrollment into the live config file. Set once in runAgent.
|
|
var currentCM *ConfigManager
|
|
|
|
// stopAgent signals the running agent to shut down. Idempotent.
|
|
func stopAgent() {
|
|
agentStopOnce.Do(func() { close(agentStopCh) })
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) > 1 && handleCLI(os.Args[1:]) {
|
|
return
|
|
}
|
|
|
|
// Windows: when installed as a service the SCM starts us and svc.Run takes
|
|
// over the process lifecycle. Foreground (or any other OS) falls through to
|
|
// runAgent, which blocks until a signal arrives.
|
|
if maybeRunAsService() {
|
|
return
|
|
}
|
|
|
|
runAgent()
|
|
}
|
|
|
|
// runAgent runs the agent daemon until stopAgent is called.
|
|
func runAgent() {
|
|
log.Println("Starting Theta Agent...")
|
|
|
|
// Attempt to load configuration
|
|
configPath := defaultConfigPath()
|
|
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
|
configPath = os.Args[1]
|
|
}
|
|
|
|
cm, err := NewConfigManager(configPath)
|
|
if err != nil {
|
|
log.Fatalf("Error loading configuration from %s: %v", configPath, err)
|
|
}
|
|
cfg := cm.Get()
|
|
|
|
log.Printf("Connecting to Theta Directory at %s", cfg.ServerURL)
|
|
log.Printf("Loaded capabilities: Telemetry=%v, LDAP=%v, Reboot=%v, Bash=%v",
|
|
cfg.Capabilities.Telemetry,
|
|
cfg.Capabilities.ConfigureLDAP,
|
|
cfg.Capabilities.Reboot,
|
|
cfg.Capabilities.ArbitraryBash,
|
|
)
|
|
|
|
// Initialize system executor and pin the platform ops the command
|
|
// dispatcher runs behind.
|
|
exec := &SystemExecutor{}
|
|
defaultPlatformOps = NewPlatformOps(cfg, exec)
|
|
currentCM = cm
|
|
|
|
// Seed the auto-VPN preference from disk; the tray checkbox updates it.
|
|
SetAutoVPN(cfg.AutoVPN)
|
|
|
|
// Tray IPC server — desktop tray connects here for status updates.
|
|
go globalTrayServer.Start()
|
|
|
|
// WebSocket connection to Theta Directory
|
|
go connectWebSocket(cm, exec)
|
|
|
|
// Home detection + tray status push (polls public IP every 60s).
|
|
go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() })
|
|
|
|
// mDNS local-discovery (MULTI_SITE_SPEC.md Appendix B) -- no-op unless
|
|
// prefer_local_directory is set.
|
|
go StartLocalDiscovery(cm)
|
|
|
|
// Foreground: exit on SIGINT/SIGTERM. A Windows service ignores these and
|
|
// is driven by its own handler.
|
|
go func() {
|
|
sigs := make(chan os.Signal, 1)
|
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sigs
|
|
stopAgent()
|
|
}()
|
|
|
|
<-agentStopCh
|
|
|
|
fmt.Println("Shutting down Theta Agent...")
|
|
}
|