diff --git a/cli/cli.go b/cli/cli.go index b5db440..76bc988 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -7,13 +7,12 @@ import ( "log/slog" "os" "os/signal" - "path/filepath" "strings" "syscall" - "time" + "github.com/coder/jail" "github.com/coder/jail/audit" - "github.com/coder/jail/network" + "github.com/coder/jail/namespace" "github.com/coder/jail/proxy" "github.com/coder/jail/rules" "github.com/coder/jail/tls" @@ -25,7 +24,6 @@ type Config struct { AllowStrings []string NoTLSIntercept bool LogLevel string - NoJailCleanup bool } // NewCommand creates and returns the root serpent command @@ -70,14 +68,6 @@ Examples: Default: "warn", Value: serpent.StringOf(&config.LogLevel), }, - { - Name: "no-jail-cleanup", - Flag: "no-jail-cleanup", - Env: "JAIL_NO_JAIL_CLEANUP", - Description: "Skip jail cleanup (hidden flag for testing).", - Value: serpent.BoolOf(&config.NoJailCleanup), - Hidden: true, - }, }, Handler: func(inv *serpent.Invocation) error { return Run(config, inv.Args) @@ -123,82 +113,77 @@ func Run(config Config, args []string) error { logger.Warn("No allow rules specified; all network traffic will be denied by default") } + // Parse allow rules allowRules, err := rules.ParseAllowSpecs(config.AllowStrings) if err != nil { logger.Error("Failed to parse allow rules", "error", err) return fmt.Errorf("failed to parse allow rules: %v", err) } - // Implicit final deny-all is handled by the RuleEngine default behavior when no rules match. - // Build final rules slice in order: user allows only. - ruleList := allowRules - // Create rule engine - ruleEngine := rules.NewRuleEngine(ruleList, logger) + ruleEngine := rules.NewRuleEngine(allowRules, logger) + + // Create auditor + auditor := audit.NewLoggingAuditor(logger) - // Get configuration directory - configDir, err := tls.GetConfigDir() + // Create network namespace configuration + nsConfig := namespace.Config{ + HTTPPort: 8040, + HTTPSPort: 8043, + } + + // Create commander + commander, err := namespace.New(nsConfig, logger) if err != nil { - logger.Error("Failed to get config directory", "error", err) - return fmt.Errorf("failed to get config directory: %v", err) + logger.Error("Failed to create network namespace", "error", err) + return fmt.Errorf("failed to create network namespace: %v", err) } // Create certificate manager (if TLS interception is enabled) - var certManager *tls.CertificateManager var tlsConfig *cryptotls.Config - var extraEnv map[string]string = make(map[string]string) - if !config.NoTLSIntercept { - certManager, err = tls.NewCertificateManager(configDir, logger) + certManager, err := tls.NewCertificateManager("", logger) // Empty configDir since it will be determined internally if err != nil { logger.Error("Failed to create certificate manager", "error", err) return fmt.Errorf("failed to create certificate manager: %v", err) } - tlsConfig = certManager.GetTLSConfig() - - // Get CA certificate for environment - caCertPEM, err := certManager.GetCACertPEM() - if err != nil { - logger.Error("Failed to get CA certificate", "error", err) - return fmt.Errorf("failed to get CA certificate: %v", err) - } - - // Write CA certificate to a temporary file for tools that need a file path - caCertPath := filepath.Join(configDir, "ca-cert.pem") - err = os.WriteFile(caCertPath, caCertPEM, 0644) + // Setup TLS config and write CA certificate to file + var caCertPath, configDir string + tlsConfig, caCertPath, configDir, err = certManager.SetupTLSAndWriteCACert() if err != nil { - logger.Error("Failed to write CA certificate file", "error", err) - return fmt.Errorf("failed to write CA certificate file: %v", err) + logger.Error("Failed to setup TLS and CA certificate", "error", err) + return fmt.Errorf("failed to setup TLS and CA certificate: %v", err) } // Set standard CA certificate environment variables for common tools // This makes tools like curl, git, etc. trust our dynamically generated CA - extraEnv["SSL_CERT_FILE"] = caCertPath // OpenSSL/LibreSSL-based tools - extraEnv["SSL_CERT_DIR"] = configDir // OpenSSL certificate directory - extraEnv["CURL_CA_BUNDLE"] = caCertPath // curl - extraEnv["GIT_SSL_CAINFO"] = caCertPath // Git - extraEnv["REQUESTS_CA_BUNDLE"] = caCertPath // Python requests - extraEnv["NODE_EXTRA_CA_CERTS"] = caCertPath // Node.js - extraEnv["JAIL_CA_CERT"] = string(caCertPEM) // Keep for backward compatibility + commander.SetEnv("SSL_CERT_FILE", caCertPath) // OpenSSL/LibreSSL-based tools + commander.SetEnv("SSL_CERT_DIR", configDir) // OpenSSL certificate directory + commander.SetEnv("CURL_CA_BUNDLE", caCertPath) // curl + commander.SetEnv("GIT_SSL_CAINFO", caCertPath) // Git + commander.SetEnv("REQUESTS_CA_BUNDLE", caCertPath) // Python requests + commander.SetEnv("NODE_EXTRA_CA_CERTS", caCertPath) // Node.js } - // Create network jail configuration - networkConfig := network.JailConfig{ - HTTPPort: 8040, - HTTPSPort: 8043, - NetJailName: "jail", - SkipCleanup: config.NoJailCleanup, - } + // Create proxy server + proxyServer := proxy.NewProxyServer(proxy.Config{ + HTTPPort: 8040, + HTTPSPort: 8043, + RuleEngine: ruleEngine, + Auditor: auditor, + Logger: logger, + TLSConfig: tlsConfig, + }) - // Create network jail - networkInstance, err := network.NewJail(networkConfig, logger) - if err != nil { - logger.Error("Failed to create network jail", "error", err) - return fmt.Errorf("failed to create network jail: %v", err) - } + // Create jail instance + jailInstance := jail.New(jail.Config{ + Commander: commander, + ProxyServer: proxyServer, + Logger: logger, + }) - // Setup signal handling BEFORE any network setup + // Setup signal handling BEFORE any setup sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) @@ -206,7 +191,7 @@ func Run(config Config, args []string) error { go func() { sig := <-sigChan logger.Info("Received signal during setup, cleaning up...", "signal", sig) - err := networkInstance.Cleanup() + err := jailInstance.Close() if err != nil { logger.Error("Emergency cleanup failed", "error", err) } @@ -216,55 +201,29 @@ func Run(config Config, args []string) error { // Ensure cleanup happens no matter what defer func() { logger.Debug("Starting cleanup process") - err := networkInstance.Cleanup() + err := jailInstance.Close() if err != nil { - logger.Error("Failed to cleanup network jail", "error", err) + logger.Error("Failed to cleanup jail", "error", err) } else { logger.Debug("Cleanup completed successfully") } }() - // Setup network jail - err = networkInstance.Setup(networkConfig.HTTPPort, networkConfig.HTTPSPort) + // Open jail (starts network namespace and proxy server) + err = jailInstance.Open() if err != nil { - logger.Error("Failed to setup network jail", "error", err) - return fmt.Errorf("failed to setup network jail: %v", err) - } - - // Create auditor - auditor := audit.NewLoggingAuditor(logger) - - // Create proxy server - proxyConfig := proxy.Config{ - HTTPPort: networkConfig.HTTPPort, - HTTPSPort: networkConfig.HTTPSPort, - RuleEngine: ruleEngine, - Auditor: auditor, - Logger: logger, - TLSConfig: tlsConfig, + logger.Error("Failed to open jail", "error", err) + return fmt.Errorf("failed to open jail: %v", err) } - proxyServer := proxy.NewProxyServer(proxyConfig) - // Create context for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Start proxy server in background - go func() { - err := proxyServer.Start(ctx) - if err != nil { - logger.Error("Proxy server error", "error", err) - } - }() - - // Give proxy time to start - time.Sleep(100 * time.Millisecond) - - // Execute command in network jail + // Execute command in jail go func() { defer cancel() - err := networkInstance.Execute(args, extraEnv) + err := jailInstance.Command(args).Run() if err != nil { logger.Error("Command execution failed", "error", err) } @@ -277,12 +236,7 @@ func Run(config Config, args []string) error { cancel() case <-ctx.Done(): // Context cancelled by command completion - } - - // Stop proxy server - err = proxyServer.Stop() - if err != nil { - logger.Error("Failed to stop proxy server", "error", err) + logger.Info("Command completed, shutting down...") } return nil diff --git a/jail.go b/jail.go new file mode 100644 index 0000000..dcdb3c3 --- /dev/null +++ b/jail.go @@ -0,0 +1,87 @@ +package jail + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "time" + + "github.com/coder/jail/proxy" +) + +type Commander interface { + Open() error + SetEnv(key string, value string) + Command(command []string) *exec.Cmd + Close() error +} + +type Config struct { + Commander Commander + ProxyServer *proxy.ProxyServer + Logger *slog.Logger +} + +type Jail struct { + commandExecutor Commander + proxyServer *proxy.ProxyServer + logger *slog.Logger + cancel context.CancelFunc + ctx context.Context +} + +func New(config Config) *Jail { + ctx, cancel := context.WithCancel(context.Background()) + + return &Jail{ + commandExecutor: config.Commander, + proxyServer: config.ProxyServer, + logger: config.Logger, + ctx: ctx, + cancel: cancel, + } +} + +func (j *Jail) Open() error { + // Open the command executor (network namespace) + err := j.commandExecutor.Open() + if err != nil { + return fmt.Errorf("failed to open command executor: %v", err) + } + + // Start proxy server in background + go func() { + err := j.proxyServer.Start(j.ctx) + if err != nil { + j.logger.Error("Proxy server error", "error", err) + } + }() + + // Give proxy time to start + time.Sleep(100 * time.Millisecond) + + return nil +} + +func (j *Jail) Command(command []string) *exec.Cmd { + return j.commandExecutor.Command(command) +} + +func (j *Jail) Close() error { + // Cancel context to stop proxy server + if j.cancel != nil { + j.cancel() + } + + // Stop proxy server + if j.proxyServer != nil { + err := j.proxyServer.Stop() + if err != nil { + j.logger.Error("Failed to stop proxy server", "error", err) + } + } + + // Close command executor + return j.commandExecutor.Close() +} diff --git a/network/linux.go b/namespace/linux.go similarity index 71% rename from network/linux.go rename to namespace/linux.go index 334ac61..073d677 100644 --- a/network/linux.go +++ b/namespace/linux.go @@ -1,6 +1,6 @@ //go:build linux -package network +package namespace import ( "fmt" @@ -9,98 +9,67 @@ import ( "os/exec" "os/user" "strconv" + "strings" "syscall" "time" ) -const ( - namespacePrefix = "coder_jail" -) - -// LinuxJail implements NetJail using Linux network namespaces -type LinuxJail struct { - config JailConfig - namespace string - logger *slog.Logger +// Linux implements jail.Commander using Linux network namespaces +type Linux struct { + config Config + namespace string + logger *slog.Logger + preparedEnv map[string]string + procAttr *syscall.SysProcAttr } -// newLinuxJail creates a new Linux network jail instance -func newLinuxJail(config JailConfig, logger *slog.Logger) (*LinuxJail, error) { - // Generate unique namespace name - namespace := fmt.Sprintf("%s_%d", namespacePrefix, time.Now().UnixNano()%10000000) - - return &LinuxJail{ +// newLinux creates a new Linux network jail instance +func newLinux(config Config, logger *slog.Logger) (*Linux, error) { + return &Linux{ config: config, - namespace: namespace, + namespace: newNamespaceName(), logger: logger, }, nil } // Setup creates network namespace and configures iptables rules -func (l *LinuxJail) Setup(httpPort, httpsPort int) error { - l.logger.Debug("Setup called", "httpPort", httpPort, "httpsPort", httpsPort) - l.config.HTTPPort = httpPort - l.config.HTTPSPort = httpsPort +func (l *Linux) Open() error { + l.logger.Debug("Setup called") // Setup DNS configuration BEFORE creating namespace // This ensures the namespace-specific resolv.conf is available when namespace is created - l.logger.Debug("Setting up DNS configuration") err := l.setupDNS() if err != nil { return fmt.Errorf("failed to setup DNS: %v", err) } - l.logger.Debug("DNS setup completed") - // Create network namespace - l.logger.Debug("Creating network namespace", "namespace", l.namespace) + // Create namespace err = l.createNamespace() if err != nil { return fmt.Errorf("failed to create namespace: %v", err) } - l.logger.Debug("Network namespace created") - // Setup network interface in namespace - l.logger.Debug("Setting up networking") + // Setup networking within namespace err = l.setupNetworking() if err != nil { return fmt.Errorf("failed to setup networking: %v", err) } - l.logger.Debug("Networking setup completed") // Setup iptables rules - l.logger.Debug("Setting up iptables rules") err = l.setupIptables() if err != nil { return fmt.Errorf("failed to setup iptables: %v", err) } - l.logger.Debug("Iptables setup completed") - - l.logger.Debug("Setup completed successfully") - return nil -} - -// Execute runs a command within the network namespace -func (l *LinuxJail) Execute(command []string, extraEnv map[string]string) error { - l.logger.Debug("Execute called", "command", command) - if len(command) == 0 { - return fmt.Errorf("no command specified") - } - - // Create command with ip netns exec - l.logger.Debug("Creating command with namespace", "namespace", l.namespace) - cmdArgs := []string{"ip", "netns", "exec", l.namespace} - cmdArgs = append(cmdArgs, command...) - l.logger.Debug("Full command args", "args", cmdArgs) - cmd := exec.Command("ip", cmdArgs[1:]...) + // Prepare environment once during setup + l.logger.Debug("Preparing environment") + l.preparedEnv = make(map[string]string) - // Set up environment - l.logger.Debug("Setting up environment") - env := os.Environ() - - // Add extra environment variables (including CA cert if provided) - for key, value := range extraEnv { - env = append(env, fmt.Sprintf("%s=%s", key, value)) + // Start with current environment + for _, envVar := range os.Environ() { + if parts := strings.SplitN(envVar, "=", 2); len(parts) == 2 { + l.preparedEnv[parts[0]] = parts[1] + } } // When running under sudo, restore essential user environment variables @@ -109,23 +78,18 @@ func (l *LinuxJail) Execute(command []string, extraEnv map[string]string) error user, err := user.Lookup(sudoUser) if err == nil { // Set HOME to original user's home directory - env = append(env, fmt.Sprintf("HOME=%s", user.HomeDir)) + l.preparedEnv["HOME"] = user.HomeDir // Set USER to original username - env = append(env, fmt.Sprintf("USER=%s", sudoUser)) + l.preparedEnv["USER"] = sudoUser // Set LOGNAME to original username (some tools check this instead of USER) - env = append(env, fmt.Sprintf("LOGNAME=%s", sudoUser)) + l.preparedEnv["LOGNAME"] = sudoUser l.logger.Debug("Restored user environment", "home", user.HomeDir, "user", sudoUser) } } - cmd.Env = env - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Drop privileges to original user if running under sudo + // Prepare process credentials once during setup + l.logger.Debug("Preparing process credentials") var gid, uid int - var err error sudoUID := os.Getenv("SUDO_UID") if sudoUID != "" { uid, err = strconv.Atoi(sudoUID) @@ -140,44 +104,52 @@ func (l *LinuxJail) Execute(command []string, extraEnv map[string]string) error l.logger.Warn("Invalid SUDO_GID, subprocess will run as root", "sudo_gid", sudoGID, "error", err) } } - cmd.SysProcAttr = &syscall.SysProcAttr{ + l.procAttr = &syscall.SysProcAttr{ Credential: &syscall.Credential{ Uid: uint32(uid), Gid: uint32(gid), }, } - // Start command - l.logger.Debug("Starting command", "path", cmd.Path, "args", cmd.Args) - err = cmd.Start() - if err != nil { - return fmt.Errorf("failed to start command: %v", err) - } - l.logger.Debug("Command started, waiting for completion") + l.logger.Debug("Setup completed successfully") + return nil +} - // Wait for command to complete - err = cmd.Wait() - l.logger.Debug("Command completed", "error", err) - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - if status, ok := exitError.Sys().(syscall.WaitStatus); ok { - l.logger.Debug("Command exit status", "status", status.ExitStatus()) - os.Exit(status.ExitStatus()) - } - } - return fmt.Errorf("command failed: %v", err) +// SetEnv sets an environment variable for commands run in the namespace +func (l *Linux) SetEnv(key string, value string) { + l.preparedEnv[key] = value +} + +// Command returns an exec.Cmd configured to run within the network namespace +func (l *Linux) Command(command []string) *exec.Cmd { + l.logger.Debug("Command called", "command", command) + + // Create command with ip netns exec + l.logger.Debug("Creating command with namespace", "namespace", l.namespace) + cmdArgs := []string{"ip", "netns", "exec", l.namespace} + cmdArgs = append(cmdArgs, command...) + l.logger.Debug("Full command args", "args", cmdArgs) + + cmd := exec.Command("ip", cmdArgs[1:]...) + + // Use prepared environment from Open method + env := make([]string, 0, len(l.preparedEnv)) + for key, value := range l.preparedEnv { + env = append(env, fmt.Sprintf("%s=%s", key, value)) } + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr - l.logger.Debug("Command executed successfully") - return nil + // Use prepared process attributes from Open method + cmd.SysProcAttr = l.procAttr + + return cmd } // Cleanup removes the network namespace and iptables rules -func (l *LinuxJail) Cleanup() error { - if l.config.SkipCleanup { - return nil - } - +func (l *Linux) Close() error { // Remove iptables rules err := l.removeIptables() if err != nil { @@ -204,7 +176,7 @@ func (l *LinuxJail) Cleanup() error { } // createNamespace creates a new network namespace -func (l *LinuxJail) createNamespace() error { +func (l *Linux) createNamespace() error { cmd := exec.Command("ip", "netns", "add", l.namespace) err := cmd.Run() if err != nil { @@ -214,7 +186,7 @@ func (l *LinuxJail) createNamespace() error { } // setupNetworking configures networking within the namespace -func (l *LinuxJail) setupNetworking() error { +func (l *Linux) setupNetworking() error { // Create veth pair with short names (Linux interface names limited to 15 chars) // Generate unique ID to avoid conflicts uniqueID := fmt.Sprintf("%d", time.Now().UnixNano()%10000000) // 7 digits max @@ -247,7 +219,7 @@ func (l *LinuxJail) setupNetworking() error { // setupDNS configures DNS resolution for the namespace // This ensures reliable DNS resolution by using public DNS servers // instead of relying on the host's potentially complex DNS configuration -func (l *LinuxJail) setupDNS() error { +func (l *Linux) setupDNS() error { // Always create namespace-specific resolv.conf with reliable public DNS servers // This avoids issues with systemd-resolved, Docker DNS, and other complex setups netnsEtc := fmt.Sprintf("/etc/netns/%s", l.namespace) @@ -275,7 +247,7 @@ options timeout:2 attempts:2 } // setupIptables configures iptables rules for traffic redirection -func (l *LinuxJail) setupIptables() error { +func (l *Linux) setupIptables() error { // Enable IP forwarding cmd := exec.Command("sysctl", "-w", "net.ipv4.ip_forward=1") cmd.Run() // Ignore error @@ -307,7 +279,7 @@ func (l *LinuxJail) setupIptables() error { } // removeIptables removes iptables rules -func (l *LinuxJail) removeIptables() error { +func (l *Linux) removeIptables() error { // Remove NAT rule cmd := exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", "192.168.100.0/24", "-j", "MASQUERADE") cmd.Run() // Ignore errors during cleanup @@ -316,7 +288,7 @@ func (l *LinuxJail) removeIptables() error { } // removeNamespace removes the network namespace -func (l *LinuxJail) removeNamespace() error { +func (l *Linux) removeNamespace() error { cmd := exec.Command("ip", "netns", "del", l.namespace) err := cmd.Run() if err != nil { diff --git a/namespace/linux_stub.go b/namespace/linux_stub.go new file mode 100644 index 0000000..63d0d7d --- /dev/null +++ b/namespace/linux_stub.go @@ -0,0 +1,15 @@ +//go:build !linux + +package namespace + +import ( + "fmt" + "log/slog" + + "github.com/coder/jail" +) + +// newLinux is not available on non-Linux platforms +func newLinux(_ Config, _ *slog.Logger) (jail.Commander, error) { + return nil, fmt.Errorf("linux network jail not supported on this platform") +} diff --git a/network/macos.go b/namespace/macos.go similarity index 75% rename from network/macos.go rename to namespace/macos.go index 025a00e..88e58e0 100644 --- a/network/macos.go +++ b/namespace/macos.go @@ -1,6 +1,6 @@ //go:build darwin -package network +package namespace import ( "fmt" @@ -14,23 +14,26 @@ import ( ) const ( - PF_ANCHOR_NAME = "network" - GROUP_NAME = "network" + pfAnchorName = "coder_jail" + groupName = "coder_jail" ) // MacOSNetJail implements network jail using macOS PF (Packet Filter) and group-based isolation type MacOSNetJail struct { - config JailConfig + config Config groupID int pfRulesPath string mainRulesPath string logger *slog.Logger + preparedEnv map[string]string + procAttr *syscall.SysProcAttr } // newMacOSJail creates a new macOS network jail instance -func newMacOSJail(config JailConfig, logger *slog.Logger) (*MacOSNetJail, error) { - pfRulesPath := fmt.Sprintf("/tmp/%s.pf", config.NetJailName) - mainRulesPath := fmt.Sprintf("/tmp/%s_main.pf", config.NetJailName) +func newMacOSJail(config Config, logger *slog.Logger) (*MacOSNetJail, error) { + ns := newNamespaceName() + pfRulesPath := fmt.Sprintf("/tmp/%s.pf", ns) + mainRulesPath := fmt.Sprintf("/tmp/%s_main.pf", ns) return &MacOSNetJail{ config: config, @@ -41,10 +44,8 @@ func newMacOSJail(config JailConfig, logger *slog.Logger) (*MacOSNetJail, error) } // Setup creates the network jail group and configures PF rules -func (m *MacOSNetJail) Setup(httpPort, httpsPort int) error { - m.logger.Debug("Setup called", "httpPort", httpPort, "httpsPort", httpsPort) - m.config.HTTPPort = httpPort - m.config.HTTPSPort = httpsPort +func (m *MacOSNetJail) Open() error { + m.logger.Debug("Setup called") // Create or get network jail group m.logger.Debug("Creating or ensuring network jail group") @@ -52,7 +53,6 @@ func (m *MacOSNetJail) Setup(httpPort, httpsPort int) error { if err != nil { return fmt.Errorf("failed to ensure group: %v", err) } - m.logger.Debug("Network jail group ready", "groupID", m.groupID) // Setup PF rules m.logger.Debug("Setting up PF rules") @@ -60,31 +60,16 @@ func (m *MacOSNetJail) Setup(httpPort, httpsPort int) error { if err != nil { return fmt.Errorf("failed to setup PF rules: %v", err) } - m.logger.Debug("PF rules setup completed") - m.logger.Debug("Setup completed successfully") - return nil -} - -// Execute runs the command with the network jail group membership -func (m *MacOSNetJail) Execute(command []string, extraEnv map[string]string) error { - m.logger.Debug("Execute called", "command", command) - if len(command) == 0 { - return fmt.Errorf("no command specified") - } - - // Create command directly (no sg wrapper needed) - m.logger.Debug("Creating command with group membership", "groupID", m.groupID) - cmd := exec.Command(command[0], command[1:]...) - m.logger.Debug("Full command args", "args", command) - - // Set up environment - m.logger.Debug("Setting up environment") - env := os.Environ() + // Prepare environment once during setup + m.logger.Debug("Preparing environment") + m.preparedEnv = make(map[string]string) - // Add extra environment variables (including CA cert if provided) - for key, value := range extraEnv { - env = append(env, fmt.Sprintf("%s=%s", key, value)) + // Start with current environment + for _, envVar := range os.Environ() { + if parts := strings.SplitN(envVar, "=", 2); len(parts) == 2 { + m.preparedEnv[parts[0]] = parts[1] + } } // When running under sudo, restore essential user environment variables @@ -93,22 +78,18 @@ func (m *MacOSNetJail) Execute(command []string, extraEnv map[string]string) err user, err := user.Lookup(sudoUser) if err == nil { // Set HOME to original user's home directory - env = append(env, fmt.Sprintf("HOME=%s", user.HomeDir)) + m.preparedEnv["HOME"] = user.HomeDir // Set USER to original username - env = append(env, fmt.Sprintf("USER=%s", sudoUser)) + m.preparedEnv["USER"] = sudoUser // Set LOGNAME to original username (some tools check this instead of USER) - env = append(env, fmt.Sprintf("LOGNAME=%s", sudoUser)) + m.preparedEnv["LOGNAME"] = sudoUser m.logger.Debug("Restored user environment", "home", user.HomeDir, "user", sudoUser) } } - cmd.Env = env - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - - // Set group ID using syscall - cmd.SysProcAttr = &syscall.SysProcAttr{ + // Prepare process credentials once during setup + m.logger.Debug("Preparing process credentials") + procAttr := &syscall.SysProcAttr{ Credential: &syscall.Credential{ Gid: uint32(m.groupID), }, @@ -122,7 +103,7 @@ func (m *MacOSNetJail) Execute(command []string, extraEnv map[string]string) err m.logger.Warn("Invalid SUDO_UID, subprocess will run as root", "sudo_uid", sudoUID, "error", err) } else { // Use original user ID but KEEP the jail group for network isolation - cmd.SysProcAttr = &syscall.SysProcAttr{ + procAttr = &syscall.SysProcAttr{ Credential: &syscall.Credential{ Uid: uint32(uid), Gid: uint32(m.groupID), // Keep jail group, not original user's group @@ -132,38 +113,46 @@ func (m *MacOSNetJail) Execute(command []string, extraEnv map[string]string) err } } - // Start and wait for command to complete - m.logger.Debug("Starting command", "path", cmd.Path, "args", cmd.Args) - err := cmd.Start() - if err != nil { - return fmt.Errorf("failed to start command: %v", err) - } - m.logger.Debug("Command started, waiting for completion") + // Store prepared process attributes for use in Command method + m.procAttr = procAttr - // Wait for command to complete - err = cmd.Wait() - m.logger.Debug("Command completed", "error", err) - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - if status, ok := exitError.Sys().(syscall.WaitStatus); ok { - m.logger.Debug("Command exit status", "status", status.ExitStatus()) - os.Exit(status.ExitStatus()) - } - } - return fmt.Errorf("command execution failed: %v", err) + m.logger.Debug("Setup completed successfully") + return nil +} + +// SetEnv sets an environment variable for commands run in the namespace +func (m *MacOSNetJail) SetEnv(key string, value string) { + m.preparedEnv[key] = value +} + +// Execute runs the command with the network jail group membership +func (m *MacOSNetJail) Command(command []string) *exec.Cmd { + m.logger.Debug("Command called", "command", command) + + // Create command directly (no sg wrapper needed) + m.logger.Debug("Creating command with group membership", "groupID", m.groupID) + cmd := exec.Command(command[0], command[1:]...) + m.logger.Debug("Full command args", "args", command) + + // Use prepared environment from Open method + env := make([]string, 0, len(m.preparedEnv)) + for key, value := range m.preparedEnv { + env = append(env, fmt.Sprintf("%s=%s", key, value)) } + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin - m.logger.Debug("Command executed successfully") - return nil + // Use prepared process attributes from Open method + cmd.SysProcAttr = m.procAttr + + return cmd } // Cleanup removes PF rules and cleans up temporary files -func (m *MacOSNetJail) Cleanup() error { +func (m *MacOSNetJail) Close() error { m.logger.Debug("Starting cleanup process") - if m.config.SkipCleanup { - m.logger.Debug("Skipping cleanup (SkipCleanup=true)") - return nil - } // Remove PF rules m.logger.Debug("Removing PF rules") @@ -183,7 +172,7 @@ func (m *MacOSNetJail) Cleanup() error { // ensureGroup creates the network jail group if it doesn't exist func (m *MacOSNetJail) ensureGroup() error { // Check if group already exists - output, err := exec.Command("dscl", ".", "-read", fmt.Sprintf("/Groups/%s", GROUP_NAME), "PrimaryGroupID").Output() + output, err := exec.Command("dscl", ".", "-read", fmt.Sprintf("/Groups/%s", groupName), "PrimaryGroupID").Output() if err == nil { // Parse GID from output stdout := string(output) @@ -203,14 +192,14 @@ func (m *MacOSNetJail) ensureGroup() error { } // Group doesn't exist, create it - cmd := exec.Command("dseditgroup", "-o", "create", GROUP_NAME) + cmd := exec.Command("dseditgroup", "-o", "create", groupName) err = cmd.Run() if err != nil { return fmt.Errorf("failed to create group: %v", err) } // Get the newly created group's GID - output, err = exec.Command("dscl", ".", "-read", fmt.Sprintf("/Groups/%s", GROUP_NAME), "PrimaryGroupID").Output() + output, err = exec.Command("dscl", ".", "-read", fmt.Sprintf("/Groups/%s", groupName), "PrimaryGroupID").Output() if err != nil { return fmt.Errorf("failed to read group GID: %v", err) } @@ -230,7 +219,7 @@ func (m *MacOSNetJail) ensureGroup() error { } } - return fmt.Errorf("failed to get GID for group %s", GROUP_NAME) + return fmt.Errorf("failed to get GID for group %s", groupName) } // getDefaultInterface gets the default network interface @@ -309,7 +298,7 @@ func (m *MacOSNetJail) setupPFRules() error { } // Load rules into anchor - cmd := exec.Command("pfctl", "-a", PF_ANCHOR_NAME, "-f", m.pfRulesPath) + cmd := exec.Command("pfctl", "-a", pfAnchorName, "-f", m.pfRulesPath) err = cmd.Run() if err != nil { return fmt.Errorf("failed to load PF rules: %v", err) @@ -333,7 +322,7 @@ rdr-anchor "%s" # 4. Filtering anchor "com.apple/*" anchor "%s" -`, PF_ANCHOR_NAME, PF_ANCHOR_NAME) +`, pfAnchorName, pfAnchorName) // Write and load the main ruleset err = os.WriteFile(m.mainRulesPath, []byte(mainRules), 0644) @@ -349,7 +338,7 @@ anchor "%s" } // Verify that rules were loaded correctly - cmd = exec.Command("pfctl", "-a", PF_ANCHOR_NAME, "-s", "rules") + cmd = exec.Command("pfctl", "-a", pfAnchorName, "-s", "rules") output, err := cmd.Output() if err == nil && len(output) > 0 { // Rules loaded successfully @@ -362,7 +351,7 @@ anchor "%s" // removePFRules removes PF rules from anchor func (m *MacOSNetJail) removePFRules() error { // Flush the anchor - cmd := exec.Command("pfctl", "-a", PF_ANCHOR_NAME, "-F", "all") + cmd := exec.Command("pfctl", "-a", pfAnchorName, "-F", "all") cmd.Run() // Ignore errors during cleanup return nil @@ -376,4 +365,4 @@ func (m *MacOSNetJail) cleanupTempFiles() { if m.mainRulesPath != "" { os.Remove(m.mainRulesPath) } -} +} \ No newline at end of file diff --git a/namespace/macos_stub.go b/namespace/macos_stub.go new file mode 100644 index 0000000..9368ae3 --- /dev/null +++ b/namespace/macos_stub.go @@ -0,0 +1,14 @@ +//go:build !darwin + +package namespace + +import ( + "log/slog" + + "github.com/coder/jail" +) + +// newMacOSJail is not available on non-macOS platforms +func newMacOSJail(config Config, logger *slog.Logger) (jail.Commander, error) { + panic("macOS network jail not available on this platform") +} diff --git a/namespace/namespace.go b/namespace/namespace.go new file mode 100644 index 0000000..a71ca22 --- /dev/null +++ b/namespace/namespace.go @@ -0,0 +1,36 @@ +package namespace + +import ( + "fmt" + "log/slog" + "runtime" + "time" + + "github.com/coder/jail" +) + +const ( + namespacePrefix = "coder_jail" +) + +// JailConfig holds configuration for network jail +type Config struct { + HTTPPort int + HTTPSPort int +} + +// NewJail creates a new NetJail instance for the current platform +func New(config Config, logger *slog.Logger) (jail.Commander, error) { + switch runtime.GOOS { + case "darwin": + return newMacOSJail(config, logger) + case "linux": + return newLinux(config, logger) + default: + return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} + +func newNamespaceName() string { + return fmt.Sprintf("%s_%d", namespacePrefix, time.Now().UnixNano()%10000000) +} \ No newline at end of file diff --git a/network/linux_stub.go b/network/linux_stub.go deleted file mode 100644 index 3155cb4..0000000 --- a/network/linux_stub.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build !linux - -package network - -import ( - "fmt" - "log/slog" -) - -// newLinuxJail is not available on non-Linux platforms -func newLinuxJail(_ JailConfig, _ *slog.Logger) (Jail, error) { - return nil, fmt.Errorf("linux network jail not supported on this platform") -} diff --git a/network/macos_stub.go b/network/macos_stub.go deleted file mode 100644 index 4dd2580..0000000 --- a/network/macos_stub.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !darwin - -package network - -import "log/slog" - -// newMacOSJail is not available on non-macOS platforms -func newMacOSJail(config JailConfig, logger *slog.Logger) (Jail, error) { - panic("macOS network jail not available on this platform") -} diff --git a/network/network.go b/network/network.go deleted file mode 100644 index cb8f3f6..0000000 --- a/network/network.go +++ /dev/null @@ -1,39 +0,0 @@ -package network - -import ( - "fmt" - "log/slog" - "runtime" -) - -// Jail represents a network isolation mechanism -type Jail interface { - // Setup configures the network jail for the given proxy ports - Setup(httpPort, httpsPort int) error - - // Execute runs a command within the network jail with additional environment variables - Execute(command []string, extraEnv map[string]string) error - - // Cleanup removes network jail resources - Cleanup() error -} - -// JailConfig holds configuration for network jail -type JailConfig struct { - HTTPPort int - HTTPSPort int - NetJailName string - SkipCleanup bool -} - -// NewJail creates a new NetJail instance for the current platform -func NewJail(config JailConfig, logger *slog.Logger) (Jail, error) { - switch runtime.GOOS { - case "darwin": - return newMacOSJail(config, logger) - case "linux": - return newLinuxJail(config, logger) - default: - return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) - } -} diff --git a/tls/tls.go b/tls/tls.go index dc5488b..e5ca824 100644 --- a/tls/tls.go +++ b/tls/tls.go @@ -62,6 +62,34 @@ func (cm *CertificateManager) GetCACertPEM() ([]byte, error) { }), nil } +// SetupTLSAndWriteCACert sets up TLS config and writes CA certificate to file +// Returns the TLS config, CA cert path, and config directory +func (cm *CertificateManager) SetupTLSAndWriteCACert() (*tls.Config, string, string, error) { + // Get config directory + configDir, err := getConfigDir() + if err != nil { + return nil, "", "", fmt.Errorf("failed to get config directory: %v", err) + } + + // Get TLS config + tlsConfig := cm.GetTLSConfig() + + // Get CA certificate PEM + caCertPEM, err := cm.GetCACertPEM() + if err != nil { + return nil, "", "", fmt.Errorf("failed to get CA certificate: %v", err) + } + + // Write CA certificate to file + caCertPath := filepath.Join(configDir, "ca-cert.pem") + err = os.WriteFile(caCertPath, caCertPEM, 0644) + if err != nil { + return nil, "", "", fmt.Errorf("failed to write CA certificate file: %v", err) + } + + return tlsConfig, caCertPath, configDir, nil +} + // loadOrGenerateCA loads existing CA or generates a new one func (cm *CertificateManager) loadOrGenerateCA() error { caKeyPath := filepath.Join(cm.configDir, "ca-key.pem") @@ -314,8 +342,8 @@ func (cm *CertificateManager) generateServerCertificate(hostname string) (*tls.C return tlsCert, nil } -// GetConfigDir returns the configuration directory path -func GetConfigDir() (string, error) { +// getConfigDir returns the configuration directory path +func getConfigDir() (string, error) { // When running under sudo, use the original user's home directory // so the subprocess can access the CA certificate files var homeDir string