This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/internal/daemon/server.go
Mathieu Virbel cb474b2d99 feat: add macOS daemon support with group-based pf routing
- Add daemon CLI subcommand (install/uninstall/status/run)
- Download tun2socks for darwin platforms in Makefile
- Export ExtractTun2Socks and add darwin embed support
- Use group-based pf filtering instead of user-based for transparent
proxying
- Install sudoers rule for passwordless sandbox-exec with _greywall
group
- Add nolint directives for gosec false positives on sudoers 0440 perms
- Fix lint issues: lowercase errors, fmt.Fprintf, nolint comments
2026-02-26 09:56:22 -06:00

431 lines
12 KiB
Go

package daemon
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"os"
"os/user"
"sync"
"time"
)
// Protocol types for JSON communication over Unix socket (newline-delimited).
// Request from CLI to daemon.
type Request struct {
Action string `json:"action"` // "create_session", "destroy_session", "status"
ProxyURL string `json:"proxy_url,omitempty"` // for create_session
DNSAddr string `json:"dns_addr,omitempty"` // for create_session
SessionID string `json:"session_id,omitempty"` // for destroy_session
}
// Response from daemon to CLI.
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
SessionID string `json:"session_id,omitempty"`
TunDevice string `json:"tun_device,omitempty"`
SandboxUser string `json:"sandbox_user,omitempty"`
SandboxGroup string `json:"sandbox_group,omitempty"`
// Status response fields.
Running bool `json:"running,omitempty"`
ActiveSessions int `json:"active_sessions,omitempty"`
}
// Session tracks an active sandbox session.
type Session struct {
ID string
ProxyURL string
DNSAddr string
CreatedAt time.Time
}
// Server listens on a Unix socket and manages sandbox sessions. It orchestrates
// TunManager (utun + pf) and DNSRelay lifecycle for each session.
type Server struct {
socketPath string
listener net.Listener
tunManager *TunManager
dnsRelay *DNSRelay
sessions map[string]*Session
mu sync.Mutex
done chan struct{}
wg sync.WaitGroup
debug bool
tun2socksPath string
sandboxGID string // cached numeric GID for the sandbox group
}
// NewServer creates a new daemon server that will listen on the given Unix socket path.
func NewServer(socketPath, tun2socksPath string, debug bool) *Server {
return &Server{
socketPath: socketPath,
tun2socksPath: tun2socksPath,
sessions: make(map[string]*Session),
done: make(chan struct{}),
debug: debug,
}
}
// Start begins listening on the Unix socket and accepting connections.
// It removes any stale socket file before binding.
func (s *Server) Start() error {
// Pre-resolve the sandbox group GID so session creation is fast
// and doesn't depend on OpenDirectory latency.
grp, err := user.LookupGroup(SandboxGroupName)
if err != nil {
Logf("Warning: could not resolve group %s at startup: %v (will retry per-session)", SandboxGroupName, err)
} else {
s.sandboxGID = grp.Gid
Logf("Resolved group %s → GID %s", SandboxGroupName, s.sandboxGID)
}
// Remove stale socket file if it exists.
if _, err := os.Stat(s.socketPath); err == nil {
s.logDebug("Removing stale socket file %s", s.socketPath)
if err := os.Remove(s.socketPath); err != nil {
return fmt.Errorf("failed to remove stale socket %s: %w", s.socketPath, err)
}
}
ln, err := net.Listen("unix", s.socketPath)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", s.socketPath, err)
}
s.listener = ln
// Set socket permissions so any local user can connect to the daemon.
// The socket is localhost-only (Unix domain socket); access control is
// handled at the daemon protocol level, not file permissions.
if err := os.Chmod(s.socketPath, 0o666); err != nil { //nolint:gosec // daemon socket needs 0666 so non-root CLI can connect
_ = ln.Close()
_ = os.Remove(s.socketPath)
return fmt.Errorf("failed to set socket permissions: %w", err)
}
s.logDebug("Listening on %s", s.socketPath)
s.wg.Add(1)
go s.acceptLoop()
return nil
}
// Stop gracefully shuts down the server. It stops accepting new connections,
// tears down all active sessions, and removes the socket file.
func (s *Server) Stop() error {
// Signal shutdown.
select {
case <-s.done:
// Already closed.
default:
close(s.done)
}
// Close the listener to unblock acceptLoop.
if s.listener != nil {
_ = s.listener.Close()
}
// Wait for the accept loop and any in-flight handlers to finish.
s.wg.Wait()
// Tear down all active sessions.
s.mu.Lock()
var errs []string
for id := range s.sessions {
s.logDebug("Stopping session %s during shutdown", id)
}
if s.tunManager != nil {
if err := s.tunManager.Stop(); err != nil {
errs = append(errs, fmt.Sprintf("stop tun manager: %v", err))
}
s.tunManager = nil
}
if s.dnsRelay != nil {
s.dnsRelay.Stop()
s.dnsRelay = nil
}
s.sessions = make(map[string]*Session)
s.mu.Unlock()
// Remove the socket file.
if err := os.Remove(s.socketPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Sprintf("remove socket: %v", err))
}
if len(errs) > 0 {
return fmt.Errorf("stop errors: %s", join(errs, "; "))
}
s.logDebug("Server stopped")
return nil
}
// ActiveSessions returns the number of currently active sessions.
func (s *Server) ActiveSessions() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.sessions)
}
// acceptLoop runs the main accept loop for the Unix socket listener.
func (s *Server) acceptLoop() {
defer s.wg.Done()
for {
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.done:
return
default:
}
s.logDebug("Accept error: %v", err)
continue
}
s.wg.Add(1)
go s.handleConnection(conn)
}
}
// handleConnection reads a single JSON request from the connection, dispatches
// it to the appropriate handler, and writes the JSON response back.
func (s *Server) handleConnection(conn net.Conn) {
defer s.wg.Done()
defer conn.Close() //nolint:errcheck // best-effort close after handling request
// Set a read deadline to prevent hung connections.
if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
s.logDebug("Failed to set read deadline: %v", err)
return
}
decoder := json.NewDecoder(conn)
encoder := json.NewEncoder(conn)
var req Request
if err := decoder.Decode(&req); err != nil {
s.logDebug("Failed to decode request: %v", err)
resp := Response{OK: false, Error: fmt.Sprintf("invalid request: %v", err)}
_ = encoder.Encode(resp) // best-effort error response
return
}
Logf("Received request: action=%s", req.Action)
var resp Response
switch req.Action {
case "create_session":
resp = s.handleCreateSession(req)
case "destroy_session":
resp = s.handleDestroySession(req)
case "status":
resp = s.handleStatus()
default:
resp = Response{OK: false, Error: fmt.Sprintf("unknown action: %q", req.Action)}
}
if err := encoder.Encode(resp); err != nil {
s.logDebug("Failed to encode response: %v", err)
}
}
// handleCreateSession creates a new sandbox session with a utun tunnel,
// optional DNS relay, and pf rules for the sandbox group.
func (s *Server) handleCreateSession(req Request) Response {
s.mu.Lock()
defer s.mu.Unlock()
if req.ProxyURL == "" {
return Response{OK: false, Error: "proxy_url is required"}
}
// Phase 1: only one session at a time.
if len(s.sessions) > 0 {
Logf("Rejecting create_session: %d session(s) already active", len(s.sessions))
return Response{OK: false, Error: "a session is already active (only one session supported in Phase 1)"}
}
Logf("Creating session: proxy=%s dns=%s", req.ProxyURL, req.DNSAddr)
// Step 1: Create and start TunManager.
tm := NewTunManager(s.tun2socksPath, req.ProxyURL, s.debug)
if err := tm.Start(); err != nil {
return Response{OK: false, Error: fmt.Sprintf("failed to start tunnel: %v", err)}
}
// Step 2: Create DNS relay if dns_addr is provided.
var dr *DNSRelay
if req.DNSAddr != "" {
var err error
dr, err = NewDNSRelay(dnsRelayIP+":"+dnsRelayPort, req.DNSAddr, s.debug)
if err != nil {
_ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to create DNS relay: %v", err)}
}
if err := dr.Start(); err != nil {
_ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to start DNS relay: %v", err)}
}
}
// Step 3: Resolve the sandbox group GID. pfctl in the LaunchDaemon
// context cannot resolve group names via OpenDirectory, so we use the
// cached GID (resolved at startup) or look it up now.
sandboxGID := s.sandboxGID
if sandboxGID == "" {
grp, err := user.LookupGroup(SandboxGroupName)
if err != nil {
_ = tm.Stop()
if dr != nil {
dr.Stop()
}
return Response{OK: false, Error: fmt.Sprintf("failed to resolve group %s: %v", SandboxGroupName, err)}
}
sandboxGID = grp.Gid
s.sandboxGID = sandboxGID
}
Logf("Loading pf rules for group %s (GID %s)", SandboxGroupName, sandboxGID)
if err := tm.LoadPFRules(sandboxGID); err != nil {
if dr != nil {
dr.Stop()
}
_ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to load pf rules: %v", err)}
}
// Step 4: Generate session ID and store.
sessionID, err := generateSessionID()
if err != nil {
if dr != nil {
dr.Stop()
}
_ = tm.UnloadPFRules() // best-effort cleanup
_ = tm.Stop() // best-effort cleanup
return Response{OK: false, Error: fmt.Sprintf("failed to generate session ID: %v", err)}
}
session := &Session{
ID: sessionID,
ProxyURL: req.ProxyURL,
DNSAddr: req.DNSAddr,
CreatedAt: time.Now(),
}
s.sessions[sessionID] = session
s.tunManager = tm
s.dnsRelay = dr
Logf("Session created: id=%s device=%s group=%s(GID %s)", sessionID, tm.TunDevice(), SandboxGroupName, sandboxGID)
return Response{
OK: true,
SessionID: sessionID,
TunDevice: tm.TunDevice(),
SandboxUser: SandboxUserName,
SandboxGroup: SandboxGroupName,
}
}
// handleDestroySession tears down an existing session by unloading pf rules,
// stopping the tunnel, and stopping the DNS relay.
func (s *Server) handleDestroySession(req Request) Response {
s.mu.Lock()
defer s.mu.Unlock()
if req.SessionID == "" {
return Response{OK: false, Error: "session_id is required"}
}
Logf("Destroying session: id=%s", req.SessionID)
session, ok := s.sessions[req.SessionID]
if !ok {
Logf("Session %q not found (active sessions: %d)", req.SessionID, len(s.sessions))
return Response{OK: false, Error: fmt.Sprintf("session %q not found", req.SessionID)}
}
var errs []string
// Step 1: Unload pf rules.
if s.tunManager != nil {
if err := s.tunManager.UnloadPFRules(); err != nil {
errs = append(errs, fmt.Sprintf("unload pf rules: %v", err))
}
}
// Step 2: Stop tun manager.
if s.tunManager != nil {
if err := s.tunManager.Stop(); err != nil {
errs = append(errs, fmt.Sprintf("stop tun manager: %v", err))
}
s.tunManager = nil
}
// Step 3: Stop DNS relay.
if s.dnsRelay != nil {
s.dnsRelay.Stop()
s.dnsRelay = nil
}
// Step 4: Remove session.
delete(s.sessions, session.ID)
if len(errs) > 0 {
Logf("Session %s destroyed with errors: %v", session.ID, errs)
return Response{OK: false, Error: fmt.Sprintf("session destroyed with errors: %s", join(errs, "; "))}
}
Logf("Session destroyed: id=%s (remaining: %d)", session.ID, len(s.sessions))
return Response{OK: true}
}
// handleStatus returns the current daemon status including whether it is running
// and how many sessions are active.
func (s *Server) handleStatus() Response {
s.mu.Lock()
defer s.mu.Unlock()
return Response{
OK: true,
Running: true,
ActiveSessions: len(s.sessions),
}
}
// generateSessionID produces a cryptographically random hex session identifier.
func generateSessionID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to read random bytes: %w", err)
}
return hex.EncodeToString(b), nil
}
// join concatenates string slices with a separator. This avoids importing
// the strings package solely for strings.Join.
func join(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, p := range parts[1:] {
result += sep + p
}
return result
}
// logDebug writes a timestamped debug message to stderr.
func (s *Server) logDebug(format string, args ...interface{}) {
if s.debug {
Logf(format, args...)
}
}