Add LDAP byte-pump tunnel, secrets rendering, and IAM engine

See CHANGELOG.md for the full breakdown. Summary:

- ldap_tunnel.go: serves a local unix socket for SSSD/PAM and relays raw
  bytes to the SSO over the existing WSS channel (ldap_tunnel messages);
  the agent never parses LDAP (DESIGN.md §4). Adds safeWriter to
  serialize WebSocket writes now that telemetry, heartbeat, the LDAP
  tunnel, and command responses all share one connection.
- secrets.go: renders local templates ({{ bao "path#key" }} placeholders)
  by fetching node-scoped values from the SSO and writing the target
  atomically at 0600, on a signed render_secrets command (DESIGN.md §5).
  demo/ has minimal bash + Node consumers of the rendered file.
- iam.go: applies signed node IAM pushes -- sudoers.d rules (visudo -c
  validated), SSH AuthorizedKeysCommand keys, /etc/security/access.conf,
  and revocation via sss_cache -E + pkill -u (DESIGN.md §6).
- Capability reporting: the agent's enabled capabilities ride along in
  its discovery frame so the SSO can show them in the Directory.
- DESIGN.md: the v2 protocol design this implements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:12:58 -04:00
parent d128807431
commit 8f0158eb9f
21 changed files with 1392 additions and 20 deletions
+182
View File
@@ -0,0 +1,182 @@
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 the unix socket and accepts connections until stopCh closes.
func (t *ldapTunnel) start(socketPath string, stopCh <-chan struct{}) {
// Remove a stale socket left over from a previous run, and make sure the
// parent directory exists (e.g. /run/theta on a fresh boot).
os.Remove(socketPath)
if dir := filepath.Dir(socketPath); dir != "." && dir != "/" {
os.MkdirAll(dir, 0755)
}
ln, err := net.Listen("unix", socketPath)
if err != nil {
log.Printf("LDAP tunnel: cannot bind %s: %v", socketPath, err)
return
}
// root:theta, 0660 — only root and the theta group can connect. A unix
// socket is preferred over 127.0.0.1:389 because filesystem permissions
// restrict which local processes can reach it.
os.Chmod(socketPath, 0660)
defer ln.Close()
log.Printf("LDAP tunnel: listening on %s", socketPath)
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)
}