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).
197 lines
4.7 KiB
Go
197 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// ldapTunnel is the agent's local LDAP socket (DESIGN.md §4). It is a pure byte
|
|
// pump: bytes from a local LDAP client (SSSD) are forwarded to the SSO over the
|
|
// WSS channel as `ldap_tunnel` messages, and the SSO's responses are written
|
|
// back to the socket. The agent never parses LDAP — it does not know or care
|
|
// what the bytes mean.
|
|
//
|
|
// Each local connection gets a conn_id. The agent reads the socket and sends
|
|
// chunks up; the SSO relays them into its real OpenLDAP and sends the response
|
|
// chunks back down; the agent writes them to the socket. `close:true` ends a
|
|
// connection.
|
|
type ldapTunnel struct {
|
|
mu sync.Mutex
|
|
conns map[string]net.Conn
|
|
send func(WSMessage) error
|
|
}
|
|
|
|
func newLdapTunnel(send func(WSMessage) error) *ldapTunnel {
|
|
return &ldapTunnel{
|
|
conns: make(map[string]net.Conn),
|
|
send: send,
|
|
}
|
|
}
|
|
|
|
// start binds both unix socket and TCP loopback, accepting connections until stopCh closes.
|
|
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
|
|
// 1. UNIX Domain Socket Listener. Windows passes an empty path and relies
|
|
// on the TCP loopback listener below.
|
|
if socketPath != "" {
|
|
os.Remove(socketPath)
|
|
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
|
|
os.MkdirAll(dir, 0755)
|
|
}
|
|
|
|
lnUnix, err := net.Listen("unix", socketPath)
|
|
if err == nil {
|
|
os.Chmod(socketPath, 0666)
|
|
log.Printf("LDAP tunnel: listening on unix socket %s", socketPath)
|
|
go t.acceptLoop(lnUnix, stopCh)
|
|
} else {
|
|
log.Printf("LDAP tunnel: cannot bind unix socket %s: %v", socketPath, err)
|
|
}
|
|
}
|
|
|
|
// 2. TCP Loopback Listener (127.0.0.1:389 with fallback to 127.0.0.1:3890)
|
|
lnTcp, errTcp := net.Listen("tcp", "127.0.0.1:389")
|
|
if errTcp != nil {
|
|
lnTcp, errTcp = net.Listen("tcp", "127.0.0.1:3890")
|
|
}
|
|
|
|
if errTcp == nil {
|
|
log.Printf("LDAP tunnel: listening on tcp %s", lnTcp.Addr().String())
|
|
go t.acceptLoop(lnTcp, stopCh)
|
|
} else {
|
|
log.Printf("LDAP tunnel: cannot bind tcp loopback: %v", errTcp)
|
|
}
|
|
}
|
|
|
|
func (t *ldapTunnel) acceptLoop(ln net.Listener, stopCh <-chan struct{}) {
|
|
defer ln.Close()
|
|
go func() {
|
|
<-stopCh
|
|
ln.Close()
|
|
}()
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go t.handleConn(conn)
|
|
}
|
|
}
|
|
|
|
// handleConn pumps one local LDAP connection up to the SSO.
|
|
func (t *ldapTunnel) handleConn(conn net.Conn) {
|
|
connID := newConnID()
|
|
t.mu.Lock()
|
|
t.conns[connID] = conn
|
|
t.mu.Unlock()
|
|
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, err := conn.Read(buf)
|
|
if n > 0 {
|
|
msg := WSMessage{
|
|
Type: "ldap_tunnel",
|
|
Payload: map[string]interface{}{
|
|
"conn_id": connID,
|
|
"data": base64.StdEncoding.EncodeToString(buf[:n]),
|
|
},
|
|
}
|
|
if err := t.send(msg); err != nil {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Signal end of connection to the SSO so it can close its OpenLDAP relay.
|
|
t.send(WSMessage{
|
|
Type: "ldap_tunnel",
|
|
Payload: map[string]interface{}{
|
|
"conn_id": connID,
|
|
"close": true,
|
|
},
|
|
})
|
|
|
|
t.mu.Lock()
|
|
delete(t.conns, connID)
|
|
t.mu.Unlock()
|
|
conn.Close()
|
|
}
|
|
|
|
// handleMessage writes SSO→agent tunnel bytes to the matching local socket.
|
|
// Called from handleCommand when an `ldap_tunnel` message arrives.
|
|
func (t *ldapTunnel) handleMessage(payload map[string]interface{}) {
|
|
connID, _ := payload["conn_id"].(string)
|
|
if connID == "" {
|
|
return
|
|
}
|
|
|
|
if closeFlag, _ := payload["close"].(bool); closeFlag {
|
|
t.mu.Lock()
|
|
conn := t.conns[connID]
|
|
delete(t.conns, connID)
|
|
t.mu.Unlock()
|
|
if conn != nil {
|
|
conn.Close()
|
|
}
|
|
return
|
|
}
|
|
|
|
dataStr, _ := payload["data"].(string)
|
|
if dataStr == "" {
|
|
return
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(dataStr)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
t.mu.Lock()
|
|
conn := t.conns[connID]
|
|
t.mu.Unlock()
|
|
if conn != nil {
|
|
conn.Write(data)
|
|
}
|
|
}
|
|
|
|
var connCounter uint64
|
|
|
|
func newConnID() string {
|
|
connCounter++
|
|
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), connCounter)
|
|
}
|
|
|
|
// safeWriter serializes writes to the WebSocket. Gorilla allows only one
|
|
// concurrent writer, but the agent has several (telemetry, heartbeat, the LDAP
|
|
// tunnel, command responses) — without this, concurrent WriteMessage calls
|
|
// corrupt the stream.
|
|
type safeWriter struct {
|
|
mu sync.Mutex
|
|
c *websocket.Conn
|
|
}
|
|
|
|
func (w *safeWriter) WriteMessage(messageType int, data []byte) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return w.c.WriteMessage(messageType, data)
|
|
}
|
|
|
|
// sendTunnelMessage marshals a WSMessage and writes it as a text frame.
|
|
func sendTunnelMessage(w MessageWriter, msg WSMessage) error {
|
|
payload, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return w.WriteMessage(websocket.TextMessage, payload)
|
|
}
|