diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 88f0e89..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,460 +0,0 @@ -# Boundary Architecture - -This document describes the architecture and components of boundary, a network isolation tool for monitoring and restricting HTTP/HTTPS requests. - -## High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ BOUNDARY SYSTEM │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ User Command: boundary --allow "*.github.com" -- npm install │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────────┐ │ -│ │ CLI LAYER │ │ -│ │ • Parse --allow rules │ │ -│ │ • Configure log level │ │ -│ │ • Setup components │ │ -│ │ • Handle signals │ │ -│ └─────────────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────────┐ │ -│ │ BOUNDARY CORE │ │ -│ │ │ │ -│ │ ┌───────────────────┐ ┌─────────────────────┐ │ │ -│ │ │ JAILER │ │ PROXY SERVER │ │ │ -│ │ │ │ │ │ │ │ -│ │ │ Network Isolation │◄───┤ HTTP/HTTPS Handler │ │ │ -│ │ │ Process Control │ │ TLS Termination │ │ │ -│ │ │ │ │ Request Filtering │ │ │ -│ │ └───────────────────┘ └─────────────────────┘ │ │ -│ │ │ │ │ │ -│ │ │ ▼ │ │ -│ │ │ ┌─────────────────────────────────────────────────┐ │ │ -│ │ │ │ SUPPORT COMPONENTS │ │ │ -│ │ │ │ │ │ │ -│ │ │ │ Rules Engine │ Auditor │ TLS Manager │ │ │ -│ │ │ │ • Pattern │ • Log │ • CA Certificate │ │ │ -│ │ │ │ Matching │ Reqs │ • Certificate │ │ │ -│ │ │ │ • Method │ • Allow/ │ Generation │ │ │ -│ │ │ │ Filtering │ Deny │ • TLS Config │ │ │ -│ │ │ └─────────────────────────────────────────────────┘ │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ ┌─────────────────────┐ │ │ -│ │ │ TARGET COMMAND │ │ │ -│ │ │ │ │ │ -│ │ │ npm install │ ◄── HTTP_PROXY/HTTPS_PROXY env vars │ │ -│ │ │ curl https://... │ ◄── Network isolation (Linux/macOS) │ │ -│ │ │ git clone │ ◄── DNS redirection │ │ -│ │ │ │ │ │ -│ │ └─────────────────────┘ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -## Component Details - -### 1. CLI Layer -**Input**: Command line arguments (`--allow`, `--log-level`, `--unprivileged`, target command) -**Output**: Configured boundary instance and executed target command - -**Responsibilities**: -- Parse and validate command line arguments -- Create rule engine from `--allow` specifications -- Setup logging configuration -- Initialize and coordinate all components -- Handle graceful shutdown on signals - -### 2. Jailer Component -**Input**: Target command, proxy configuration -**Output**: Isolated process with network restrictions - -Platform-specific implementations: - -#### Linux Jailer -``` -┌─────────────────────────────────────────────┐ -│ LINUX JAILER │ -├─────────────────────────────────────────────┤ -│ │ -│ Network Namespace Creation │ -│ │ │ -│ ├─ Create veth pair (host ↔ namespace) │ -│ ├─ Configure IP addresses │ -│ ├─ Setup routing │ -│ └─ Configure DNS resolution │ -│ │ -│ iptables Rules │ -│ │ │ -│ ├─ REDIRECT all HTTP → proxy (8080) │ -│ ├─ REDIRECT all HTTPS → proxy (8080) │ -│ └─ Allow localhost traffic │ -│ │ -│ Process Execution │ -│ │ │ -│ ├─ Set HTTP_PROXY env var │ -│ ├─ Set HTTPS_PROXY env var │ -│ ├─ Set SSL_CERT_FILE (custom CA) │ -│ └─ Execute in network namespace │ -│ │ -└─────────────────────────────────────────────┘ -``` - -#### macOS Jailer -``` -┌─────────────────────────────────────────────┐ -│ MACOS JAILER │ -├─────────────────────────────────────────────┤ -│ │ -│ PF (Packet Filter) Rules │ -│ │ │ -│ ├─ Create custom anchor │ -│ ├─ REDIRECT HTTP → proxy (127.0.0.1:8080) │ -│ ├─ REDIRECT HTTPS → proxy (127.0.0.1:8080) │ -│ └─ Apply rules to specific process group │ -│ │ -│ Process Group Isolation │ -│ │ │ -│ ├─ Create restricted group │ -│ ├─ Set process group ID │ -│ └─ Configure environment variables │ -│ │ -│ Process Execution │ -│ │ │ -│ ├─ Set HTTP_PROXY env var │ -│ ├─ Set HTTPS_PROXY env var │ -│ ├─ Set SSL_CERT_FILE (custom CA) │ -│ └─ Execute with group restrictions │ -│ │ -└─────────────────────────────────────────────┘ -``` - -#### Unprivileged Jailer -``` -┌─────────────────────────────────────────────┐ -│ UNPRIVILEGED JAILER │ -├─────────────────────────────────────────────┤ -│ │ -│ Environment Variables Only │ -│ │ │ -│ ├─ Set HTTP_PROXY env var │ -│ ├─ Set HTTPS_PROXY env var │ -│ ├─ Set SSL_CERT_FILE (custom CA) │ -│ └─ No network isolation │ -│ │ -│ Process Execution │ -│ │ │ -│ ├─ Execute with proxy env vars │ -│ └─ Relies on application proxy support │ -│ │ -│ Note: Less secure but works without sudo │ -│ │ -└─────────────────────────────────────────────┘ -``` - -### 3. Proxy Server Component -**Input**: HTTP/HTTPS requests from jailed processes -**Output**: Allowed requests forwarded to internet, denied requests blocked - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ PROXY SERVER │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ Connection Handler │ -│ │ │ -│ ├─ Listen on port 8080 │ -│ ├─ Detect HTTP vs HTTPS (peek first byte) │ -│ ├─ Route to appropriate handler │ -│ └─ Handle connection errors │ -│ │ -│ ┌─────────────────────┐ ┌───────────────────────────────────┐ │ -│ │ HTTP HANDLER │ │ HTTPS HANDLER │ │ -│ │ │ │ │ │ -│ │ • Direct requests │ │ • CONNECT tunneling │ │ -│ │ • Apply rules │ │ • TLS termination │ │ -│ │ • Forward allowed │ │ • Certificate generation │ │ -│ │ • Block denied │ │ • Decrypt → HTTP → Re-encrypt │ │ -│ │ │ │ • Apply rules to decrypted │ │ -│ └─────────────────────┘ └───────────────────────────────────┘ │ -│ │ │ │ -│ └────────────────┬───────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ REQUEST PROCESSING │ │ -│ │ │ │ -│ │ 1. Extract method (GET, POST, etc.) │ │ -│ │ 2. Extract URL (https://github.com/user/repo) │ │ -│ │ 3. Evaluate against rules │ │ -│ │ 4. Audit request (log allow/deny decision) │ │ -│ │ 5. Forward if allowed, block if denied │ │ -│ │ │ │ -│ └────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 4. Rules Engine -**Input**: HTTP method, URL, configured allow rules -**Output**: Allow/Deny decision with matching rule - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ RULES ENGINE │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ Rule Structure │ -│ │ │ -│ ├─ Pattern: "*.github.com", "api.*", "exact.com" │ -│ ├─ Methods: ["GET", "POST"] or nil (all methods) │ -│ └─ Raw: "allow GET,POST *.github.com" (for logging) │ -│ │ -│ Pattern Matching │ -│ │ │ -│ ├─ Wildcard support: * matches any characters │ -│ ├─ Case-insensitive matching │ -│ ├─ Protocol-agnostic: pattern "github.com" matches │ -│ │ both "http://github.com" and "https://github.com" │ -│ └─ Domain-only matching: "github.com" matches any path │ -│ │ -│ Evaluation Process │ -│ │ │ -│ ├─ 1. Check each rule in order │ -│ ├─ 2. Verify method matches (if specified) │ -│ ├─ 3. Apply wildcard pattern matching to URL │ -│ ├─ 4. Return ALLOW + rule on first match │ -│ └─ 5. Return DENY if no rules match (default deny-all) │ -│ │ -│ Examples: │ -│ • "*.github.com" → matches "api.github.com" │ -│ • "GET github.com" → matches "GET https://github.com/user" │ -│ • "api.*" → matches "api.example.com" │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 5. Auditor Component -**Input**: Request details and allow/deny decision -**Output**: Structured logs - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ AUDITOR │ -├──────────────────────────────────────────────────────────────────┤ -│ │ -│ Request Information │ -│ │ │ -│ ├─ Method: GET, POST, PUT, DELETE, etc. │ -│ ├─ URL: Full URL of the request │ -│ ├─ Allowed: boolean (true/false) │ -│ └─ Rule: Matching rule string (if allowed) │ -│ │ -│ Log Output │ -│ │ │ -│ ├─ ALLOW requests: INFO level │ -│ │ "ALLOW method=GET url=https://github.com rule=*.github.com" │ -│ │ │ -│ └─ DENY requests: WARN level │ -│ "DENY method=GET url=https://example.com" │ -│ │ -│ Structured Logging │ -│ │ │ -│ ├─ Uses slog for structured output │ -│ ├─ Machine-readable format │ -│ ├─ Filterable by log level │ -│ └─ Includes contextual information │ -│ │ -└──────────────────────────────────────────────────────────────────┘ -``` - -### 6. TLS Manager -**Input**: Hostname from HTTPS requests -**Output**: Valid TLS certificates, CA certificate file - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ TLS MANAGER │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ Certificate Authority (CA) │ -│ │ │ -│ ├─ Generate root CA private key │ -│ ├─ Create root CA certificate │ -│ ├─ Write CA cert to file system │ -│ └─ Configure system to trust CA (via SSL_CERT_FILE) │ -│ │ -│ Dynamic Certificate Generation │ -│ │ │ -│ ├─ On-demand cert creation per hostname │ -│ ├─ Sign certificates with CA private key │ -│ ├─ Cache certificates for reuse │ -│ ├─ Include Subject Alternative Names (SAN) │ -│ └─ Set appropriate validity periods │ -│ │ -│ TLS Termination │ -│ │ │ -│ ├─ Accept HTTPS connections │ -│ ├─ Present generated certificate │ -│ ├─ Decrypt TLS traffic │ -│ ├─ Process as HTTP internally │ -│ └─ Re-encrypt for upstream connections │ -│ │ -│ Certificate Cache │ -│ │ │ -│ ├─ In-memory storage for performance │ -│ ├─ Thread-safe access with mutex │ -│ ├─ Key: hostname │ -│ └─ Value: *tls.Certificate │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Request Flow Examples - -### HTTP Request Flow -``` -1. Target Process (npm install) - ├─ Makes HTTP request to registry.npmjs.org - ├─ HTTP_PROXY env var points to localhost:8080 - └─ Request sent to boundary proxy - -2. Jailer (Network Isolation) - ├─ iptables/PF rules intercept request - ├─ Redirect to proxy server (port 8080) - └─ Process isolated in namespace/group - -3. Proxy Server - ├─ Receive HTTP request - ├─ Extract method=GET, url=http://registry.npmjs.org/package - └─ Route to HTTP handler - -4. Rules Engine - ├─ Evaluate "GET http://registry.npmjs.org/package" - ├─ Check against rules: ["*.npmjs.org"] - ├─ Pattern "*.npmjs.org" matches "registry.npmjs.org" - └─ Return: ALLOW + rule="*.npmjs.org" - -5. Auditor - ├─ Log: "ALLOW method=GET url=http://registry.npmjs.org/package rule=*.npmjs.org" - └─ Output to structured log - -6. Request Forwarding - ├─ Create upstream HTTP request - ├─ Forward to registry.npmjs.org - ├─ Receive response - └─ Return response to target process -``` - -### HTTPS Request Flow -``` -1. Target Process (curl https://github.com) - ├─ Makes HTTPS request to github.com - ├─ HTTPS_PROXY env var points to localhost:8080 - └─ Sends CONNECT request to proxy - -2. Jailer (Network Isolation) - ├─ iptables/PF rules intercept CONNECT - ├─ Redirect to proxy server (port 8080) - └─ Process sees custom CA certificate - -3. Proxy Server (CONNECT Handler) - ├─ Receive "CONNECT github.com:443" - ├─ Accept connection - └─ Wait for TLS handshake - -4. TLS Manager - ├─ Generate certificate for "github.com" - ├─ Sign with boundary CA - ├─ Present certificate to client - └─ Establish TLS connection - -5. HTTPS Handler - ├─ Decrypt TLS traffic - ├─ Parse HTTP request: "GET / HTTP/1.1 Host: github.com" - └─ Route to request processing - -6. Rules Engine - ├─ Evaluate "GET https://github.com/" - ├─ Check against rules: ["*.github.com"] - ├─ Pattern "*.github.com" matches "github.com" - └─ Return: ALLOW + rule="*.github.com" - -7. Auditor - ├─ Log: "ALLOW method=GET url=https://github.com/ rule=*.github.com" - └─ Output to structured log - -8. Request Forwarding - ├─ Create upstream HTTPS request - ├─ Connect to real github.com:443 - ├─ Forward decrypted HTTP request - ├─ Receive response - ├─ Encrypt response with boundary TLS - └─ Return to target process -``` - -### Denied Request Flow -``` -1. Target Process (curl https://malicious.com) - ├─ Makes HTTPS request to malicious.com - └─ Request intercepted by boundary - -2. Proxy Server Processing - ├─ Extract method=GET, url=https://malicious.com/ - └─ Route to rules engine - -3. Rules Engine - ├─ Evaluate "GET https://malicious.com/" - ├─ Check against rules: ["*.github.com", "*.npmjs.org"] - ├─ No patterns match "malicious.com" - └─ Return: DENY (default deny-all) - -4. Auditor - ├─ Log: "DENY method=GET url=https://malicious.com/" - └─ Output to structured log - -5. Request Blocking - ├─ Return HTTP 403 Forbidden - ├─ Include boundary error message - └─ Close connection -``` - -## Platform Differences - -| Aspect | Linux | macOS | Unprivileged | -|--------|--------|--------|--------------| -| **Isolation** | Network namespaces | Process groups + PF | Environment variables only | -| **Traffic Interception** | iptables REDIRECT | PF rdr rules | HTTP_PROXY/HTTPS_PROXY | -| **DNS** | Custom resolv.conf | System DNS + PF | System DNS | -| **Privileges** | Requires sudo | Requires sudo | No privileges required | -| **Security** | Strong isolation | Moderate isolation | Weak (app-dependent) | -| **Compatibility** | Linux kernel 3.8+ | macOS with PF | Any platform | -| **Process Control** | Network namespace | Process group | Standard process | - -## Security Model - -### Default Deny-All -- All network requests are blocked by default -- Only explicitly allowed patterns are permitted -- Fail-safe behavior: unknown requests are denied - -### Network Isolation -- Process cannot bypass boundary (except in unprivileged mode) -- All traffic routed through proxy server -- TLS interception prevents encrypted bypass - -### Certificate Authority -- Boundary acts as trusted CA for intercepted HTTPS -- Generated certificates signed by boundary CA -- Target processes trust boundary CA via SSL_CERT_FILE - -### Audit Trail -- All requests (allowed and denied) are logged -- Structured logging for analysis -- Rule attribution for allowed requests diff --git a/audit/log_auditor.go b/audit/log_auditor.go deleted file mode 100644 index 562fe2f..0000000 --- a/audit/log_auditor.go +++ /dev/null @@ -1,29 +0,0 @@ -package audit - -import "log/slog" - -// LogAuditor implements proxy.Auditor by logging to slog -type LogAuditor struct { - logger *slog.Logger -} - -// NewLogAuditor creates a new LogAuditor -func NewLogAuditor(logger *slog.Logger) *LogAuditor { - return &LogAuditor{ - logger: logger, - } -} - -// AuditRequest logs the request using structured logging -func (a *LogAuditor) AuditRequest(req Request) { - if req.Allowed { - a.logger.Info("ALLOW", - "method", req.Method, - "url", req.URL, - "rule", req.Rule) - } else { - a.logger.Warn("DENY", - "method", req.Method, - "url", req.URL) - } -} diff --git a/audit/log_auditor_test.go b/audit/log_auditor_test.go deleted file mode 100644 index 9cf642e..0000000 --- a/audit/log_auditor_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package audit - -import "testing" - -// Stub test file - tests removed -func TestStub(t *testing.T) { - // This is a stub test - t.Skip("stub test file") -} diff --git a/audit/request.go b/audit/request.go deleted file mode 100644 index 54f4c4e..0000000 --- a/audit/request.go +++ /dev/null @@ -1,13 +0,0 @@ -package audit - -type Auditor interface { - AuditRequest(req Request) -} - -// Request represents information about an HTTP request for auditing -type Request struct { - Method string - URL string - Allowed bool - Rule string // The rule that matched (if any) -} diff --git a/boundary.go b/boundary.go deleted file mode 100644 index 9599820..0000000 --- a/boundary.go +++ /dev/null @@ -1,93 +0,0 @@ -package boundary - -import ( - "context" - "crypto/tls" - "fmt" - "log/slog" - "os/exec" - "time" - - "github.com/coder/boundary/audit" - "github.com/coder/boundary/jail" - "github.com/coder/boundary/proxy" - "github.com/coder/boundary/rules" -) - -type Config struct { - RuleEngine rules.Evaluator - Auditor audit.Auditor - TLSConfig *tls.Config - Logger *slog.Logger - Jailer jail.Jailer -} - -type Boundary struct { - config Config - jailer jail.Jailer - proxyServer *proxy.Server - logger *slog.Logger - ctx context.Context - cancel context.CancelFunc -} - -func New(ctx context.Context, config Config) (*Boundary, error) { - // Create proxy server - proxyServer := proxy.NewProxyServer(proxy.Config{ - HTTPPort: 8080, - RuleEngine: config.RuleEngine, - Auditor: config.Auditor, - Logger: config.Logger, - TLSConfig: config.TLSConfig, - }) - - // Create cancellable context for boundary - ctx, cancel := context.WithCancel(ctx) - - return &Boundary{ - config: config, - jailer: config.Jailer, - proxyServer: proxyServer, - logger: config.Logger, - ctx: ctx, - cancel: cancel, - }, nil -} - -func (b *Boundary) Start() error { - // Start the jailer (network isolation) - err := b.jailer.Start() - if err != nil { - return fmt.Errorf("failed to start jailer: %v", err) - } - - // Start proxy server in background - go func() { - err := b.proxyServer.Start(b.ctx) - if err != nil { - b.logger.Error("Proxy server error", "error", err) - } - }() - - // Give proxy time to start - time.Sleep(100 * time.Millisecond) - - return nil -} - -func (b *Boundary) Command(command []string) *exec.Cmd { - return b.jailer.Command(command) -} - -func (b *Boundary) Close() error { - // Stop proxy server - if b.proxyServer != nil { - err := b.proxyServer.Stop() - if err != nil { - b.logger.Error("Failed to stop proxy server", "error", err) - } - } - - // Close jailer - return b.jailer.Close() -} \ No newline at end of file diff --git a/cli/cli.go b/cli/cli.go deleted file mode 100644 index 9e8b993..0000000 --- a/cli/cli.go +++ /dev/null @@ -1,301 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "log/slog" - "os" - "os/signal" - "os/user" - "path/filepath" - "strconv" - "strings" - "syscall" - - "github.com/coder/boundary" - "github.com/coder/boundary/audit" - "github.com/coder/boundary/jail" - "github.com/coder/boundary/rules" - "github.com/coder/boundary/tls" - "github.com/coder/serpent" -) - -// Config holds all configuration for the CLI -type Config struct { - AllowStrings []string - LogLevel string - Unprivileged bool -} - -// NewCommand creates and returns the root serpent command -func NewCommand() *serpent.Command { - // To make the top level boundary command, we just make some minor changes to the base command - cmd := BaseCommand() - cmd.Use = "boundary [flags] -- command [args...]" // Add the flags and args pieces to usage. - - // Add example usage to the long description. This is different from usage as a subcommand because it - // may be called something different when used as a subcommand / there will be a leading binary (i.e. `coder boundary` vs. `boundary`). - cmd.Long += `Examples: - # Allow only requests to github.com - boundary --allow "github.com" -- curl https://github.com - - # Monitor all requests to specific domains (allow only those) - boundary --allow "github.com/api/issues/*" --allow "GET,HEAD github.com" -- npm install - - # Block everything by default (implicit)` - - return cmd -} - -// Base command returns the boundary serpent command without the information involved in making it the -// *top level* serpent command. We are creating this split to make it easier to integrate into the coder -// CLI if needed. -func BaseCommand() *serpent.Command { - config := Config{} - - return &serpent.Command{ - Use: "boundary", - Short: "Network isolation tool for monitoring and restricting HTTP/HTTPS requests", - Long: `boundary creates an isolated network environment for target processes, intercepting HTTP/HTTPS traffic through a transparent proxy that enforces user-defined allow rules.`, - Options: []serpent.Option{ - serpent.Option{ - Flag: "allow", - Env: "BOUNDARY_ALLOW", - Description: "Allow rule (repeatable). Format: \"pattern\" or \"METHOD[,METHOD] pattern\".", - Value: serpent.StringArrayOf(&config.AllowStrings), - }, - serpent.Option{ - Flag: "log-level", - Env: "BOUNDARY_LOG_LEVEL", - Description: "Set log level (error, warn, info, debug).", - Default: "warn", - Value: serpent.StringOf(&config.LogLevel), - }, - serpent.Option{ - Flag: "unprivileged", - Env: "BOUNDARY_UNPRIVILEGED", - Description: "Run in unprivileged mode (no network isolation, uses proxy environment variables).", - Value: serpent.BoolOf(&config.Unprivileged), - }, - }, - Handler: func(inv *serpent.Invocation) error { - args := inv.Args - return Run(inv.Context(), config, args) - }, - } -} - -// Run executes the boundary command with the given configuration and arguments -func Run(ctx context.Context, config Config, args []string) error { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - logger := setupLogging(config.LogLevel) - username, uid, gid, homeDir, configDir := getUserInfo() - - // Get command arguments - if len(args) == 0 { - return fmt.Errorf("no command specified") - } - - // Parse allow list; default to deny-all if none provided - if len(config.AllowStrings) == 0 { - 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) - } - - // Create rule engine - ruleEngine := rules.NewRuleEngine(allowRules, logger) - - // Create auditor - auditor := audit.NewLogAuditor(logger) - - // Create TLS certificate manager - certManager, err := tls.NewCertificateManager(tls.Config{ - Logger: logger, - ConfigDir: configDir, - Uid: uid, - Gid: gid, - }) - if err != nil { - logger.Error("Failed to create certificate manager", "error", err) - return fmt.Errorf("failed to create certificate manager: %v", err) - } - - // Setup TLS to get cert path for jailer - tlsConfig, caCertPath, configDir, err := certManager.SetupTLSAndWriteCACert() - if err != nil { - return fmt.Errorf("failed to setup TLS and CA certificate: %v", err) - } - - // Create jailer with cert path from TLS setup - jailer, err := createJailer(jail.Config{ - Logger: logger, - HttpProxyPort: 8080, - Username: username, - Uid: uid, - Gid: gid, - HomeDir: homeDir, - ConfigDir: configDir, - CACertPath: caCertPath, - }, config.Unprivileged) - if err != nil { - return fmt.Errorf("failed to create jailer: %v", err) - } - - // Create boundary instance - boundaryInstance, err := boundary.New(ctx, boundary.Config{ - RuleEngine: ruleEngine, - Auditor: auditor, - TLSConfig: tlsConfig, - Logger: logger, - Jailer: jailer, - }) - if err != nil { - return fmt.Errorf("failed to create boundary instance: %v", err) - } - - // Setup signal handling BEFORE any setup - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - // Open boundary (starts network namespace and proxy server) - err = boundaryInstance.Start() - if err != nil { - return fmt.Errorf("failed to open boundary: %v", err) - } - defer func() { - logger.Info("Closing boundary...") - err := boundaryInstance.Close() - if err != nil { - logger.Error("Failed to close boundary", "error", err) - } - }() - - // Execute command in boundary - go func() { - defer cancel() - cmd := boundaryInstance.Command(args) - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout - cmd.Stdin = os.Stdin - - logger.Debug("Executing command in boundary", "command", strings.Join(args, " ")) - err := cmd.Run() - if err != nil { - logger.Error("Command execution failed", "error", err) - } - }() - - // Wait for signal or context cancellation - select { - case sig := <-sigChan: - logger.Info("Received signal, shutting down...", "signal", sig) - cancel() - case <-ctx.Done(): - // Context cancelled by command completion - logger.Info("Command completed, shutting down...") - } - - return nil -} - -// getUserInfo returns information about the current user, handling sudo scenarios -func getUserInfo() (string, int, int, string, string) { - // Only consider SUDO_USER if we're actually running with elevated privileges - // In environments like Coder workspaces, SUDO_USER may be set to 'root' - // but we're not actually running under sudo - if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" && os.Geteuid() == 0 && sudoUser != "root" { - // We're actually running under sudo with a non-root original user - user, err := user.Lookup(sudoUser) - if err != nil { - return getCurrentUserInfo() // Fallback to current user - } - - uid, _ := strconv.Atoi(os.Getenv("SUDO_UID")) - gid, _ := strconv.Atoi(os.Getenv("SUDO_GID")) - - // If we couldn't get UID/GID from env, parse from user info - if uid == 0 { - if parsedUID, err := strconv.Atoi(user.Uid); err == nil { - uid = parsedUID - } - } - if gid == 0 { - if parsedGID, err := strconv.Atoi(user.Gid); err == nil { - gid = parsedGID - } - } - - configDir := getConfigDir(user.HomeDir) - - return sudoUser, uid, gid, user.HomeDir, configDir - } - - // Not actually running under sudo, use current user - return getCurrentUserInfo() -} - -// setupLogging creates a slog logger with the specified level -func setupLogging(logLevel string) *slog.Logger { - var level slog.Level - switch strings.ToLower(logLevel) { - case "error": - level = slog.LevelError - case "warn": - level = slog.LevelWarn - case "info": - level = slog.LevelInfo - case "debug": - level = slog.LevelDebug - default: - level = slog.LevelWarn // Default to warn if invalid level - } - - // Create a standard slog logger with the appropriate level - handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: level, - }) - - return slog.New(handler) -} - -// getCurrentUserInfo gets information for the current user -func getCurrentUserInfo() (string, int, int, string, string) { - currentUser, err := user.Current() - if err != nil { - // Fallback with empty values if we can't get user info - return "", 0, 0, "", "" - } - - uid, _ := strconv.Atoi(currentUser.Uid) - gid, _ := strconv.Atoi(currentUser.Gid) - - configDir := getConfigDir(currentUser.HomeDir) - - return currentUser.Username, uid, gid, currentUser.HomeDir, configDir -} - -// getConfigDir determines the config directory based on XDG_CONFIG_HOME or fallback -func getConfigDir(homeDir string) string { - // Use XDG_CONFIG_HOME if set, otherwise fallback to ~/.config/coder_boundary - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { - return filepath.Join(xdgConfigHome, "coder_boundary") - } - return filepath.Join(homeDir, ".config", "coder_boundary") -} - -// createJailer creates a new jail instance for the current platform -func createJailer(config jail.Config, unprivileged bool) (jail.Jailer, error) { - if unprivileged { - return jail.NewUnprivileged(config) - } - - // Use the DefaultOS function for platform-specific jail creation - return jail.DefaultOS(config) -} diff --git a/cmd/boundary/main.go b/cmd/boundary/main.go index 86eefa3..bba87e6 100644 --- a/cmd/boundary/main.go +++ b/cmd/boundary/main.go @@ -2,9 +2,6 @@ package main import ( "fmt" - "os" - - "github.com/coder/boundary/cli" ) // Version information injected at build time @@ -14,11 +11,5 @@ var ( ) func main() { - cmd := cli.NewCommand() - - err := cmd.Invoke().WithOS().Run() - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } + fmt.Println("(づ。◕‿◕。)づ Boundary version:", version) } diff --git a/go.mod b/go.mod index 638ddb7..be8f8fb 100644 --- a/go.mod +++ b/go.mod @@ -1,31 +1,3 @@ module github.com/coder/boundary go 1.24 - -require github.com/coder/serpent v0.10.0 - -require ( - cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/muesli/termenv v0.15.2 // indirect - github.com/pion/transport/v2 v2.0.0 // indirect - github.com/pion/udp v0.1.4 // indirect - github.com/rivo/uniseg v0.4.4 // indirect - github.com/spf13/pflag v1.0.5 // indirect - go.opentelemetry.io/otel v1.19.0 // indirect - go.opentelemetry.io/otel/trace v1.19.0 // indirect - golang.org/x/crypto v0.19.0 // indirect - golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect - golang.org/x/sys v0.17.0 // indirect - golang.org/x/term v0.17.0 // indirect - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum index d751167..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,146 +0,0 @@ -cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 h1:KHblWIE/KHOwQ6lEbMZt6YpcGve2FEZ1sDtrW1Am5UI= -cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ= -cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY= -cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/logging v1.8.1 h1:26skQWPeYhvIasWKm48+Eq7oUqdcdbwsCVwz5Ys0FvU= -cloud.google.com/go/logging v1.8.1/go.mod h1:TJjR+SimHwuC8MZ9cjByQulAMgni+RkXeI3wwctHJEI= -cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI= -cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU= -github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= -github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= -github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= -github.com/coder/serpent v0.10.0 h1:ofVk9FJXSek+SmL3yVE3GoArP83M+1tX+H7S4t8BSuM= -github.com/coder/serpent v0.10.0/go.mod h1:cZFW6/fP+kE9nd/oRkEHJpG6sXCtQ+AX7WMMEHv0Y3Q= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= -github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= -github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= -github.com/pion/transport/v2 v2.0.0 h1:bsMYyqHCbkvHwj+eNCFBuxtlKndKfyGI2vaQmM3fIE4= -github.com/pion/transport/v2 v2.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc= -github.com/pion/udp v0.1.4 h1:OowsTmu1Od3sD6i3fQUJxJn2fEvJO6L1TidgadtbTI8= -github.com/pion/udp v0.1.4/go.mod h1:G8LDo56HsFwC24LIcnT4YIDU5qcB6NepqqjP0keL2us= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= -go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= -go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= -go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= -go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= -go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= -go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= -go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= -golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= -google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY= -google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo= -google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= -google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= -google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/jail/jail.go b/jail/jail.go deleted file mode 100644 index b59bf2d..0000000 --- a/jail/jail.go +++ /dev/null @@ -1,37 +0,0 @@ -package jail - -import ( - "fmt" - "log/slog" - "os/exec" - "runtime" -) - -type Jailer interface { - Start() error - Command(command []string) *exec.Cmd - Close() error -} - -type Config struct { - Logger *slog.Logger - HttpProxyPort int - Username string - Uid int - Gid int - HomeDir string - ConfigDir string - CACertPath string -} - -// DefaultOS returns the appropriate jailer implementation for the current operating system -func DefaultOS(config Config) (Jailer, error) { - switch runtime.GOOS { - case "linux": - return NewLinuxJail(config) - case "darwin": - return NewMacOSJail(config) - default: - return nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } -} \ No newline at end of file diff --git a/jail/jail_test.go b/jail/jail_test.go deleted file mode 100644 index 5e8e051..0000000 --- a/jail/jail_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package jail - -import "testing" - -// Stub test file - tests removed -func TestStub(t *testing.T) { - // This is a stub test - t.Skip("stub test file") -} diff --git a/jail/linux.go b/jail/linux.go deleted file mode 100644 index 085e69f..0000000 --- a/jail/linux.go +++ /dev/null @@ -1,273 +0,0 @@ -//go:build linux - -package jail - -import ( - "fmt" - "log/slog" - "os" - "os/exec" - "time" -) - -// LinuxJail implements Jailer using Linux network namespaces -type LinuxJail struct { - logger *slog.Logger - namespace string - vethHost string // Host-side veth interface name for iptables rules - commandEnv []string - httpProxyPort int - configDir string - caCertPath string - homeDir string - username string - uid int - gid int -} - -func NewLinuxJail(config Config) (*LinuxJail, error) { - return &LinuxJail{ - logger: config.Logger, - namespace: newNamespaceName(), - httpProxyPort: config.HttpProxyPort, - configDir: config.ConfigDir, - caCertPath: config.CACertPath, - homeDir: config.HomeDir, - username: config.Username, - uid: config.Uid, - gid: config.Gid, - }, nil -} - -// Start creates network namespace and configures iptables rules -func (l *LinuxJail) Start() 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 - err := l.setupDNS() - if err != nil { - return fmt.Errorf("failed to setup DNS: %v", err) - } - - // Create namespace - err = l.createNamespace() - if err != nil { - return fmt.Errorf("failed to create namespace: %v", err) - } - - // Setup networking within namespace - err = l.setupNetworking() - if err != nil { - return fmt.Errorf("failed to setup networking: %v", err) - } - - // Setup iptables rules on host - err = l.setupIptables() - if err != nil { - return fmt.Errorf("failed to setup iptables: %v", err) - } - - return nil -} - -// Command returns an exec.Cmd configured to run within the network namespace -func (l *LinuxJail) Command(command []string) *exec.Cmd { - l.logger.Debug("Creating command with namespace", "namespace", l.namespace) - - cmdArgs := []string{"ip", "netns", "exec", l.namespace} - cmdArgs = append(cmdArgs, command...) - - cmd := exec.Command("sudo", cmdArgs...) - cmd.Env = l.commandEnv - - return cmd -} - -// Close removes the network namespace and iptables rules -func (l *LinuxJail) Close() error { - l.logger.Debug("Close called") - - // Clean up iptables rules - err := l.cleanupIptables() - if err != nil { - l.logger.Error("Failed to clean up iptables rules", "error", err) - // Continue with other cleanup even if this fails - } - - // Clean up networking - err = l.cleanupNetworking() - if err != nil { - l.logger.Error("Failed to clean up networking", "error", err) - // Continue with other cleanup even if this fails - } - - // Clean up namespace-specific DNS config directory - netnsEtc := fmt.Sprintf("/etc/netns/%s", l.namespace) - err = os.RemoveAll(netnsEtc) - if err != nil { - l.logger.Warn("Failed to remove namespace DNS config", "dir", netnsEtc, "error", err) - // Continue with other cleanup - } - - // Remove network namespace - err = l.removeNamespace() - if err != nil { - return fmt.Errorf("failed to remove namespace: %v", err) - } - - return nil -} - -// createNamespace creates a new network namespace -func (l *LinuxJail) createNamespace() error { - cmd := exec.Command("ip", "netns", "add", l.namespace) - err := cmd.Run() - if err != nil { - return fmt.Errorf("failed to create namespace: %v", err) - } - return nil -} - -// setupNetworking configures networking within the namespace -func (l *LinuxJail) 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 - vethHost := fmt.Sprintf("veth_h_%s", uniqueID) // veth_h_1234567 = 14 chars - vethNetJail := fmt.Sprintf("veth_n_%s", uniqueID) // veth_n_1234567 = 14 chars - - // Store veth interface name for iptables rules - l.vethHost = vethHost - - setupCmds := []struct { - description string - command *exec.Cmd - }{ - {"create veth pair", exec.Command("ip", "link", "add", vethHost, "type", "veth", "peer", "name", vethNetJail)}, - {"move veth to namespace", exec.Command("ip", "link", "set", vethNetJail, "netns", l.namespace)}, - {"configure host veth", exec.Command("ip", "addr", "add", "192.168.100.1/24", "dev", vethHost)}, - {"bring up host veth", exec.Command("ip", "link", "set", vethHost, "up")}, - {"configure namespace veth", exec.Command("ip", "netns", "exec", l.namespace, "ip", "addr", "add", "192.168.100.2/24", "dev", vethNetJail)}, - {"bring up namespace veth", exec.Command("ip", "netns", "exec", l.namespace, "ip", "link", "set", vethNetJail, "up")}, - {"bring up loopback", exec.Command("ip", "netns", "exec", l.namespace, "ip", "link", "set", "lo", "up")}, - {"set default route in namespace", exec.Command("ip", "netns", "exec", l.namespace, "ip", "route", "add", "default", "via", "192.168.100.1")}, - } - - for _, command := range setupCmds { - if err := command.command.Run(); err != nil { - return fmt.Errorf("failed to %s: %v", command.description, err) - } - } - - return nil -} - -// 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 { - // 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) - err := os.MkdirAll(netnsEtc, 0755) - if err != nil { - return fmt.Errorf("failed to create /etc/netns directory: %v", err) - } - - // Write custom resolv.conf with multiple reliable public DNS servers - resolvConfPath := fmt.Sprintf("%s/resolv.conf", netnsEtc) - dnsConfig := `# Custom DNS for network namespace -nameserver 8.8.8.8 -nameserver 8.8.4.4 -nameserver 1.1.1.1 -nameserver 9.9.9.9 -options timeout:2 attempts:2 -` - err = os.WriteFile(resolvConfPath, []byte(dnsConfig), 0644) - if err != nil { - return fmt.Errorf("failed to write namespace-specific resolv.conf: %v", err) - } - - l.logger.Debug("DNS setup completed") - return nil -} - -// setupIptables configures iptables rules for comprehensive TCP traffic interception -func (l *LinuxJail) setupIptables() error { - // Enable IP forwarding - cmd := exec.Command("sysctl", "-w", "net.ipv4.ip_forward=1") - _ = cmd.Run() // Ignore error - - // NAT rules for outgoing traffic (MASQUERADE for return traffic) - cmd = exec.Command("iptables", "-t", "nat", "-A", "POSTROUTING", "-s", "192.168.100.0/24", "-j", "MASQUERADE") - err := cmd.Run() - if err != nil { - return fmt.Errorf("failed to add NAT rule: %v", err) - } - - // COMPREHENSIVE APPROACH: Route ALL TCP traffic to HTTP proxy - // The HTTP proxy will intelligently handle both HTTP and TLS traffic - cmd = exec.Command("iptables", "-t", "nat", "-A", "PREROUTING", "-i", l.vethHost, "-p", "tcp", "-j", "REDIRECT", "--to-ports", fmt.Sprintf("%d", l.httpProxyPort)) - err = cmd.Run() - if err != nil { - return fmt.Errorf("failed to add comprehensive TCP redirect rule: %v", err) - } - - l.logger.Debug("Comprehensive TCP boundarying enabled", "interface", l.vethHost, "proxy_port", l.httpProxyPort) - return nil -} - -// cleanupIptables removes iptables rules -func (l *LinuxJail) cleanupIptables() error { - // Remove comprehensive TCP redirect rule - cmd := exec.Command("iptables", "-t", "nat", "-D", "PREROUTING", "-i", l.vethHost, "-p", "tcp", "-j", "REDIRECT", "--to-ports", fmt.Sprintf("%d", l.httpProxyPort)) - err := cmd.Run() - if err != nil { - l.logger.Error("Failed to remove TCP redirect rule", "error", err) - // Continue with other cleanup even if this fails - } - - // Remove NAT rule - cmd = exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", "192.168.100.0/24", "-j", "MASQUERADE") - err = cmd.Run() - if err != nil { - l.logger.Error("Failed to remove NAT rule", "error", err) - // Continue with other cleanup even if this fails - } - - return nil -} - -// cleanupNetworking removes networking configuration -func (l *LinuxJail) cleanupNetworking() error { - // Generate unique ID to match veth pair - uniqueID := fmt.Sprintf("%d", time.Now().UnixNano()%10000000) // 7 digits max - vethHost := fmt.Sprintf("veth_h_%s", uniqueID) // veth_h_1234567 = 14 chars - - // Clean up networking - cleanupCmds := []struct { - description string - command *exec.Cmd - }{ - {"delete veth pair", exec.Command("ip", "link", "del", vethHost)}, - } - - for _, command := range cleanupCmds { - if err := command.command.Run(); err != nil { - return fmt.Errorf("failed to %s: %v", command.description, err) - } - } - - return nil -} - -// removeNamespace removes the network namespace -func (l *LinuxJail) removeNamespace() error { - cmd := exec.Command("ip", "netns", "del", l.namespace) - err := cmd.Run() - if err != nil { - return fmt.Errorf("failed to remove namespace: %v", err) - } - return nil -} diff --git a/jail/linux_stub.go b/jail/linux_stub.go deleted file mode 100644 index 19d32dc..0000000 --- a/jail/linux_stub.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build !linux - -package jail - -import ( - "fmt" -) - -// NewLinuxJail is not available on non-Linux platforms -func NewLinuxJail(_ Config) (Jailer, error) { - return nil, fmt.Errorf("linux jail not supported on this platform") -} \ No newline at end of file diff --git a/jail/macos.go b/jail/macos.go deleted file mode 100644 index f10c273..0000000 --- a/jail/macos.go +++ /dev/null @@ -1,342 +0,0 @@ -//go:build darwin - -package jail - -import ( - "fmt" - "log/slog" - "os" - "os/exec" - "strconv" - "strings" - "syscall" -) - -const ( - pfAnchorName = "coder_boundary" - groupName = "coder_boundary" -) - -// MacOSJail implements network boundary using macOS PF (Packet Filter) and group-based isolation -type MacOSJail struct { - restrictedGid int - pfRulesPath string - mainRulesPath string - logger *slog.Logger - commandEnv []string - procAttr *syscall.SysProcAttr - httpProxyPort int - configDir string - caCertPath string - homeDir string - username string - uid int - gid int -} - -// NewMacOSJail creates a new macOS network boundary instance -func NewMacOSJail(config Config) (*MacOSJail, error) { - ns := newNamespaceName() - pfRulesPath := fmt.Sprintf("/tmp/%s.pf", ns) - mainRulesPath := fmt.Sprintf("/tmp/%s_main.pf", ns) - - return &MacOSJail{ - pfRulesPath: pfRulesPath, - mainRulesPath: mainRulesPath, - logger: config.Logger, - httpProxyPort: config.HttpProxyPort, - configDir: config.ConfigDir, - caCertPath: config.CACertPath, - homeDir: config.HomeDir, - username: config.Username, - uid: config.Uid, - gid: config.Gid, - }, nil -} - -// Setup creates the network boundary group and configures PF rules -func (n *MacOSJail) Start() error { - n.logger.Debug("Setup called") - - // Create or get network boundary group - n.logger.Debug("Creating or ensuring network boundary group") - err := n.ensureGroup() - if err != nil { - return fmt.Errorf("failed to ensure group: %v", err) - } - - // Setup PF rules - n.logger.Debug("Setting up PF rules") - err = n.setupPFRules() - if err != nil { - return fmt.Errorf("failed to setup PF rules: %v", err) - } - - // Prepare environment once during setup - n.logger.Debug("Preparing environment") - - e := getEnvs(n.configDir, n.caCertPath) - n.commandEnv = mergeEnvs(e, map[string]string{ - "HOME": n.homeDir, - "USER": n.username, - "LOGNAME": n.username, - }) - - // Prepare process credentials once during setup - n.logger.Debug("Preparing process credentials") - // Use original user ID but KEEP the boundary group for network isolation - procAttr := &syscall.SysProcAttr{ - Credential: &syscall.Credential{ - Uid: uint32(n.uid), - Gid: uint32(n.restrictedGid), - }, - } - - // Store prepared process attributes for use in Command method - n.procAttr = procAttr - - n.logger.Debug("Setup completed successfully") - return nil -} - -// Command runs the command with the network boundary group membership -func (n *MacOSJail) Command(command []string) *exec.Cmd { - n.logger.Debug("Command called", "command", command) - - // Create command directly (no sg wrapper needed) - n.logger.Debug("Creating command with group membership", "groupID", n.restrictedGid) - cmd := exec.Command(command[0], command[1:]...) - n.logger.Debug("Full command args", "args", command) - - cmd.Env = n.commandEnv - - // Use prepared process attributes from Open method - cmd.SysProcAttr = n.procAttr - - return cmd -} - -// Cleanup removes PF rules and cleans up temporary files -func (n *MacOSJail) Close() error { - n.logger.Debug("Starting cleanup process") - - // Remove PF rules - n.logger.Debug("Removing PF rules") - err := n.removePFRules() - if err != nil { - return fmt.Errorf("failed to remove PF rules: %v", err) - } - - // Clean up temporary files - n.logger.Debug("Cleaning up temporary files") - n.cleanupTempFiles() - - n.logger.Debug("Cleanup completed successfully") - return nil -} - -// ensureGroup creates the network boundary group if it doesn't exist -func (n *MacOSJail) ensureGroup() error { - // Check if group already exists - output, err := exec.Command("dscl", ".", "-read", fmt.Sprintf("/Groups/%s", groupName), "PrimaryGroupID").Output() - if err == nil { - // Parse GID from output - stdout := string(output) - for _, line := range strings.Split(stdout, "\n") { - if strings.Contains(line, "PrimaryGroupID") { - parts := strings.Fields(line) - if len(parts) >= 2 { - gid, err := strconv.Atoi(parts[len(parts)-1]) - if err != nil { - return fmt.Errorf("failed to parse GID: %v", err) - } - n.restrictedGid = gid - return nil - } - } - } - } - - // Group doesn't exist, create it - 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", groupName), "PrimaryGroupID").Output() - if err != nil { - return fmt.Errorf("failed to read group GID: %v", err) - } - - stdout := string(output) - for _, line := range strings.Split(stdout, "\n") { - if strings.Contains(line, "PrimaryGroupID") { - parts := strings.Fields(line) - if len(parts) >= 2 { - gid, err := strconv.Atoi(parts[len(parts)-1]) - if err != nil { - return fmt.Errorf("failed to parse GID: %v", err) - } - n.restrictedGid = gid - return nil - } - } - } - - return fmt.Errorf("failed to get GID for group %s", groupName) -} - -// getDefaultInterface gets the default network interface -func (n *MacOSJail) getDefaultInterface() (string, error) { - output, err := exec.Command("route", "-n", "get", "default").Output() - if err != nil { - return "", fmt.Errorf("failed to get default route: %v", err) - } - - stdout := string(output) - for _, line := range strings.Split(stdout, "\n") { - if strings.Contains(line, "interface:") { - parts := strings.Fields(line) - if len(parts) >= 2 { - return parts[1], nil - } - } - } - - // Fallback to en0 if we can't determine - return "en0", nil -} - -// createPFRules creates PF rules for comprehensive TCP traffic diversion -func (n *MacOSJail) createPFRules() (string, error) { - // Get the default network interface - iface, err := n.getDefaultInterface() - if err != nil { - return "", fmt.Errorf("failed to get default interface: %v", err) - } - - // Create comprehensive PF rules for ALL TCP traffic interception - // This prevents bypass via non-standard ports (8080, 3306, 22, etc.) - rules := fmt.Sprintf(`# comprehensive TCP boundarying PF rules for GID %d on interface %s -# COMPREHENSIVE APPROACH: Intercept ALL TCP traffic from the boundaryed group -# This ensures NO TCP traffic can bypass the proxy by using alternative ports - -# First, redirect ALL TCP traffic arriving on lo0 to our HTTP proxy with TLS termination -# The HTTP proxy with TLS termination can handle both HTTP and HTTPS traffic -rdr pass on lo0 inet proto tcp from any to any -> 127.0.0.1 port %d - -# Route ALL TCP traffic from boundary group to lo0 where it will be redirected -pass out route-to (lo0 127.0.0.1) inet proto tcp from any to any group %d keep state - -# Also handle ALL TCP traffic on the specific interface from the group -pass out on %s route-to (lo0 127.0.0.1) inet proto tcp from any to any group %d keep state - -# Allow all loopback traffic -pass on lo0 all -`, - n.restrictedGid, - iface, - n.httpProxyPort, // Use HTTP proxy with TLS termination for all TCP traffic - n.restrictedGid, - iface, - n.restrictedGid, - ) - - n.logger.Debug("Comprehensive TCP boundarying enabled for macOS", "group_id", n.restrictedGid, "proxy_port", n.httpProxyPort) - return rules, nil -} - -// setupPFRules configures packet filter rules to redirect traffic -func (n *MacOSJail) setupPFRules() error { - // Create PF rules - rules, err := n.createPFRules() - if err != nil { - return fmt.Errorf("failed to create PF rules: %v", err) - } - - // Write rules to temp file - err = os.WriteFile(n.pfRulesPath, []byte(rules), 0644) - if err != nil { - return fmt.Errorf("failed to write PF rules file: %v", err) - } - - // Load rules into anchor - cmd := exec.Command("pfctl", "-a", pfAnchorName, "-f", n.pfRulesPath) - err = cmd.Run() - if err != nil { - return fmt.Errorf("failed to load PF rules: %v", err) - } - - // Enable PF if not already enabled - cmd = exec.Command("pfctl", "-E") - _ = cmd.Run() // Ignore error as PF might already be enabled - - // Create and load main ruleset that includes our anchor - mainRules := fmt.Sprintf(`# Temporary main ruleset to include boundary anchor -# Include default Apple anchors (in required order) -# 1. Normalization -scrub-anchor "com.apple/*" -# 2. Queueing -dummynet-anchor "com.apple/*" -# 3. Translation (NAT/RDR) -nat-anchor "com.apple/*" -rdr-anchor "com.apple/*" -rdr-anchor "%s" -# 4. Filtering -anchor "com.apple/*" -anchor "%s" -`, pfAnchorName, pfAnchorName) - - // Write and load the main ruleset - err = os.WriteFile(n.mainRulesPath, []byte(mainRules), 0644) - if err != nil { - return fmt.Errorf("failed to write main PF rules: %v", err) - } - - cmd = exec.Command("pfctl", "-f", n.mainRulesPath) - err = cmd.Run() - if err != nil { - // Don't fail if main rules can't be loaded, but warn - fmt.Fprintf(os.Stderr, "Warning: failed to load main PF rules: %v\n", err) - } - - // Verify that rules were loaded correctly - cmd = exec.Command("pfctl", "-a", pfAnchorName, "-s", "rules") - output, err := cmd.Output() - if err == nil && len(output) > 0 { - // Rules loaded successfully - return nil - } - - return nil -} - -// removePFRules removes PF rules from anchor -func (n *MacOSJail) removePFRules() error { - // Flush the anchor - cmd := exec.Command("pfctl", "-a", pfAnchorName, "-F", "all") - err := cmd.Run() - if err != nil { - return fmt.Errorf("failed to flush PF anchor: %v", err) - } - - return nil -} - -// cleanupTempFiles removes temporary rule files -func (n *MacOSJail) cleanupTempFiles() { - if n.pfRulesPath != "" { - err := os.Remove(n.pfRulesPath) - if err != nil { - n.logger.Error("Failed to remove temporary PF rules file", "file", n.pfRulesPath, "error", err) - } - } - if n.mainRulesPath != "" { - err := os.Remove(n.mainRulesPath) - if err != nil { - n.logger.Error("Failed to remove temporary main PF rules file", "file", n.mainRulesPath, "error", err) - } - } -} diff --git a/jail/macos_stub.go b/jail/macos_stub.go deleted file mode 100644 index 89f86a0..0000000 --- a/jail/macos_stub.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !darwin - -package jail - -import "fmt" - -// NewMacOSJail is not available on non-macOS platforms -func NewMacOSJail(_ Config) (Jailer, error) { - return nil, fmt.Errorf("macOS jail not supported on this platform") -} \ No newline at end of file diff --git a/jail/unprivileged.go b/jail/unprivileged.go deleted file mode 100644 index 516ac9c..0000000 --- a/jail/unprivileged.go +++ /dev/null @@ -1,62 +0,0 @@ -package jail - -import ( - "fmt" - "log/slog" - "os/exec" -) - -type Unprivileged struct { - logger *slog.Logger - commandEnv []string - httpProxyPort int - configDir string - caCertPath string - homeDir string - username string - uid int - gid int -} - -func NewUnprivileged(config Config) (*Unprivileged, error) { - return &Unprivileged{ - logger: config.Logger, - httpProxyPort: config.HttpProxyPort, - configDir: config.ConfigDir, - caCertPath: config.CACertPath, - homeDir: config.HomeDir, - username: config.Username, - uid: config.Uid, - gid: config.Gid, - }, nil -} - -func (u *Unprivileged) Start() error { - u.logger.Debug("Starting in unprivileged mode") - e := getEnvs(u.configDir, u.caCertPath) - p := fmt.Sprintf("http://localhost:%d", u.httpProxyPort) - u.commandEnv = mergeEnvs(e, map[string]string{ - "HOME": u.homeDir, - "USER": u.username, - "LOGNAME": u.username, - "HTTP_PROXY": p, - "HTTPS_PROXY": p, - "http_proxy": p, - "https_proxy": p, - }) - return nil -} - -func (u *Unprivileged) Command(command []string) *exec.Cmd { - u.logger.Debug("Creating unprivileged command", "command", command) - - cmd := exec.Command(command[0], command[1:]...) - cmd.Env = u.commandEnv - - return cmd -} - -func (u *Unprivileged) Close() error { - u.logger.Debug("Closing unprivileged jail") - return nil -} diff --git a/jail/util.go b/jail/util.go deleted file mode 100644 index 8a230d7..0000000 --- a/jail/util.go +++ /dev/null @@ -1,54 +0,0 @@ -package jail - -import ( - "fmt" - "os" - "strings" - "time" -) - -const ( - prefix = "coder_boundary" -) - -func newNamespaceName() string { - return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()%10000000) -} - -func getEnvs(configDir string, caCertPath string) []string { - e := os.Environ() - - e = mergeEnvs(e, map[string]string{ - // Set standard CA certificate environment variables for common tools - // This makes tools like curl, git, etc. trust our dynamically generated CA - "SSL_CERT_FILE": caCertPath, // OpenSSL/LibreSSL-based tools - "SSL_CERT_DIR": configDir, // OpenSSL certificate directory - "CURL_CA_BUNDLE": caCertPath, // curl - "GIT_SSL_CAINFO": caCertPath, // Git - "REQUESTS_CA_BUNDLE": caCertPath, // Python requests - "NODE_EXTRA_CA_CERTS": caCertPath, // Node.js - }) - - return e -} - -func mergeEnvs(base []string, extra map[string]string) []string { - envMap := make(map[string]string) - for _, env := range base { - parts := strings.SplitN(env, "=", 2) - if len(parts) == 2 { - envMap[parts[0]] = parts[1] - } - } - - for key, value := range extra { - envMap[key] = value - } - - merged := make([]string, 0, len(envMap)) - for key, value := range envMap { - merged = append(merged, key+"="+value) - } - - return merged -} diff --git a/proxy/proxy.go b/proxy/proxy.go deleted file mode 100644 index 6328a38..0000000 --- a/proxy/proxy.go +++ /dev/null @@ -1,744 +0,0 @@ -package proxy - -import ( - "bufio" - "context" - "crypto/tls" - "fmt" - "io" - "log/slog" - "net" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "github.com/coder/boundary/audit" - "github.com/coder/boundary/rules" -) - -// Server handles HTTP and HTTPS requests with rule-based filtering -type Server struct { - ruleEngine rules.Evaluator - auditor audit.Auditor - logger *slog.Logger - tlsConfig *tls.Config - httpPort int - - httpServer *http.Server -} - -// Config holds configuration for the proxy server -type Config struct { - HTTPPort int - RuleEngine rules.Evaluator - Auditor audit.Auditor - Logger *slog.Logger - TLSConfig *tls.Config -} - -// NewProxyServer creates a new proxy server instance -func NewProxyServer(config Config) *Server { - return &Server{ - ruleEngine: config.RuleEngine, - auditor: config.Auditor, - logger: config.Logger, - tlsConfig: config.TLSConfig, - httpPort: config.HTTPPort, - } -} - -// Start starts the HTTP proxy server with TLS termination capability -func (p *Server) Start(ctx context.Context) error { - // Create HTTP server with TLS termination capability - p.httpServer = &http.Server{ - Addr: fmt.Sprintf(":%d", p.httpPort), - Handler: http.HandlerFunc(p.handleHTTPWithTLSTermination), - } - - // Start HTTP server with custom listener for TLS detection - go func() { - p.logger.Info("Starting HTTP proxy with TLS termination", "port", p.httpPort) - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", p.httpPort)) - if err != nil { - p.logger.Error("Failed to create HTTP listener", "error", err) - return - } - - for { - conn, err := listener.Accept() - if err != nil { - select { - case <-ctx.Done(): - err = listener.Close() - if err != nil { - p.logger.Error("Failed to close listener", "error", err) - } - return - default: - p.logger.Error("Failed to accept connection", "error", err) - continue - } - } - - // Handle connection with TLS detection - go p.handleConnectionWithTLSDetection(conn) - } - }() - - // Wait for context cancellation - <-ctx.Done() - return p.Stop() -} - -// Stops proxy server -func (p *Server) Stop() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var httpErr error - if p.httpServer != nil { - httpErr = p.httpServer.Shutdown(ctx) - } - - if httpErr != nil { - return httpErr - } - return nil -} - -// handleHTTP handles regular HTTP requests and CONNECT tunneling -func (p *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { - p.logger.Debug("handleHTTP called", "method", r.Method, "url", r.URL.String(), "host", r.Host) - - // Handle CONNECT method for HTTPS tunneling - if r.Method == "CONNECT" { - p.handleConnect(w, r) - return - } - - // Ensure URL is fully qualified - if r.URL.Host == "" { - r.URL.Host = r.Host - } - if r.URL.Scheme == "" { - r.URL.Scheme = "http" - } - - // Check if request should be allowed - result := p.ruleEngine.Evaluate(r.Method, r.URL.String()) - - // Audit the request - p.auditor.AuditRequest(audit.Request{ - Method: r.Method, - URL: r.URL.String(), - Allowed: result.Allowed, - Rule: result.Rule, - }) - - if !result.Allowed { - p.writeBlockedResponse(w, r) - return - } - - // Forward regular HTTP request - p.forwardRequest(w, r, false) -} - -// forwardRequest forwards a regular HTTP request -func (p *Server) forwardRequest(w http.ResponseWriter, r *http.Request, https bool) { - p.logger.Debug("forwardHTTPRequest called", "method", r.Method, "url", r.URL.String(), "host", r.Host) - - s := "http" - if https { - s = "https" - } - // Create a new request to the target server - targetURL := &url.URL{ - Scheme: s, - Host: r.Host, - Path: r.URL.Path, - RawQuery: r.URL.RawQuery, - } - - p.logger.Debug("Target URL constructed", "target", targetURL.String()) - - // Create HTTP client with very short timeout for debugging - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse // Don't follow redirects - }, - } - - // Create new request - req, err := http.NewRequest(r.Method, targetURL.String(), r.Body) - if err != nil { - p.logger.Error("Failed to create forward request", "error", err) - http.Error(w, fmt.Sprintf("Failed to create request: %v", err), http.StatusInternalServerError) - return - } - - // Copy headers - for name, values := range r.Header { - // Skip connection-specific headers - if strings.ToLower(name) == "connection" || strings.ToLower(name) == "proxy-connection" { - continue - } - for _, value := range values { - req.Header.Add(name, value) - } - } - - p.logger.Debug("About to make HTTP request", "target", targetURL.String()) - resp, err := client.Do(req) - if err != nil { - p.logger.Error("Failed to make forward request", "error", err, "target", targetURL.String(), "error_type", fmt.Sprintf("%T", err)) - http.Error(w, fmt.Sprintf("Failed to make request: %v", err), http.StatusBadGateway) - return - } - defer func() { _ = resp.Body.Close() }() - - p.logger.Debug("Received response", "status", resp.StatusCode, "target", targetURL.String()) - - // Copy response headers (except connection-specific ones) - for name, values := range resp.Header { - if strings.ToLower(name) == "connection" || strings.ToLower(name) == "transfer-encoding" { - continue - } - for _, value := range values { - w.Header().Add(name, value) - } - } - - // Copy status code - w.WriteHeader(resp.StatusCode) - - // Copy response body - bytesWritten, copyErr := io.Copy(w, resp.Body) - if copyErr != nil { - p.logger.Error("Error copying response body", "error", copyErr, "bytes_written", bytesWritten) - http.Error(w, "Failed to copy response", http.StatusBadGateway) - } else { - p.logger.Debug("Successfully forwarded HTTP response", "bytes_written", bytesWritten, "status", resp.StatusCode) - } - - // Ensure response is flushed - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() - } - p.logger.Debug("forwardHTTPRequest completed") -} - -// writeBlockedResponse writes a blocked response -func (p *Server) writeBlockedResponse(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusForbidden) - - // Extract host from URL for cleaner display - host := r.URL.Host - if host == "" { - host = r.Host - } - - _, _ = fmt.Fprintf(w, `🚫 Request Blocked by Boundary - -Request: %s %s -Host: %s - -To allow this request, restart boundary with: - --allow "%s" # Allow all methods to this host - --allow "%s %s" # Allow only %s requests to this host - -For more help: https://github.com/coder/boundary -`, - r.Method, r.URL.Path, host, host, r.Method, host, r.Method) -} - -// handleConnect handles CONNECT requests for HTTPS tunneling with TLS termination -func (p *Server) handleConnect(w http.ResponseWriter, r *http.Request) { - // Extract hostname from the CONNECT request - hostname := r.URL.Hostname() - if hostname == "" { - // Fallback to Host header parsing - host := r.URL.Host - if host == "" { - host = r.Host - } - if h, _, err := net.SplitHostPort(host); err == nil { - hostname = h - } else { - hostname = host - } - } - - if hostname == "" { - http.Error(w, "Invalid CONNECT request: no hostname", http.StatusBadRequest) - return - } - - // Allow all CONNECT requests - we'll evaluate rules on the decrypted HTTPS content - p.logger.Debug("Establishing CONNECT tunnel with TLS termination", "hostname", hostname) - - // Hijack the connection to handle TLS manually - hijacker, ok := w.(http.Hijacker) - if !ok { - http.Error(w, "Hijacking not supported", http.StatusInternalServerError) - return - } - - // Hijack the underlying connection - conn, _, err := hijacker.Hijack() - if err != nil { - p.logger.Error("Failed to hijack connection", "error", err) - return - } - defer func() { - err := conn.Close() - if err != nil { - p.logger.Error("Failed to close connection", "error", err) - } - }() - - // Send 200 Connection established response manually - _, err = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")) - if err != nil { - p.logger.Error("Failed to send CONNECT response", "error", err) - return - } - - // Perform TLS handshake with the client using our certificates - p.logger.Debug("Starting TLS handshake", "hostname", hostname) - - // Create TLS config that forces HTTP/1.1 (disable HTTP/2 ALPN) - tlsConfig := p.tlsConfig.Clone() - tlsConfig.NextProtos = []string{"http/1.1"} - - tlsConn := tls.Server(conn, tlsConfig) - err = tlsConn.Handshake() - if err != nil { - p.logger.Error("TLS handshake failed", "hostname", hostname, "error", err) - return - } - p.logger.Debug("TLS handshake successful", "hostname", hostname) - - // Log connection state after handshake - state := tlsConn.ConnectionState() - p.logger.Debug("TLS connection established", "hostname", hostname, "version", state.Version, "cipher_suite", state.CipherSuite, "negotiated_protocol", state.NegotiatedProtocol) - - // Now we have a TLS connection - handle HTTPS requests - p.logger.Debug("Starting HTTPS request handling", "hostname", hostname) - p.handleTLSConnection(tlsConn, hostname) - p.logger.Debug("HTTPS request handling completed", "hostname", hostname) -} - -// handleTLSConnection processes decrypted HTTPS requests over the TLS connection with streaming support -func (p *Server) handleTLSConnection(tlsConn *tls.Conn, hostname string) { - p.logger.Debug("Creating streaming HTTP handler for TLS connection", "hostname", hostname) - - // Use streaming HTTP parsing instead of ReadRequest - bufReader := bufio.NewReader(tlsConn) - for { - // Parse HTTP request headers incrementally - req, err := p.parseHTTPRequestHeaders(bufReader, hostname) - if err != nil { - if err == io.EOF { - p.logger.Debug("TLS connection closed by client", "hostname", hostname) - } else { - p.logger.Debug("Failed to parse HTTP request headers", "hostname", hostname, "error", err) - } - break - } - - p.logger.Debug("Processing streaming HTTPS request", "hostname", hostname, "method", req.Method, "path", req.URL.Path) - - // Handle CONNECT method for HTTPS tunneling - if req.Method == "CONNECT" { - p.handleConnectStreaming(tlsConn, req, hostname) - return // CONNECT takes over the entire connection - } - - // Check if request should be allowed (based on headers only) - fullURL := p.constructFullURL(req, hostname) - result := p.ruleEngine.Evaluate(req.Method, fullURL) - - // Audit the request - p.auditor.AuditRequest(audit.Request{ - Method: req.Method, - URL: fullURL, - Allowed: result.Allowed, - Rule: result.Rule, - }) - - if !result.Allowed { - p.writeBlockedResponseStreaming(tlsConn, req) - continue - } - - // Stream the request to target server - err = p.streamRequestToTarget(tlsConn, bufReader, req, hostname) - if err != nil { - p.logger.Debug("Error streaming request", "hostname", hostname, "error", err) - break - } - } - - p.logger.Debug("TLS connection handling completed", "hostname", hostname) -} - -// handleDecryptedHTTPS handles decrypted HTTPS requests and applies rules -func (p *Server) handleDecryptedHTTPS(w http.ResponseWriter, r *http.Request) { - // Handle CONNECT method for HTTPS tunneling - if r.Method == "CONNECT" { - p.handleConnect(w, r) - return - } - - fullURL := r.URL.String() - if r.URL.Host == "" { - // Fallback: construct URL from Host header - fullURL = fmt.Sprintf("https://%s%s", r.Host, r.URL.Path) - if r.URL.RawQuery != "" { - fullURL += "?" + r.URL.RawQuery - } - } - // Check if request should be allowed - result := p.ruleEngine.Evaluate(r.Method, fullURL) - - // Audit the request - p.auditor.AuditRequest(audit.Request{ - Method: r.Method, - URL: fullURL, - Allowed: result.Allowed, - Rule: result.Rule, - }) - - if !result.Allowed { - p.writeBlockedResponse(w, r) - return - } - - // Forward the HTTPS request (now handled same as HTTP after TLS termination) - p.forwardRequest(w, r, true) -} - -// handleConnectionWithTLSDetection detects TLS vs HTTP and handles appropriately -func (p *Server) handleConnectionWithTLSDetection(conn net.Conn) { - defer func() { - err := conn.Close() - if err != nil { - p.logger.Error("Failed to close connection", "error", err) - } - }() - - // Peek at first byte to detect protocol - buf := make([]byte, 1) - _, err := conn.Read(buf) - if err != nil { - p.logger.Debug("Failed to read first byte from connection", "error", err) - return - } - - // Create connection wrapper that can "unread" the peeked byte - connWrapper := &connectionWrapper{conn, buf, false} - - // TLS handshake starts with 0x16 (TLS Content Type: Handshake) - if buf[0] == 0x16 { - p.logger.Debug("Detected TLS handshake, performing TLS termination") - // Perform TLS handshake - tlsConn := tls.Server(connWrapper, p.tlsConfig) - err := tlsConn.Handshake() - if err != nil { - p.logger.Debug("TLS handshake failed", "error", err) - return - } - p.logger.Debug("TLS handshake successful") - // Use HTTP server with TLS connection - listener := newSingleConnectionListener(tlsConn) - defer func() { - err := listener.Close() - if err != nil { - p.logger.Error("Failed to close connection", "error", err) - } - }() - err = http.Serve(listener, http.HandlerFunc(p.handleDecryptedHTTPS)) - p.logger.Debug("http.Serve completed for HTTPS", "error", err) - } else { - p.logger.Debug("Detected HTTP request, handling normally") - // Use HTTP server with regular connection - p.logger.Debug("About to call http.Serve for HTTP connection") - listener := newSingleConnectionListener(connWrapper) - defer func() { - err := listener.Close() - if err != nil { - p.logger.Error("Failed to close connection", "error", err) - } - }() - err = http.Serve(listener, http.HandlerFunc(p.handleHTTP)) - p.logger.Debug("http.Serve completed", "error", err) - } -} - -// handleHTTPWithTLSTermination is the main handler (currently just delegates to regular HTTP) -func (p *Server) handleHTTPWithTLSTermination(w http.ResponseWriter, r *http.Request) { - // This handler is not used when we do custom connection handling - // All traffic goes through handleConnectionWithTLSDetection - p.handleHTTP(w, r) -} - -// connectionWrapper lets us "unread" the peeked byte -type connectionWrapper struct { - net.Conn - buf []byte - bufUsed bool -} - -func (c *connectionWrapper) Read(p []byte) (int, error) { - if !c.bufUsed && len(c.buf) > 0 { - n := copy(p, c.buf) - c.bufUsed = true - return n, nil - } - return c.Conn.Read(p) -} - -// singleConnectionListener wraps a single connection into a net.Listener -type singleConnectionListener struct { - conn net.Conn - used bool - closed chan struct{} - mu sync.Mutex -} - -func newSingleConnectionListener(conn net.Conn) *singleConnectionListener { - return &singleConnectionListener{ - conn: conn, - closed: make(chan struct{}), - } -} - -func (sl *singleConnectionListener) Accept() (net.Conn, error) { - sl.mu.Lock() - defer sl.mu.Unlock() - - if sl.used || sl.conn == nil { - // Wait for close signal - <-sl.closed - return nil, io.EOF - } - sl.used = true - return sl.conn, nil -} - -func (sl *singleConnectionListener) Close() error { - sl.mu.Lock() - defer sl.mu.Unlock() - - select { - case <-sl.closed: - // Already closed - default: - close(sl.closed) - } - - if sl.conn != nil { - err := sl.conn.Close() - if err != nil { - return fmt.Errorf("failed to close connection: %w", err) - } - sl.conn = nil - } - return nil -} - -func (sl *singleConnectionListener) Addr() net.Addr { - if sl.conn == nil { - return nil - } - return sl.conn.LocalAddr() -} - -// parseHTTPRequestHeaders parses HTTP request headers incrementally without reading the body -func (p *Server) parseHTTPRequestHeaders(bufReader *bufio.Reader, hostname string) (*http.Request, error) { - // Read the request line (e.g., "GET /path HTTP/1.1") - requestLine, _, err := bufReader.ReadLine() - if err != nil { - return nil, err - } - - // Parse request line - parts := strings.Fields(string(requestLine)) - if len(parts) != 3 { - return nil, fmt.Errorf("invalid request line: %s", requestLine) - } - - method := parts[0] - requestURI := parts[1] - proto := parts[2] - - // Parse URL - var url *url.URL - if strings.HasPrefix(requestURI, "http://") || strings.HasPrefix(requestURI, "https://") { - url, err = url.Parse(requestURI) - } else { - // Relative URL, construct with hostname - url, err = url.Parse("https://" + hostname + requestURI) - } - if err != nil { - return nil, fmt.Errorf("invalid request URI: %s", requestURI) - } - - // Read headers - headers := make(http.Header) - for { - headerLine, _, err := bufReader.ReadLine() - if err != nil { - return nil, err - } - - // Empty line indicates end of headers - if len(headerLine) == 0 { - break - } - - // Parse header - headerStr := string(headerLine) - colonIdx := strings.Index(headerStr, ":") - if colonIdx == -1 { - continue // Skip malformed headers - } - - headerName := strings.TrimSpace(headerStr[:colonIdx]) - headerValue := strings.TrimSpace(headerStr[colonIdx+1:]) - headers.Add(headerName, headerValue) - } - - // Create request object (without body) - req := &http.Request{ - Method: method, - URL: url, - Proto: proto, - Header: headers, - Host: url.Host, - // Note: Body is intentionally nil - we'll stream it separately - } - - return req, nil -} - -// constructFullURL builds the full URL from request and hostname -func (p *Server) constructFullURL(req *http.Request, hostname string) string { - if req.URL.Host == "" { - req.URL.Host = hostname - } - if req.URL.Scheme == "" { - req.URL.Scheme = "https" - } - return req.URL.String() -} - -// writeBlockedResponseStreaming writes a blocked response directly to the TLS connection -func (p *Server) writeBlockedResponseStreaming(tlsConn *tls.Conn, req *http.Request) { - response := fmt.Sprintf("HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n🚫 Request Blocked by Boundary\n\nRequest: %s %s\nHost: %s\n\nTo allow this request, restart boundary with:\n --allow \"%s\"\n", - req.Method, req.URL.Path, req.Host, req.Host) - _, _ = tlsConn.Write([]byte(response)) -} - -// streamRequestToTarget streams the HTTP request (including body) to the target server -func (p *Server) streamRequestToTarget(clientConn *tls.Conn, bufReader *bufio.Reader, req *http.Request, hostname string) error { - // Connect to target server - targetConn, err := tls.Dial("tcp", hostname+":443", &tls.Config{ServerName: hostname}) - if err != nil { - return fmt.Errorf("failed to connect to target %s: %v", hostname, err) - } - defer func() { - err := targetConn.Close() - if err != nil { - p.logger.Error("Failed to close target connection", "error", err) - } - }() - - // Send HTTP request headers to target - reqLine := fmt.Sprintf("%s %s %s\r\n", req.Method, req.URL.RequestURI(), req.Proto) - _, err = targetConn.Write([]byte(reqLine)) - if err != nil { - return fmt.Errorf("failed to write request line to target: %v", err) - } - - // Send headers - for name, values := range req.Header { - for _, value := range values { - headerLine := fmt.Sprintf("%s: %s\r\n", name, value) - _, err = targetConn.Write([]byte(headerLine)) - if err != nil { - return fmt.Errorf("failed to write header to target: %v", err) - } - } - } - _, err = targetConn.Write([]byte("\r\n")) // End of headers - if err != nil { - return fmt.Errorf("failed to write headers to target: %v", err) - } - - // Stream request body and response bidirectionally - go func() { - // Stream request body: client -> target - _, err := io.Copy(targetConn, bufReader) - if err != nil { - p.logger.Error("Error copying request body to target", "error", err) - } - }() - - // Stream response: target -> client - _, err = io.Copy(clientConn, targetConn) - if err != nil { - p.logger.Error("Error copying response from target to client", "error", err) - } - - return nil -} - -// handleConnectStreaming handles CONNECT requests with streaming TLS termination -func (p *Server) handleConnectStreaming(tlsConn *tls.Conn, req *http.Request, hostname string) { - p.logger.Debug("Handling CONNECT request with streaming", "hostname", hostname) - - // For CONNECT, we need to establish a tunnel but still maintain TLS termination - // This is the tricky part - we're already inside a TLS connection from the client - // The client is asking us to CONNECT to another server, but we want to intercept that too - - // Send CONNECT response - response := "HTTP/1.1 200 Connection established\r\n\r\n" - _, err := tlsConn.Write([]byte(response)) - if err != nil { - p.logger.Error("Failed to send CONNECT response", "error", err) - return - } - - // Now the client will try to do TLS handshake for the target server - // But we want to intercept and terminate it - // This means we need to do another level of TLS termination - - // For now, let's create a simple tunnel and log that we're not inspecting - p.logger.Warn("CONNECT tunnel established - content not inspected", "hostname", hostname) - - // Create connection to real target - targetConn, err := net.Dial("tcp", req.Host) - if err != nil { - p.logger.Error("Failed to connect to CONNECT target", "target", req.Host, "error", err) - return - } - defer func() { _ = targetConn.Close() }() - - // Bidirectional copy - go func() { - _, err := io.Copy(targetConn, tlsConn) - if err != nil { - p.logger.Error("Error copying from client to target", "error", err) - } - }() - _, err = io.Copy(tlsConn, targetConn) - if err != nil { - p.logger.Error("Error copying from target to client", "error", err) - } - p.logger.Debug("CONNECT tunnel closed", "hostname", hostname) -} diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go deleted file mode 100644 index dae1ad6..0000000 --- a/proxy/proxy_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package proxy - -import "testing" - -// Stub test file - tests removed -func TestStub(t *testing.T) { - // This is a stub test - t.Skip("stub test file") -} diff --git a/rules/rules.go b/rules/rules.go deleted file mode 100644 index ab64cc4..0000000 --- a/rules/rules.go +++ /dev/null @@ -1,212 +0,0 @@ -package rules - -import ( - "fmt" - "log/slog" - "strings" -) - -type Evaluator interface { - Evaluate(method, url string) Result -} - -// Rule represents an allow rule with optional HTTP method restrictions -type Rule struct { - Pattern string // wildcard pattern for matching - Methods map[string]bool // nil means all methods allowed - Raw string // rule string for logging -} - -// ParseAllowSpecs parses a slice of --allow specs into allow Rules. -func ParseAllowSpecs(allowStrings []string) ([]Rule, error) { - var out []Rule - for _, s := range allowStrings { - r, err := newAllowRule(s) - if err != nil { - return nil, fmt.Errorf("failed to parse allow '%s': %v", s, err) - } - out = append(out, r) - } - return out, nil -} - -// Engine evaluates HTTP requests against a set of rules -type Engine struct { - rules []Rule - logger *slog.Logger -} - -// NewRuleEngine creates a new rule engine -func NewRuleEngine(rules []Rule, logger *slog.Logger) *Engine { - return &Engine{ - rules: rules, - logger: logger, - } -} - -// Result contains the result of rule evaluation -type Result struct { - Allowed bool - Rule string // The rule that matched (if any) -} - -// Evaluate evaluates a request and returns both result and matching rule -func (re *Engine) Evaluate(method, url string) Result { - // Check if any allow rule matches - for _, rule := range re.rules { - if re.matches(rule, method, url) { - return Result{ - Allowed: true, - Rule: rule.Raw, - } - } - } - - // Default deny if no allow rules match - return Result{ - Allowed: false, - Rule: "", - } -} - -// Matches checks if the rule matches the given method and URL using wildcard patterns -func (re *Engine) matches(r Rule, method, url string) bool { - // Check method if specified - if r.Methods != nil && !r.Methods[strings.ToUpper(method)] { - return false - } - - // Check URL pattern using wildcard matching - // Try exact match first - if wildcardMatch(r.Pattern, url) { - return true - } - - // If pattern doesn't start with protocol, try matching against the URL without protocol - if !strings.HasPrefix(r.Pattern, "http://") && !strings.HasPrefix(r.Pattern, "https://") { - // Extract domain and path from URL - urlWithoutProtocol := url - if strings.HasPrefix(url, "https://") { - urlWithoutProtocol = url[8:] // Remove "https://" - } else if strings.HasPrefix(url, "http://") { - urlWithoutProtocol = url[7:] // Remove "http://" - } - - // Try matching against URL without protocol - if wildcardMatch(r.Pattern, urlWithoutProtocol) { - return true - } - - // Also try matching just the domain part - domainEnd := strings.Index(urlWithoutProtocol, "/") - if domainEnd > 0 { - domain := urlWithoutProtocol[:domainEnd] - if wildcardMatch(r.Pattern, domain) { - return true - } - } else { - // No path, just domain - if wildcardMatch(r.Pattern, urlWithoutProtocol) { - return true - } - } - } - - return false -} - -// wildcardMatch performs wildcard pattern matching -// Supports * (matches any sequence of characters) -func wildcardMatch(pattern, text string) bool { - pattern = strings.ToLower(pattern) - text = strings.ToLower(text) - - // Handle simple case - if pattern == "*" { - return true - } - - // Split pattern by '*' and check each part exists in order - parts := strings.Split(pattern, "*") - - // If no wildcards, must be exact match - if len(parts) == 1 { - return pattern == text - } - - textPos := 0 - for i, part := range parts { - if part == "" { - continue // Skip empty parts from consecutive '*' - } - - if i == 0 { - // First part must be at the beginning - if !strings.HasPrefix(text, part) { - return false - } - textPos = len(part) - } else if i == len(parts)-1 { - // Last part must be at the end - if !strings.HasSuffix(text[textPos:], part) { - return false - } - } else { - // Middle parts must exist in order - idx := strings.Index(text[textPos:], part) - if idx == -1 { - return false - } - textPos += idx + len(part) - } - } - - return true -} - -// newAllowRule creates an allow Rule from a spec string used by --allow. -// Supported formats: -// -// "pattern" -> allow all methods to pattern -// "GET,HEAD pattern" -> allow only listed methods to pattern -func newAllowRule(spec string) (Rule, error) { - s := strings.TrimSpace(spec) - if s == "" { - return Rule{}, fmt.Errorf("invalid allow spec: empty") - } - - var methods map[string]bool - pattern := s - - // Detect optional leading methods list separated by commas and a space before pattern - // e.g., "GET,HEAD github.com" - if idx := strings.IndexFunc(s, func(r rune) bool { return r == ' ' || r == '\t' }); idx > 0 { - left := strings.TrimSpace(s[:idx]) - right := strings.TrimSpace(s[idx:]) - // methods part is valid if it only contains letters and commas - valid := left != "" && strings.IndexFunc(left, func(r rune) bool { - return r != ',' && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') - }) == -1 - if valid { - methods = make(map[string]bool) - for _, m := range strings.Split(left, ",") { - m = strings.TrimSpace(m) - if m == "" { - continue - } - methods[strings.ToUpper(m)] = true - } - pattern = right - } - } - - if pattern == "" { - return Rule{}, fmt.Errorf("invalid allow spec: missing pattern") - } - - return Rule{ - Pattern: pattern, - Methods: methods, - Raw: "allow " + spec, - }, nil -} diff --git a/rules/rules_test.go b/rules/rules_test.go deleted file mode 100644 index eb702fe..0000000 --- a/rules/rules_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package rules - -import "testing" - -// Stub test file - tests removed -func TestStub(t *testing.T) { - // This is a stub test - t.Skip("stub test file") -} diff --git a/tls/tls.go b/tls/tls.go deleted file mode 100644 index d3da893..0000000 --- a/tls/tls.go +++ /dev/null @@ -1,355 +0,0 @@ -package tls - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "fmt" - "log/slog" - "math/big" - "net" - "os" - "path/filepath" - "sync" - "time" -) - -type Manager interface { - SetupTLSAndWriteCACert() (*tls.Config, string, string, error) -} - -type Config struct { - Logger *slog.Logger - ConfigDir string - Uid int - Gid int -} - -// CertificateManager manages TLS certificates for the proxy -type CertificateManager struct { - caKey *rsa.PrivateKey - caCert *x509.Certificate - certCache map[string]*tls.Certificate - mutex sync.RWMutex - logger *slog.Logger - configDir string - uid int - gid int -} - -// NewCertificateManager creates a new certificate manager -func NewCertificateManager(config Config) (*CertificateManager, error) { - cm := &CertificateManager{ - certCache: make(map[string]*tls.Certificate), - logger: config.Logger, - configDir: config.ConfigDir, - uid: config.Uid, - gid: config.Gid, - } - - // Load or generate CA certificate - err := cm.loadOrGenerateCA() - if err != nil { - return nil, fmt.Errorf("failed to load or generate CA: %v", err) - } - - return cm, 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 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(cm.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, cm.configDir, nil -} - -// loadOrGenerateCA loads existing CA or generates a new one -func (cm *CertificateManager) loadOrGenerateCA() error { - caKeyPath := filepath.Join(cm.configDir, "ca-key.pem") - caCertPath := filepath.Join(cm.configDir, "ca-cert.pem") - - // Try to load existing CA - if cm.loadExistingCA(caKeyPath, caCertPath) { - cm.logger.Debug("Loaded existing CA certificate") - return nil - } - - // Generate new CA - cm.logger.Info("Generating new CA certificate") - return cm.generateCA(caKeyPath, caCertPath) -} - -// getTLSConfig returns a TLS config that generates certificates on-demand -func (cm *CertificateManager) getTLSConfig() *tls.Config { - return &tls.Config{ - GetCertificate: cm.getCertificate, - MinVersion: tls.VersionTLS12, - } -} - -// getCACertPEM returns the CA certificate in PEM format -func (cm *CertificateManager) getCACertPEM() ([]byte, error) { - return pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: cm.caCert.Raw, - }), nil -} - -// loadExistingCA attempts to load existing CA files -func (cm *CertificateManager) loadExistingCA(keyPath, certPath string) bool { - // Check if files exist - if _, err := os.Stat(keyPath); os.IsNotExist(err) { - return false - } - if _, err := os.Stat(certPath); os.IsNotExist(err) { - return false - } - - // Load private key - keyData, err := os.ReadFile(keyPath) - if err != nil { - cm.logger.Warn("Failed to read CA key", "error", err) - return false - } - - keyBlock, _ := pem.Decode(keyData) - if keyBlock == nil { - cm.logger.Warn("Failed to decode CA key PEM") - return false - } - - privateKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) - if err != nil { - cm.logger.Warn("Failed to parse CA private key", "error", err) - return false - } - - // Load certificate - certData, err := os.ReadFile(certPath) - if err != nil { - cm.logger.Warn("Failed to read CA cert", "error", err) - return false - } - - certBlock, _ := pem.Decode(certData) - if certBlock == nil { - cm.logger.Warn("Failed to decode CA cert PEM") - return false - } - - cert, err := x509.ParseCertificate(certBlock.Bytes) - if err != nil { - cm.logger.Warn("Failed to parse CA certificate", "error", err) - return false - } - - // Check if certificate is still valid - if time.Now().After(cert.NotAfter) { - cm.logger.Warn("CA certificate has expired") - return false - } - - cm.caKey = privateKey - cm.caCert = cert - return true -} - -// generateCA generates a new CA certificate and key -func (cm *CertificateManager) generateCA(keyPath, certPath string) error { - // Create config directory if it doesn't exist - err := os.MkdirAll(cm.configDir, 0700) - if err != nil { - return fmt.Errorf("failed to create config directory at %s: %v", cm.configDir, err) - } - - // ensure the directory is owned by the original user - err = os.Chown(cm.configDir, cm.uid, cm.gid) - if err != nil { - cm.logger.Warn("Failed to change config directory ownership", "error", err) - } - - // Generate private key - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return fmt.Errorf("failed to generate private key: %v", err) - } - - // Create certificate template - template := x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - Organization: []string{"coder"}, - Country: []string{"US"}, - Province: []string{""}, - Locality: []string{""}, - StreetAddress: []string{""}, - PostalCode: []string{""}, - CommonName: "coder CA", - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - IsCA: true, - } - - // Create certificate - certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) - if err != nil { - return fmt.Errorf("failed to create certificate: %v", err) - } - - // Parse certificate - cert, err := x509.ParseCertificate(certDER) - if err != nil { - return fmt.Errorf("failed to parse certificate: %v", err) - } - - // Save private key - keyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return fmt.Errorf("failed to create key file: %v", err) - } - defer func() { - err := keyFile.Close() - if err != nil { - cm.logger.Error("Failed to close key file", "error", err) - } - }() - - err = pem.Encode(keyFile, &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(privateKey), - }) - if err != nil { - return fmt.Errorf("failed to write key to file: %v", err) - } - - // Save certificate - certFile, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) - if err != nil { - return fmt.Errorf("failed to create cert file: %v", err) - } - defer func() { - err := certFile.Close() - if err != nil { - cm.logger.Error("Failed to close cert file", "error", err) - } - }() - - err = pem.Encode(certFile, &pem.Block{ - Type: "CERTIFICATE", - Bytes: certDER, - }) - if err != nil { - return fmt.Errorf("failed to write cert to file: %v", err) - } - - cm.caKey = privateKey - cm.caCert = cert - - return nil -} - -// getCertificate generates or retrieves a certificate for the given hostname -func (cm *CertificateManager) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - hostname := hello.ServerName - if hostname == "" { - return nil, fmt.Errorf("no server name provided") - } - - // Check cache first - cm.mutex.RLock() - if cert, exists := cm.certCache[hostname]; exists { - cm.mutex.RUnlock() - return cert, nil - } - cm.mutex.RUnlock() - - // Generate new certificate - cm.mutex.Lock() - defer cm.mutex.Unlock() - - // Double-check cache (another goroutine might have generated it) - if cert, exists := cm.certCache[hostname]; exists { - return cert, nil - } - - cert, err := cm.generateServerCertificate(hostname) - if err != nil { - return nil, fmt.Errorf("failed to generate certificate for %s: %v", hostname, err) - } - - cm.certCache[hostname] = cert - cm.logger.Debug("Generated certificate", "hostname", hostname) - - return cert, nil -} - -// generateServerCertificate generates a server certificate for the given hostname -func (cm *CertificateManager) generateServerCertificate(hostname string) (*tls.Certificate, error) { - // Generate private key - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, fmt.Errorf("failed to generate private key: %v", err) - } - - // Create certificate template - template := x509.Certificate{ - SerialNumber: big.NewInt(time.Now().UnixNano()), - Subject: pkix.Name{ - Organization: []string{"coder"}, - Country: []string{"US"}, - Province: []string{""}, - Locality: []string{""}, - StreetAddress: []string{""}, - PostalCode: []string{""}, - CommonName: hostname, - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(24 * time.Hour), // 1 day - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - DNSNames: []string{hostname}, - } - - // Add IP address if hostname is an IP - if ip := net.ParseIP(hostname); ip != nil { - template.IPAddresses = []net.IP{ip} - } - - // Create certificate - certDER, err := x509.CreateCertificate(rand.Reader, &template, cm.caCert, &privateKey.PublicKey, cm.caKey) - if err != nil { - return nil, fmt.Errorf("failed to create certificate: %v", err) - } - - // Create TLS certificate - tlsCert := &tls.Certificate{ - Certificate: [][]byte{certDER}, - PrivateKey: privateKey, - } - - cm.logger.Debug("Generated certificate", "hostname", hostname) - - return tlsCert, nil -} diff --git a/tls/tls_test.go b/tls/tls_test.go deleted file mode 100644 index 73a7c38..0000000 --- a/tls/tls_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package tls - -import "testing" - -// Stub test file - tests removed -func TestStub(t *testing.T) { - // This is a stub test - t.Skip("stub test file") -}