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).
211 lines
5.2 KiB
Go
211 lines
5.2 KiB
Go
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"
|
|
"path/filepath"
|
|
"strings"
|
|
"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)
|
|
os.MkdirAll(filepath.Dir(p), 0755) //nolint:errcheck
|
|
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, serverURL string) {
|
|
color := ColorRed
|
|
statusText := "Not connected to directory"
|
|
isHome := false
|
|
|
|
if connected {
|
|
// Server URL is local (localhost, 127.0.0.1, LAN IP, or .local)
|
|
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.")
|
|
|
|
// On Home LAN if:
|
|
// 1) Both agent & home public IPs are known and match, OR
|
|
// 2) Connecting to a local/LAN SSO server, OR
|
|
// 3) homePublicIP is not yet set by directory (default to local home)
|
|
if (homePublicIP != "" && agentPublicIP != "" && agentPublicIP == homePublicIP) || isLocalServer || homePublicIP == "" {
|
|
isHome = true
|
|
}
|
|
|
|
if vpnActive {
|
|
color = ColorBlue
|
|
statusText = fmt.Sprintf("VPN active → %s", siteName)
|
|
} else if isHome {
|
|
color = ColorGreen
|
|
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
|
|
}
|