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...) } }