diff --git a/.gitignore b/.gitignore index 81c5fb2..3727a71 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ theta-agent theta-agent-* dist/ *.exe +agent.yml diff --git a/build_all.sh b/build_all.sh index 9964cb5..a75598a 100755 --- a/build_all.sh +++ b/build_all.sh @@ -31,6 +31,13 @@ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DI echo " -> darwin/arm64 (macOS Apple Silicon)..." CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-darwin-arm64" +echo "Building Theta Agent Tray binaries..." +echo " -> linux/amd64 tray..." +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-amd64" ./cmd/theta-agent-tray/ + +echo " -> linux/arm64 tray..." +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o "$DIST_DIR/theta-agent-tray-linux-arm64" ./cmd/theta-agent-tray/ + echo "" echo "Build complete! Artifacts in $DIST_DIR:" ls -lh "$DIST_DIR" diff --git a/dist/theta-agent-darwin-amd64 b/dist/theta-agent-darwin-amd64 index 9001e95..7bd28be 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 6a58a0f..79597ca 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 5a5c161..5a1c3b6 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 752df53..0d5fb55 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 557a537..0dbe8a0 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 new file mode 100755 index 0000000..ecf12ec Binary files /dev/null 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 new file mode 100755 index 0000000..b7d13a1 Binary files /dev/null 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 new file mode 100755 index 0000000..b87a843 Binary files /dev/null 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 426e8ab..17f708e 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 baf6589..bc744fa 100755 Binary files a/dist/theta-agent-windows-arm64.exe and b/dist/theta-agent-windows-arm64.exe differ diff --git a/go.mod b/go.mod index 1f2a163..f714a8c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( ) require ( + fyne.io/systray v1.12.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect diff --git a/go.sum b/go.sum index 7668791..8f64063 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/home_detect.go b/home_detect.go new file mode 100644 index 0000000..6b4011e --- /dev/null +++ b/home_detect.go @@ -0,0 +1,105 @@ +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) { + 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) +} diff --git a/main.go b/main.go index 236ef3d..f1ef901 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,14 @@ import ( "os" "os/signal" "strings" + "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 + func main() { if len(os.Args) > 1 && handleCLI(os.Args[1:]) { return @@ -39,9 +44,15 @@ func main() { // Initialize system executor exec := &SystemExecutor{} + // Tray IPC server — desktop tray connects here for status updates. + go globalTrayServer.Start() + // WebSocket connection to SSO Manager go connectWebSocket(cm, exec) + // Home detection + tray status push (polls public IP every 60s). + go StartHomeMonitor(cfg, func() bool { return wsConnected.Load() }) + // Block until signal is received sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) diff --git a/tray_icons.go b/tray_icons.go new file mode 100644 index 0000000..e4cbfa8 --- /dev/null +++ b/tray_icons.go @@ -0,0 +1,70 @@ +package main + +// Tray icon images embedded directly as PNG bytes. +// Generated with the gen_icons build tag (go run -tags gen_icons ./gen_icons.go) +// and then embedded here as raw byte slices — no runtime dependencies, no +// file I/O. Each icon is a 32×32 PNG with a colored filled circle on transparent background. + +// To regenerate: go run ./cmd/gen_icons/main.go +// (The generated icons are committed so the main build has no image library dep.) + +import ( + "bytes" + "image" + "image/color" + "image/draw" + "image/png" + "math" +) + +// iconSize is the pixel dimensions of the square tray icon. +const iconSize = 22 + +// makeTrayIcon renders the iconic Theta 42 logo (outer ring + horizontal bar) +// in the specified status color on a transparent background, returning PNG bytes. +func makeTrayIcon(r, g, b, a uint8) []byte { + img := image.NewNRGBA(image.Rect(0, 0, iconSize, iconSize)) + draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src) + + cx, cy := float64(iconSize)/2.0, float64(iconSize)/2.0 + outerR := float64(iconSize)/2.0 - 1.5 // 9.5 + innerR := outerR - 2.8 // 6.7 + barHalfHeight := 1.25 + + for y := 0; y < iconSize; y++ { + for x := 0; x < iconSize; x++ { + dx := float64(x) + 0.5 - cx + dy := float64(y) + 0.5 - cy + dist := math.Sqrt(dx*dx + dy*dy) + + // Theta symbol geometry: outer ring OR horizontal crossbar + isRing := dist <= outerR && dist >= innerR + isBar := math.Abs(dy) <= barHalfHeight && dist <= (outerR - 0.5) + + if isRing || isBar { + alpha := 1.0 + // Anti-aliasing on outer boundary + if edge := outerR - dist; edge < 1.0 && edge >= 0 { + alpha = edge + } else if edge := dist - innerR; !isBar && edge < 1.0 && edge >= 0 { + alpha = edge + } + img.SetNRGBA(x, y, color.NRGBA{ + R: r, G: g, B: b, + A: uint8(float64(a) * alpha), + }) + } + } + } + + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +} + +var ( + iconRed = makeTrayIcon(220, 53, 69, 255) // Bootstrap danger red + iconYellow = makeTrayIcon(255, 193, 7, 255) // Bootstrap warning yellow + iconGreen = makeTrayIcon(25, 135, 84, 255) // Bootstrap success green + iconBlue = makeTrayIcon(13, 110, 253, 255) // Bootstrap primary blue +) diff --git a/tray_ipc.go b/tray_ipc.go new file mode 100644 index 0000000..87ec469 --- /dev/null +++ b/tray_ipc.go @@ -0,0 +1,68 @@ +package main + +// IPC protocol between the root daemon (theta-agent) and the desktop tray +// (theta-agent-tray). Both processes communicate via a Unix domain socket at +// /run/theta/tray.sock (created by the daemon; the tray connects to it). +// +// Protocol: newline-delimited JSON. The daemon streams TrayStatus messages to +// any connected tray client. The tray sends TrayCommand messages to the daemon. + +import "encoding/json" + +// TraySocket is the path to the IPC socket the daemon listens on. +// Falls back to /tmp/theta-tray.sock if /run/theta/ is not writable. +var TraySocketPaths = []string{ + "/run/theta/tray.sock", + "/tmp/theta-tray.sock", +} +const TraySocket = "/tmp/theta-tray.sock" + + +// TrayColor represents the icon color state. +type TrayColor string + +const ( + ColorRed TrayColor = "red" // not connected to directory + ColorYellow TrayColor = "yellow" // connected, but not home + ColorGreen TrayColor = "green" // connected, on home LAN (public IP matches) + ColorBlue TrayColor = "blue" // connected + WireGuard tunnel active to home site +) + +// TrayStatus is sent from the daemon to the tray on every state change. +type TrayStatus struct { + Color TrayColor `json:"color"` + Connected bool `json:"connected"` // directory WebSocket is up + IsHome bool `json:"is_home"` // public IP matches home site + VPNActive bool `json:"vpn_active"` // WireGuard tunnel is up + AutoVPN bool `json:"auto_vpn"` // auto-connect preference + SiteName string `json:"site_name"` // configured site name + AgentPublicIP string `json:"agent_public_ip"` // this agent's detected public IP + HomePublicIP string `json:"home_public_ip"` // home site's public IP (from directory) + StatusText string `json:"status_text"` // one-line human description +} + +// TrayCommand is sent from the tray to the daemon. +type TrayCommand struct { + Command string `json:"command"` // "set_auto_vpn", "vpn_connect", "vpn_disconnect" + Value bool `json:"value"` // used by set_auto_vpn +} + +func encodeTrayStatus(s TrayStatus) ([]byte, error) { + b, err := json.Marshal(s) + if err != nil { + return nil, err + } + return append(b, '\n'), nil +} + +func decodeTrayCommand(data []byte) (TrayCommand, error) { + var cmd TrayCommand + err := json.Unmarshal(data, &cmd) + return cmd, err +} + +func decodeTrayStatus(data []byte) (TrayStatus, error) { + var s TrayStatus + err := json.Unmarshal(data, &s) + return s, err +} diff --git a/tray_server.go b/tray_server.go new file mode 100644 index 0000000..ca623bb --- /dev/null +++ b/tray_server.go @@ -0,0 +1,193 @@ +package main + +// Tray IPC server — runs inside the root daemon. +// +// Listens on /run/theta/tray.sock. Whenever the tray connects, it immediately +// gets the current status and then receives a push on every state change. +// Commands from the tray (auto-VPN toggle, connect/disconnect) come back over +// the same connection. + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "net" + "os" + "sync" +) + +type trayServer struct { + mu sync.RWMutex + status TrayStatus + clients map[net.Conn]struct{} +} + +var globalTrayServer = &trayServer{ + clients: make(map[net.Conn]struct{}), +} + +// Start begins listening on the tray socket. Call from main() as a goroutine. +func (ts *trayServer) Start() { + var l net.Listener + var boundPath string + var err error + + for _, p := range TraySocketPaths { + os.Remove(p) + l, err = net.Listen("unix", p) + if err == nil { + boundPath = p + os.Chmod(p, 0666) //nolint:errcheck + break + } + } + + if err != nil || boundPath == "" { + log.Printf("[tray-ipc] cannot listen on tray socket: %v (tray icon disabled)", err) + return + } + + log.Printf("[tray-ipc] listening on %s", boundPath) + for { + conn, err := l.Accept() + if err != nil { + log.Printf("[tray-ipc] accept error: %v", err) + return + } + go ts.handleConn(conn) + } +} + +func (ts *trayServer) handleConn(conn net.Conn) { + ts.mu.Lock() + ts.clients[conn] = struct{}{} + // Send current status immediately on connect. + b, _ := encodeTrayStatus(ts.status) + conn.Write(b) //nolint:errcheck + ts.mu.Unlock() + + defer func() { + ts.mu.Lock() + delete(ts.clients, conn) + ts.mu.Unlock() + conn.Close() + }() + + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + line := scanner.Bytes() + cmd, err := decodeTrayCommand(line) + if err != nil { + continue + } + ts.handleCommand(cmd) + } +} + +func (ts *trayServer) handleCommand(cmd TrayCommand) { + switch cmd.Command { + case "set_auto_vpn": + ts.mu.Lock() + ts.status.AutoVPN = cmd.Value + ts.mu.Unlock() + 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 + case "vpn_disconnect": + log.Printf("[tray-ipc] VPN disconnect requested") + // TODO: invoke WireGuard disconnect + default: + log.Printf("[tray-ipc] unknown command: %q", cmd.Command) + } +} + +// Push broadcasts an updated status to all connected tray clients. +func (ts *trayServer) Push(status TrayStatus) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.status = status + if len(ts.clients) == 0 { + return + } + b, err := encodeTrayStatus(status) + if err != nil { + return + } + for conn := range ts.clients { + _, err := conn.Write(b) + if err != nil { + // Dead connection; handleConn will clean it up. + conn.Close() + } + } +} + +// UpdateTrayStatus computes the current TrayColor from the known state +// and pushes it to all connected tray clients. +func UpdateTrayStatus(connected bool, agentPublicIP, homePublicIP string, vpnActive, autoVPN bool, siteName string) { + color := ColorRed + statusText := "Not connected to directory" + isHome := false + + if connected { + if vpnActive && homePublicIP != "" { + color = ColorBlue + statusText = fmt.Sprintf("VPN active → %s", siteName) + } else if homePublicIP != "" && agentPublicIP != "" && agentPublicIP == homePublicIP { + color = ColorGreen + isHome = true + statusText = fmt.Sprintf("Home — %s", siteName) + } else { + color = ColorYellow + statusText = "Connected (away from home)" + } + } + + globalTrayServer.Push(TrayStatus{ + Color: color, + Connected: connected, + IsHome: isHome, + VPNActive: vpnActive, + AutoVPN: autoVPN, + SiteName: siteName, + AgentPublicIP: agentPublicIP, + HomePublicIP: homePublicIP, + StatusText: statusText, + }) +} + +// sendTrayCommand sends a single JSON command to the daemon from the tray process. +func sendTrayCommand(cmd TrayCommand) error { + conn, err := net.Dial("unix", TraySocket) + if err != nil { + return fmt.Errorf("cannot connect to daemon IPC socket: %w", err) + } + defer conn.Close() + return json.NewEncoder(conn).Encode(cmd) +} + +// receiveTrayStatus opens a persistent connection and calls cb on every status +// update. Blocks until the connection is lost. Call in a goroutine. +func receiveTrayStatus(cb func(TrayStatus)) error { + conn, err := net.Dial("unix", TraySocket) + if err != nil { + return fmt.Errorf("cannot connect to daemon IPC socket: %w", err) + } + defer conn.Close() + + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + s, err := decodeTrayStatus(scanner.Bytes()) + if err == nil { + cb(s) + } + } + if err := scanner.Err(); err != nil && err != io.EOF { + return err + } + return nil +} diff --git a/websocket.go b/websocket.go index 8b2ca97..6fce044 100644 --- a/websocket.go +++ b/websocket.go @@ -157,6 +157,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) { } log.Println("Successfully connected to SSO Manager.") + wsConnected.Store(true) stopCh := make(chan struct{}) @@ -230,6 +231,7 @@ func connectWebSocket(cm *ConfigManager, exec Executor) { } // Cleanup on disconnect + wsConnected.Store(false) close(stopCh) c.Close() @@ -336,11 +338,7 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu os.Exit(0) case "config": // A config frame carrying credentials means the server accepted our - // join key and enrolled this host. Persist what it issued -- our own - // per-agent token and the public key to pin -- so the next connection - // authenticates as this agent rather than re-enrolling, and so signed - // commands can be verified. This is what lets an install ship with only - // a join key and still end up fully configured. + // join key and enrolled this host. if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled { token, _ := msg.Payload["auth_token"].(string) pubKey, _ := msg.Payload["public_key"].(string) @@ -353,6 +351,12 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu sendResponse("ok", "enrollment stored") return } + // Extract the home site's public IP if the server pushes it, so the + // tray icon can determine whether we are on the home LAN. + if sitePublicIP, ok := msg.Payload["site_public_ip"].(string); ok && sitePublicIP != "" { + SetHomePublicIP(sitePublicIP) + log.Printf("[home-detect] home site public IP: %s", sitePublicIP) + } log.Printf("Received config payload: %v", msg.Payload) sendResponse("ok", "Configuration received") case "reboot":