diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f97cb9..a859e6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,41 @@ on: branches: [ main ] jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + check-latest: true + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download and verify dependencies + run: make deps + + - name: Install golangci-lint + run: | + # binary will be $(go env GOPATH)/bin/golangci-lint + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.4.0 + golangci-lint --version + + - name: Run linting + run: make lint + test: name: Test strategy: diff --git a/boundary b/boundary deleted file mode 100755 index ca624d5..0000000 Binary files a/boundary and /dev/null differ diff --git a/cmd/boundary/main.go b/cmd/boundary/main.go index 1544b38..86eefa3 100644 --- a/cmd/boundary/main.go +++ b/cmd/boundary/main.go @@ -9,6 +9,7 @@ import ( // Version information injected at build time var ( + //nolint:unused version = "dev" // Set via -ldflags "-X main.version=v1.0.0" ) diff --git a/jail/linux.go b/jail/linux.go index 8480b20..085e69f 100644 --- a/jail/linux.go +++ b/jail/linux.go @@ -197,7 +197,7 @@ options timeout:2 attempts:2 func (l *LinuxJail) setupIptables() error { // Enable IP forwarding cmd := exec.Command("sysctl", "-w", "net.ipv4.ip_forward=1") - cmd.Run() // Ignore error + _ = 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") @@ -222,11 +222,19 @@ func (l *LinuxJail) setupIptables() error { 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)) - cmd.Run() // Ignore errors during cleanup + 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") - cmd.Run() // Ignore errors during cleanup + 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 } @@ -262,4 +270,4 @@ func (l *LinuxJail) removeNamespace() error { return fmt.Errorf("failed to remove namespace: %v", err) } return nil -} \ No newline at end of file +} diff --git a/jail/macos.go b/jail/macos.go index 67d7335..f10c273 100644 --- a/jail/macos.go +++ b/jail/macos.go @@ -266,13 +266,12 @@ func (n *MacOSJail) setupPFRules() error { cmd := exec.Command("pfctl", "-a", pfAnchorName, "-f", n.pfRulesPath) err = cmd.Run() if err != nil { - n.logger.Error("Failed to load PF rules", "error", err, "rules_file", n.pfRulesPath) 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 + _ = 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 @@ -318,7 +317,10 @@ anchor "%s" func (n *MacOSJail) removePFRules() error { // Flush the anchor cmd := exec.Command("pfctl", "-a", pfAnchorName, "-F", "all") - cmd.Run() // Ignore errors during cleanup + err := cmd.Run() + if err != nil { + return fmt.Errorf("failed to flush PF anchor: %v", err) + } return nil } @@ -326,9 +328,15 @@ func (n *MacOSJail) removePFRules() error { // cleanupTempFiles removes temporary rule files func (n *MacOSJail) cleanupTempFiles() { if n.pfRulesPath != "" { - os.Remove(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 != "" { - os.Remove(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) + } } -} \ No newline at end of file +} diff --git a/proxy/proxy.go b/proxy/proxy.go index cf02621..6328a38 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -71,7 +71,10 @@ func (p *Server) Start(ctx context.Context) error { if err != nil { select { case <-ctx.Done(): - listener.Close() + 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) @@ -194,7 +197,7 @@ func (p *Server) forwardRequest(w http.ResponseWriter, r *http.Request, https bo http.Error(w, fmt.Sprintf("Failed to make request: %v", err), http.StatusBadGateway) return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() p.logger.Debug("Received response", "status", resp.StatusCode, "target", targetURL.String()) @@ -238,7 +241,7 @@ func (p *Server) writeBlockedResponse(w http.ResponseWriter, r *http.Request) { host = r.Host } - fmt.Fprintf(w, `🚫 Request Blocked by Boundary + _, _ = fmt.Fprintf(w, `🚫 Request Blocked by Boundary Request: %s %s Host: %s @@ -290,7 +293,12 @@ func (p *Server) handleConnect(w http.ResponseWriter, r *http.Request) { p.logger.Error("Failed to hijack connection", "error", err) return } - defer conn.Close() + 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")) @@ -416,7 +424,12 @@ func (p *Server) handleDecryptedHTTPS(w http.ResponseWriter, r *http.Request) { // handleConnectionWithTLSDetection detects TLS vs HTTP and handles appropriately func (p *Server) handleConnectionWithTLSDetection(conn net.Conn) { - defer conn.Close() + 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) @@ -442,7 +455,12 @@ func (p *Server) handleConnectionWithTLSDetection(conn net.Conn) { p.logger.Debug("TLS handshake successful") // Use HTTP server with TLS connection listener := newSingleConnectionListener(tlsConn) - defer listener.Close() + 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 { @@ -450,7 +468,12 @@ func (p *Server) handleConnectionWithTLSDetection(conn net.Conn) { // Use HTTP server with regular connection p.logger.Debug("About to call http.Serve for HTTP connection") listener := newSingleConnectionListener(connWrapper) - defer listener.Close() + 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) } @@ -519,7 +542,10 @@ func (sl *singleConnectionListener) Close() error { } if sl.conn != nil { - sl.conn.Close() + err := sl.conn.Close() + if err != nil { + return fmt.Errorf("failed to close connection: %w", err) + } sl.conn = nil } return nil @@ -613,9 +639,9 @@ func (p *Server) constructFullURL(req *http.Request, hostname string) 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", + 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)) + _, _ = tlsConn.Write([]byte(response)) } // streamRequestToTarget streams the HTTP request (including body) to the target server @@ -625,60 +651,94 @@ func (p *Server) streamRequestToTarget(clientConn *tls.Conn, bufReader *bufio.Re if err != nil { return fmt.Errorf("failed to connect to target %s: %v", hostname, err) } - defer targetConn.Close() + 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) - targetConn.Write([]byte(reqLine)) + _, 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) - targetConn.Write([]byte(headerLine)) + _, err = targetConn.Write([]byte(headerLine)) + if err != nil { + return fmt.Errorf("failed to write header to target: %v", err) + } } } - targetConn.Write([]byte("\r\n")) // End of headers + _, 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 - io.Copy(targetConn, bufReader) + _, err := io.Copy(targetConn, bufReader) + if err != nil { + p.logger.Error("Error copying request body to target", "error", err) + } }() // Stream response: target -> client - io.Copy(clientConn, targetConn) + _, 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" - tlsConn.Write([]byte(response)) - + _, 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 targetConn.Close() - + defer func() { _ = targetConn.Close() }() + // Bidirectional copy - go io.Copy(targetConn, tlsConn) - io.Copy(tlsConn, targetConn) -} \ No newline at end of file + 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/rules/rules.go b/rules/rules.go index 2e51e57..ab64cc4 100644 --- a/rules/rules.go +++ b/rules/rules.go @@ -185,7 +185,7 @@ func newAllowRule(spec string) (Rule, error) { 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')) + return r != ',' && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') }) == -1 if valid { methods = make(map[string]bool) diff --git a/tls/tls.go b/tls/tls.go index 7bcbca5..d3da893 100644 --- a/tls/tls.go +++ b/tls/tls.go @@ -229,24 +229,40 @@ func (cm *CertificateManager) generateCA(keyPath, certPath string) error { if err != nil { return fmt.Errorf("failed to create key file: %v", err) } - defer keyFile.Close() - - pem.Encode(keyFile, &pem.Block{ + 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 certFile.Close() + defer func() { + err := certFile.Close() + if err != nil { + cm.logger.Error("Failed to close cert file", "error", err) + } + }() - pem.Encode(certFile, &pem.Block{ + 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