Sandboxed commands previously ran as `sudo -u _greywall`, breaking user identity (home dir, SSH keys, git config). Now uses `sudo -u #<uid> -g _greywall` so the process keeps the real user's identity while pf matches on EGID for traffic routing. Key changes: - pf rules use `group <GID>` instead of `user _greywall` - GID resolved dynamically at daemon startup (not hardcoded, since macOS system groups like com.apple.access_ssh may claim preferred IDs) - Sudoers rule installed at /etc/sudoers.d/greywall (validated with visudo) - Invoking user added to _greywall group via dscl (not dseditgroup, which clobbers group attributes) - tun2socks device discovery scans both stdout and stderr (fixes 10s timeout caused by STACK message going to stdout) - Always-on daemon logging for session create/destroy events
431 lines
12 KiB
Go
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...)
|
|
}
|
|
}
|