Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Binary file removed boundary
Binary file not shown.
1 change: 1 addition & 0 deletions cmd/boundary/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
16 changes: 12 additions & 4 deletions jail/linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}
Expand Down Expand Up @@ -262,4 +270,4 @@ func (l *LinuxJail) removeNamespace() error {
return fmt.Errorf("failed to remove namespace: %v", err)
}
return nil
}
}
20 changes: 14 additions & 6 deletions jail/macos.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -318,17 +317,26 @@ 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
}

// 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)
}
}
}
}
114 changes: 87 additions & 27 deletions proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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)
Expand All @@ -442,15 +455,25 @@ 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 {
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 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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
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)
}
2 changes: 1 addition & 1 deletion rules/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading