diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f83a2fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +jail +jail.exe + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp + +# Log files +*.log + +# Certificate files (generated at runtime) +*.pem +*.crt +*.key diff --git a/README.md b/README.md new file mode 100644 index 0000000..88c32a4 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# jail + +**Network isolation tool for monitoring and restricting HTTP/HTTPS requests from processes** + +jail creates an isolated network environment for target processes, intercepting all HTTP/HTTPS traffic through a transparent proxy that enforces user-defined allow rules. + +## Features + +- 🔒 **Process-level network isolation** - Linux namespaces, macOS process groups +- 🌐 **HTTP/HTTPS interception** - Transparent proxy with TLS certificate injection +- 🎯 **Wildcard pattern matching** - Simple `*` wildcards for URL patterns +- 📝 **Request logging** - Monitor and log all HTTP/HTTPS requests +- 🖥️ **Cross-platform** - Native support for Linux and macOS +- ⚡ **Zero configuration** - Works out of the box with sensible defaults +- 🛡️ **Default deny-all** - Secure by default, only allow what you explicitly permit + +## Quick Start + +```bash +# Build the tool +go build -o jail . + +# Allow only requests to github.com +./jail --allow "github.com" -- curl https://github.com + +# Allow full access to GitHub issues API, but only GET/HEAD elsewhere on GitHub +./jail \ + --allow "github.com/api/issues/*" \ + --allow "GET,HEAD github.com" \ + -- npm install + +# Default deny-all: everything is blocked unless explicitly allowed +./jail -- curl https://example.com +``` + +## Allow Rules + +jail uses simple wildcard patterns for URL matching. + +### Rule Format + +```text +--allow "pattern" +--allow "METHOD[,METHOD] pattern" +``` + +- If only a pattern is provided, all HTTP methods are allowed +- If methods are provided, only those HTTP methods are allowed (case-insensitive) +- Patterns use wildcards: `*` (matches any characters) + +### Examples + +```bash +# Basic patterns +jail --allow "github.com" -- git pull + +# Wildcard patterns +jail --allow "*.github.com" -- npm install # GitHub subdomains +jail --allow "api.*" -- ./app # Any API domain + +# Method-specific rules +jail --allow "GET,HEAD api.github.com" -- curl https://api.github.com +``` + +**Default Policy:** All traffic is denied unless explicitly allowed. + +## Logging + +```bash +# Monitor all requests with info logging +jail --log-level info --allow "*" -- npm install + +# Debug logging for troubleshooting +jail --log-level debug --allow "github.com" -- git pull + +# Error-only logging +jail --log-level error --allow "*" -- ./app +``` + +**Log Levels:** +- `error`: Shows only errors +- `warn`: Shows blocked requests and errors (default) +- `info`: Shows all requests (allowed and blocked) +- `debug`: Shows detailed information including TLS operations + +## Blocked Request Messages + +When a request is blocked, jail provides helpful guidance: + +``` +🚫 Request Blocked by Jail + +Request: GET / +Host: google.com +Reason: No matching allow rules (default deny-all policy) + +To allow this request, restart jail with: + --allow "google.com" # Allow all methods to this host + --allow "GET google.com" # Allow only GET requests to this host + +For more help: https://github.com/coder/jail +``` + +## Platform Support + +| Platform | Implementation | Sudo Required | +|----------|----------------|---------------| +| Linux | Network namespaces + iptables | Yes | +| macOS | Process groups + PF rules | Yes | +| Windows | Not supported | - | + +## Installation + +### Prerequisites + +**Linux:** +- Linux kernel 3.8+ (network namespace support) +- iptables +- Go 1.21+ (for building) +- sudo access + +**macOS:** +- macOS 10.15+ (Catalina or later) +- pfctl (included) +- Go 1.21+ (for building) +- sudo access + +### Build from Source + +```bash +git clone https://github.com/coder/jail +cd jail +go build -o jail . +``` + +## TLS Interception + +jail automatically generates a Certificate Authority (CA) to intercept HTTPS traffic: + +- CA stored in `~/.config/jail/` (or `$XDG_CONFIG_HOME/jail/`) +- CA certificate provided via `JAIL_CA_CERT` environment variable +- Certificates generated on-demand for intercepted domains +- CA expires after 1 year + +### Disable TLS Interception + +```bash +jail --no-tls-intercept --allow "*" -- ./app +``` + +## Command-Line Options + +```text +jail [flags] -- command [args...] + +OPTIONS: + --allow Allow rule (repeatable) + Format: "pattern" or "METHOD[,METHOD] pattern" + --log-level Set log level (error, warn, info, debug) + --no-tls-intercept Disable HTTPS interception + -h, --help Print help +``` + +## Development + +```bash +# Build +go build -o jail . + +# Test +go test ./... + +# Cross-compile +GOOS=linux GOARCH=amd64 go build -o jail-linux . +GOOS=darwin GOARCH=amd64 go build -o jail-macos . +``` + +## License + +MIT License - see LICENSE file for details. \ No newline at end of file diff --git a/go.mod b/go.mod index ba19d1d..1e0f109 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,31 @@ -module github.com/coder/squeeze +module github.com/coder/jail -go 1.25.0 +go 1.25 -require golang.org/x/sys v0.35.0 // indirect +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 5ace7b9..d751167 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,146 @@ -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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/isolation-flow.puml b/isolation-flow.puml deleted file mode 100644 index 25d7f23..0000000 --- a/isolation-flow.puml +++ /dev/null @@ -1,45 +0,0 @@ -@startuml Isolation Flow -participant "User" as U -participant "squeeze (parent)" as P -participant "squeeze (child)" as C -participant "target command" as T - -U -> P: squeeze -- command args -activate P - -note over P: Parse config, setup proxy - -P -> P: fork() -activate C - -note over C: Child process starts\nin parent's namespaces - -C -> C: unshare(CLONE_NEWUSER) -note over C: Create user namespace\n(enables root inside) - -C -> C: unshare(CLONE_NEWNS) -note over C: Create mount namespace\n(isolate filesystem view) - -C -> C: unshare(CLONE_NEWNET) -note over C: Create network namespace\n(isolate network) - -note over C: Setup mount binds\nfor allowed paths - -note over C: Setup network interfaces\nand iptables rules - -C -> T: exec(target command) -deactivate C -activate T - -note over T: Target runs in\nisolated namespaces - -P -> P: wait for child -note over P: Parent monitors child\nand manages proxy - -T -> C: exit -deactivate T -C -> P: child exit status -P -> U: return exit status -deactivate P - -@enduml \ No newline at end of file diff --git a/main.go b/main.go index b9d6b1b..e07736b 100644 --- a/main.go +++ b/main.go @@ -1,66 +1,276 @@ package main import ( - "flag" + "context" + cryptotls "crypto/tls" "fmt" + "log/slog" "os" + "os/signal" + "path/filepath" "strings" + "syscall" + "time" - "github.com/coder/squeeze/squeeze" + "github.com/coder/jail/netjail" + "github.com/coder/jail/proxy" + "github.com/coder/jail/rules" + "github.com/coder/jail/tls" + "github.com/coder/serpent" ) -// runChildProcess handles the child process execution in isolated namespaces -func runChildProcess() { - // TODO: We need to pass config data from parent to child - // For now, just create namespaces and exit to test - - if err := squeeze.CreateNamespaces(); err != nil { - fmt.Fprintf(os.Stderr, "Child: failed to create namespaces: %v\n", err) +var ( + allowStrings []string + noTLSIntercept bool + logLevel string + noJailCleanup bool +) + +func main() { + cmd := &serpent.Command{ + Use: "jail [flags] -- command [args...]", + Short: "Monitor and restrict HTTP/HTTPS requests from processes", + Long: `jail creates an isolated network environment for the target process, +intercepting all HTTP/HTTPS traffic through a transparent proxy that enforces +user-defined rules. + +Examples: + # Allow only requests to github.com + jail --allow "github.com" -- curl https://github.com + + # Monitor all requests to specific domains (allow only those) + jail --allow "github.com/api/issues/*" --allow "GET,HEAD github.com" -- npm install + + # Block everything by default (implicit)`, + Options: serpent.OptionSet{ + { + Name: "allow", + Flag: "allow", + Env: "JAIL_ALLOW", + Description: "Allow rule (can be specified multiple times). Format: 'pattern' or 'METHOD[,METHOD] pattern'.", + Value: serpent.StringArrayOf(&allowStrings), + }, + { + Name: "no-tls-intercept", + Flag: "no-tls-intercept", + Env: "JAIL_NO_TLS_INTERCEPT", + Description: "Disable HTTPS interception.", + Value: serpent.BoolOf(&noTLSIntercept), + }, + { + Name: "log-level", + Flag: "log-level", + Env: "JAIL_LOG_LEVEL", + Description: "Set log level (error, warn, info, debug).", + Default: "warn", + Value: serpent.StringOf(&logLevel), + }, + { + Name: "no-jail-cleanup", + Flag: "no-jail-cleanup", + Env: "JAIL_NO_JAIL_CLEANUP", + Description: "Skip jail cleanup (hidden flag for testing).", + Value: serpent.BoolOf(&noJailCleanup), + Hidden: true, + }, + }, + Handler: runJail, + } + + err := cmd.Invoke().WithOS().Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - - fmt.Printf("Child: successfully created namespaces\n") - os.Exit(0) } -func main() { - // Check if we're running as the child process for namespace setup - if len(os.Args) > 1 && os.Args[1] == "squeeze-child" { - runChildProcess() - return +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 } - var configFile = flag.String("config", "", "path to configuration file") - - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: %s [options] -- command [args...]\n", os.Args[0]) - fmt.Fprintf(os.Stderr, "\nOptions:\n") - flag.PrintDefaults() + // Create a standard slog logger with the appropriate level + handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + }) + + return slog.New(handler) +} + +func runJail(inv *serpent.Invocation) error { + logger := setupLogging(logLevel) + + // Get command arguments + args := inv.Args + if len(args) == 0 { + return fmt.Errorf("no command specified") } - - flag.Parse() - command := flag.Args() - if len(command) < 1 { - fmt.Fprintf(os.Stderr, "Error: no command specified after --\n") - flag.Usage() - os.Exit(1) + // Parse allow list; default to deny-all if none provided + if len(allowStrings) == 0 { + logger.Warn("No allow rules specified; all network traffic will be denied by default") } - - // Create basic config for testing (config file loading not implemented yet) - config := squeeze.NewConfig( - squeeze.WithCommand(command[0], command[1:]...), - squeeze.WithWorkingDir("."), - ) - - if *configFile != "" { - fmt.Printf("Config file specified: %s (not implemented yet)\n", *configFile) + + allowRules, err := rules.ParseAllowSpecs(allowStrings) + if err != nil { + logger.Error("Failed to parse allow rules", "error", err) + return fmt.Errorf("failed to parse allow rules: %v", err) } - - fmt.Printf("Running isolated: %s\n", strings.Join(command, " ")) - - if err := config.RunIsolated(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + // Implicit final deny-all is handled by the RuleEngine default behavior when no rules match. + // Build final rules slice in order: user allows only. + ruleList := allowRules + + // Create rule engine + ruleEngine := rules.NewRuleEngine(ruleList, logger) + + // Get configuration directory + configDir, err := tls.GetConfigDir() + if err != nil { + logger.Error("Failed to get config directory", "error", err) + return fmt.Errorf("failed to get config directory: %v", err) + } + + // Create certificate manager (if TLS interception is enabled) + var certManager *tls.CertificateManager + var tlsConfig *cryptotls.Config + var extraEnv map[string]string = make(map[string]string) + + if !noTLSIntercept { + certManager, err = tls.NewCertificateManager(configDir, logger) + if err != nil { + logger.Error("Failed to create certificate manager", "error", err) + return fmt.Errorf("failed to create certificate manager: %v", err) + } + + tlsConfig = certManager.GetTLSConfig() + + // Get CA certificate for environment + caCertPEM, err := certManager.GetCACertPEM() + if err != nil { + logger.Error("Failed to get CA certificate", "error", err) + return fmt.Errorf("failed to get CA certificate: %v", err) + } + + // Write CA certificate to a temporary file for tools that need a file path + caCertPath := filepath.Join(configDir, "ca-cert.pem") + if err := os.WriteFile(caCertPath, caCertPEM, 0644); err != nil { + logger.Error("Failed to write CA certificate file", "error", err) + return fmt.Errorf("failed to write CA certificate file: %v", err) + } + + // Set standard CA certificate environment variables for common tools + // This makes tools like curl, git, etc. trust our dynamically generated CA + extraEnv["SSL_CERT_FILE"] = caCertPath // OpenSSL/LibreSSL-based tools + extraEnv["SSL_CERT_DIR"] = configDir // OpenSSL certificate directory + extraEnv["CURL_CA_BUNDLE"] = caCertPath // curl + extraEnv["GIT_SSL_CAINFO"] = caCertPath // Git + extraEnv["REQUESTS_CA_BUNDLE"] = caCertPath // Python requests + extraEnv["NODE_EXTRA_CA_CERTS"] = caCertPath // Node.js + extraEnv["JAIL_CA_CERT"] = string(caCertPEM) // Keep for backward compatibility + } + + // Create network jail configuration + netjailConfig := netjail.Config{ + HTTPPort: 8040, + HTTPSPort: 8043, + NetJailName: "jail", + SkipCleanup: noJailCleanup, + } + + // Create network jail + netjailInstance, err := netjail.NewNetJail(netjailConfig, logger) + if err != nil { + logger.Error("Failed to create network jail", "error", err) + return fmt.Errorf("failed to create network jail: %v", err) + } + + // Setup signal handling BEFORE any network setup + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Handle signals immediately in background + go func() { + sig := <-sigChan + logger.Info("Received signal during setup, cleaning up...", "signal", sig) + if err := netjailInstance.Cleanup(); err != nil { + logger.Error("Emergency cleanup failed", "error", err) + } os.Exit(1) + }() + + // Ensure cleanup happens no matter what + defer func() { + logger.Debug("Starting cleanup process") + if err := netjailInstance.Cleanup(); err != nil { + logger.Error("Failed to cleanup network jail", "error", err) + } else { + logger.Debug("Cleanup completed successfully") + } + }() + + // Setup network jail + if err := netjailInstance.Setup(netjailConfig.HTTPPort, netjailConfig.HTTPSPort); err != nil { + logger.Error("Failed to setup network jail", "error", err) + return fmt.Errorf("failed to setup network jail: %v", err) + } + + // Create proxy server + proxyConfig := proxy.Config{ + HTTPPort: netjailConfig.HTTPPort, + HTTPSPort: netjailConfig.HTTPSPort, + RuleEngine: ruleEngine, + Logger: logger, + TLSConfig: tlsConfig, + } + + proxyServer := proxy.NewProxyServer(proxyConfig) + + // Create context for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start proxy server in background + go func() { + if err := proxyServer.Start(ctx); err != nil { + logger.Error("Proxy server error", "error", err) + } + }() + + // Give proxy time to start + time.Sleep(100 * time.Millisecond) + + // Execute command in network jail + go func() { + defer cancel() + if err := netjailInstance.Execute(args, extraEnv); 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 + } + + // Stop proxy server + if err := proxyServer.Stop(); err != nil { + logger.Error("Failed to stop proxy server", "error", err) } -} \ No newline at end of file + + return nil +} diff --git a/netjail/linux.go b/netjail/linux.go new file mode 100644 index 0000000..0371c5d --- /dev/null +++ b/netjail/linux.go @@ -0,0 +1,291 @@ +//go:build linux + +package netjail + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + "syscall" + "time" +) + +// LinuxNetJail implements NetJail using Linux network namespaces +type LinuxNetJail struct { + config Config + namespace string + logger *slog.Logger +} + +// newLinuxNetJail creates a new Linux network jail instance +func newLinuxNetJail(config Config, logger *slog.Logger) (*LinuxNetJail, error) { + // Generate unique namespace name + namespace := fmt.Sprintf("boundary_%d", time.Now().UnixNano()%10000000) + + return &LinuxNetJail{ + config: config, + namespace: namespace, + logger: logger, + }, nil +} + +// Setup creates network namespace and configures iptables rules +func (l *LinuxNetJail) Setup(httpPort, httpsPort int) error { + l.logger.Debug("Setup called", "httpPort", httpPort, "httpsPort", httpsPort) + l.config.HTTPPort = httpPort + l.config.HTTPSPort = httpsPort + + // Setup DNS configuration BEFORE creating namespace + // This ensures the namespace-specific resolv.conf is available when namespace is created + l.logger.Debug("Setting up DNS configuration") + if err := l.setupDNS(); err != nil { + return fmt.Errorf("failed to setup DNS: %v", err) + } + l.logger.Debug("DNS setup completed") + + // Create network namespace + l.logger.Debug("Creating network namespace", "namespace", l.namespace) + if err := l.createNamespace(); err != nil { + return fmt.Errorf("failed to create namespace: %v", err) + } + l.logger.Debug("Network namespace created") + + // Setup network interface in namespace + l.logger.Debug("Setting up networking") + if err := l.setupNetworking(); err != nil { + return fmt.Errorf("failed to setup networking: %v", err) + } + l.logger.Debug("Networking setup completed") + + // Setup iptables rules + l.logger.Debug("Setting up iptables rules") + if err := l.setupIptables(); err != nil { + return fmt.Errorf("failed to setup iptables: %v", err) + } + l.logger.Debug("Iptables setup completed") + + l.logger.Debug("Setup completed successfully") + return nil +} + +// Execute runs a command within the network namespace +func (l *LinuxNetJail) Execute(command []string, extraEnv map[string]string) error { + l.logger.Debug("Execute called", "command", command) + if len(command) == 0 { + return fmt.Errorf("no command specified") + } + + // Create command with ip netns exec + l.logger.Debug("Creating command with namespace", "namespace", l.namespace) + cmdArgs := []string{"ip", "netns", "exec", l.namespace} + cmdArgs = append(cmdArgs, command...) + l.logger.Debug("Full command args", "args", cmdArgs) + + cmd := exec.Command("ip", cmdArgs[1:]...) + + // Set up environment + l.logger.Debug("Setting up environment") + env := os.Environ() + + // Add extra environment variables (including CA cert if provided) + for key, value := range extraEnv { + env = append(env, fmt.Sprintf("%s=%s", key, value)) + } + + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Start command + l.logger.Debug("Starting command", "path", cmd.Path, "args", cmd.Args) + err := cmd.Start() + if err != nil { + return fmt.Errorf("failed to start command: %v", err) + } + l.logger.Debug("Command started, waiting for completion") + + // Wait for command to complete + err = cmd.Wait() + l.logger.Debug("Command completed", "error", err) + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + if status, ok := exitError.Sys().(syscall.WaitStatus); ok { + l.logger.Debug("Command exit status", "status", status.ExitStatus()) + os.Exit(status.ExitStatus()) + } + } + return fmt.Errorf("command failed: %v", err) + } + + l.logger.Debug("Command executed successfully") + return nil +} + +// Cleanup removes the network namespace and iptables rules +func (l *LinuxNetJail) Cleanup() error { + if l.config.SkipCleanup { + return nil + } + + // Remove iptables rules + if err := l.removeIptables(); err != nil { + return fmt.Errorf("failed to remove iptables rules: %v", err) + } + + // Clean up namespace-specific DNS config directory + netnsEtc := fmt.Sprintf("/etc/netns/%s", l.namespace) + if _, err := os.Stat(netnsEtc); err == nil { + if err := os.RemoveAll(netnsEtc); err != nil { + // Don't fail cleanup for this, just log + fmt.Printf("Warning: failed to remove DNS config directory %s: %v\n", netnsEtc, err) + } + } + + // Remove network namespace + if err := l.removeNamespace(); err != nil { + return fmt.Errorf("failed to remove namespace: %v", err) + } + + return nil +} + +// createNamespace creates a new network namespace +func (l *LinuxNetJail) createNamespace() error { + cmd := exec.Command("ip", "netns", "add", l.namespace) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create namespace: %v", err) + } + return nil +} + +// setupNetworking configures networking within the namespace +func (l *LinuxNetJail) 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 + + cmd := exec.Command("ip", "link", "add", vethHost, "type", "veth", "peer", "name", vethNetJail) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create veth pair: %v", err) + } + + // Move netjail end to namespace + cmd = exec.Command("ip", "link", "set", vethNetJail, "netns", l.namespace) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to move veth to namespace: %v", err) + } + + // Configure host side of veth pair + cmd = exec.Command("ip", "addr", "add", "192.168.100.1/24", "dev", vethHost) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to configure host veth: %v", err) + } + + cmd = exec.Command("ip", "link", "set", vethHost, "up") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to bring up host veth: %v", err) + } + + // Configure namespace side of veth pair + cmd = exec.Command("ip", "netns", "exec", l.namespace, "ip", "addr", "add", "192.168.100.2/24", "dev", vethNetJail) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to configure namespace veth: %v", err) + } + + cmd = exec.Command("ip", "netns", "exec", l.namespace, "ip", "link", "set", vethNetJail, "up") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to bring up namespace veth: %v", err) + } + + cmd = exec.Command("ip", "netns", "exec", l.namespace, "ip", "link", "set", "lo", "up") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to bring up loopback: %v", err) + } + + // Set default route in namespace + cmd = exec.Command("ip", "netns", "exec", l.namespace, "ip", "route", "add", "default", "via", "192.168.100.1") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to set default route: %v", 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 *LinuxNetJail) 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) + if err := os.MkdirAll(netnsEtc, 0755); 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 boundary 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 +` + if err := os.WriteFile(resolvConfPath, []byte(dnsConfig), 0644); 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 traffic redirection +func (l *LinuxNetJail) setupIptables() error { + // Enable IP forwarding + cmd := exec.Command("sysctl", "-w", "net.ipv4.ip_forward=1") + cmd.Run() // Ignore error + + // NAT rules for outgoing traffic + cmd = exec.Command("iptables", "-t", "nat", "-A", "POSTROUTING", "-s", "192.168.100.0/24", "-j", "MASQUERADE") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add NAT rule: %v", err) + } + + // Redirect HTTP traffic to proxy + cmd = exec.Command("ip", "netns", "exec", l.namespace, "iptables", "-t", "nat", "-A", "OUTPUT", + "-p", "tcp", "--dport", "80", "-j", "DNAT", "--to-destination", fmt.Sprintf("192.168.100.1:%d", l.config.HTTPPort)) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add HTTP redirect rule: %v", err) + } + + // Redirect HTTPS traffic to proxy + cmd = exec.Command("ip", "netns", "exec", l.namespace, "iptables", "-t", "nat", "-A", "OUTPUT", + "-p", "tcp", "--dport", "443", "-j", "DNAT", "--to-destination", fmt.Sprintf("192.168.100.1:%d", l.config.HTTPSPort)) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add HTTPS redirect rule: %v", err) + } + + return nil +} + +// removeIptables removes iptables rules +func (l *LinuxNetJail) removeIptables() error { + // Remove NAT rule + cmd := exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", "192.168.100.0/24", "-j", "MASQUERADE") + cmd.Run() // Ignore errors during cleanup + + return nil +} + +// removeNamespace removes the network namespace +func (l *LinuxNetJail) removeNamespace() error { + cmd := exec.Command("ip", "netns", "del", l.namespace) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to remove namespace: %v", err) + } + return nil +} \ No newline at end of file diff --git a/netjail/linux_stub.go b/netjail/linux_stub.go new file mode 100644 index 0000000..4f3041f --- /dev/null +++ b/netjail/linux_stub.go @@ -0,0 +1,13 @@ +//go:build !linux + +package netjail + +import ( + "fmt" + "log/slog" +) + +// newLinuxNetJail is not available on non-Linux platforms +func newLinuxNetJail(config Config, logger *slog.Logger) (NetJail, error) { + return nil, fmt.Errorf("Linux network jail not supported on this platform") +} diff --git a/netjail/macos.go b/netjail/macos.go new file mode 100644 index 0000000..e9256f9 --- /dev/null +++ b/netjail/macos.go @@ -0,0 +1,338 @@ +//go:build darwin + +package netjail + +import ( + "fmt" + "io/ioutil" + "log/slog" + "os" + "os/exec" + "strconv" + "strings" + "syscall" +) + +const ( + PF_ANCHOR_NAME = "boundary" + GROUP_NAME = "boundary" +) + +// MacOSNetJail implements network jail using macOS PF (Packet Filter) and group-based isolation +type MacOSNetJail struct { + config Config + groupID int + pfRulesPath string + mainRulesPath string + logger *slog.Logger +} + +// newMacOSNetJail creates a new macOS network jail instance +func newMacOSNetJail(config Config, logger *slog.Logger) (*MacOSNetJail, error) { + pfRulesPath := fmt.Sprintf("/tmp/%s.pf", config.NetJailName) + mainRulesPath := fmt.Sprintf("/tmp/%s_main.pf", config.NetJailName) + + return &MacOSNetJail{ + config: config, + pfRulesPath: pfRulesPath, + mainRulesPath: mainRulesPath, + logger: logger, + }, nil +} + +// Setup configures PF rules and creates the network jail group +func (m *MacOSNetJail) Setup(httpPort, httpsPort int) error { + m.logger.Debug("Setup called", "httpPort", httpPort, "httpsPort", httpsPort) + m.config.HTTPPort = httpPort + m.config.HTTPSPort = httpsPort + + // Create or get network jail group + m.logger.Debug("Creating or ensuring network jail group") + if err := m.ensureGroup(); err != nil { + return fmt.Errorf("failed to ensure group: %v", err) + } + m.logger.Debug("Network jail group ready", "groupID", m.groupID) + + // Setup PF rules + m.logger.Debug("Setting up PF rules") + if err := m.setupPFRules(); err != nil { + return fmt.Errorf("failed to setup PF rules: %v", err) + } + m.logger.Debug("PF rules setup completed") + + m.logger.Debug("Setup completed successfully") + return nil +} + +// Execute runs the command with the network jail group membership +func (m *MacOSNetJail) Execute(command []string, extraEnv map[string]string) error { + m.logger.Debug("Execute called", "command", command) + if len(command) == 0 { + return fmt.Errorf("no command specified") + } + + // Create command directly (no sg wrapper needed) + m.logger.Debug("Creating command with group membership", "groupID", m.groupID) + cmd := exec.Command(command[0], command[1:]...) + m.logger.Debug("Full command args", "args", command) + + // Set up environment + m.logger.Debug("Setting up environment") + env := os.Environ() + + // Add extra environment variables (including CA cert if provided) + for key, value := range extraEnv { + env = append(env, fmt.Sprintf("%s=%s", key, value)) + } + + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + // Set group ID using syscall (like httpjail does) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Gid: uint32(m.groupID), + }, + } + + // Start and wait for command to complete + m.logger.Debug("Starting command", "path", cmd.Path, "args", cmd.Args) + err := cmd.Start() + if err != nil { + return fmt.Errorf("failed to start command: %v", err) + } + m.logger.Debug("Command started, waiting for completion") + + // Wait for command to complete + err = cmd.Wait() + m.logger.Debug("Command completed", "error", err) + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + if status, ok := exitError.Sys().(syscall.WaitStatus); ok { + m.logger.Debug("Command exit status", "status", status.ExitStatus()) + os.Exit(status.ExitStatus()) + } + } + return fmt.Errorf("command execution failed: %v", err) + } + + m.logger.Debug("Command executed successfully") + return nil +} + +// Cleanup removes PF rules and cleans up temporary files +func (m *MacOSNetJail) Cleanup() error { + m.logger.Debug("Starting cleanup process") + if m.config.SkipCleanup { + m.logger.Debug("Skipping cleanup (SkipCleanup=true)") + return nil + } + + // Remove PF rules + m.logger.Debug("Removing PF rules") + if err := m.removePFRules(); err != nil { + return fmt.Errorf("failed to remove PF rules: %v", err) + } + + // Clean up temporary files + m.logger.Debug("Cleaning up temporary files") + m.cleanupTempFiles() + + m.logger.Debug("Cleanup completed successfully") + return nil +} + +// ensureGroup creates the network jail group if it doesn't exist +func (m *MacOSNetJail) ensureGroup() error { + // Check if group already exists + output, err := exec.Command("dscl", ".", "-read", fmt.Sprintf("/Groups/%s", GROUP_NAME), "PrimaryGroupID").Output() + 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) + } + m.groupID = gid + return nil + } + } + } + } + + // Group doesn't exist, create it + cmd := exec.Command("dseditgroup", "-o", "create", GROUP_NAME) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create group: %v", err) + } + + // Get the newly created group's GID + output, err = exec.Command("dscl", ".", "-read", fmt.Sprintf("/Groups/%s", GROUP_NAME), "PrimaryGroupID").Output() + 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) + } + m.groupID = gid + return nil + } + } + } + + return fmt.Errorf("failed to get GID for group %s", GROUP_NAME) +} + +// getDefaultInterface gets the default network interface +func (m *MacOSNetJail) 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 traffic diversion +func (m *MacOSNetJail) createPFRules() (string, error) { + // Get the default network interface + iface, err := m.getDefaultInterface() + if err != nil { + return "", fmt.Errorf("failed to get default interface: %v", err) + } + + // Create PF rules following httpjail's working pattern + rules := fmt.Sprintf(`# boundary PF rules for GID %d on interface %s +# First, redirect traffic arriving on lo0 to our proxy ports +rdr pass on lo0 inet proto tcp from any to any port 80 -> 127.0.0.1 port %d +rdr pass on lo0 inet proto tcp from any to any port 443 -> 127.0.0.1 port %d + +# Route boundary group traffic to lo0 where it will be redirected +pass out route-to (lo0 127.0.0.1) inet proto tcp from any to any port 80 group %d keep state +pass out route-to (lo0 127.0.0.1) inet proto tcp from any to any port 443 group %d keep state + +# Also handle traffic on the specific interface +pass out on %s route-to (lo0 127.0.0.1) inet proto tcp from any to any port 80 group %d keep state +pass out on %s route-to (lo0 127.0.0.1) inet proto tcp from any to any port 443 group %d keep state + +# Allow all loopback traffic +pass on lo0 all +`, + m.groupID, + iface, + m.config.HTTPPort, + m.config.HTTPSPort, + m.groupID, + m.groupID, + iface, + m.groupID, + iface, + m.groupID, + ) + + return rules, nil +} + +// setupPFRules configures packet filter rules to redirect traffic +func (m *MacOSNetJail) setupPFRules() error { + // Create PF rules + rules, err := m.createPFRules() + if err != nil { + return fmt.Errorf("failed to create PF rules: %v", err) + } + + // Write rules to temp file + if err := ioutil.WriteFile(m.pfRulesPath, []byte(rules), 0644); err != nil { + return fmt.Errorf("failed to write PF rules file: %v", err) + } + + // Load rules into anchor + cmd := exec.Command("pfctl", "-a", PF_ANCHOR_NAME, "-f", m.pfRulesPath) + if err := cmd.Run(); 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" +`, PF_ANCHOR_NAME, PF_ANCHOR_NAME) + + // Write and load the main ruleset + if err := ioutil.WriteFile(m.mainRulesPath, []byte(mainRules), 0644); err != nil { + return fmt.Errorf("failed to write main PF rules: %v", err) + } + + cmd = exec.Command("pfctl", "-f", m.mainRulesPath) + if err := cmd.Run(); 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", PF_ANCHOR_NAME, "-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 (m *MacOSNetJail) removePFRules() error { + // Flush the anchor + cmd := exec.Command("pfctl", "-a", PF_ANCHOR_NAME, "-F", "all") + cmd.Run() // Ignore errors during cleanup + + return nil +} + +// cleanupTempFiles removes temporary rule files +func (m *MacOSNetJail) cleanupTempFiles() { + if m.pfRulesPath != "" { + os.Remove(m.pfRulesPath) + } + if m.mainRulesPath != "" { + os.Remove(m.mainRulesPath) + } +} \ No newline at end of file diff --git a/netjail/macos_stub.go b/netjail/macos_stub.go new file mode 100644 index 0000000..99ac3d7 --- /dev/null +++ b/netjail/macos_stub.go @@ -0,0 +1,10 @@ +//go:build !darwin + +package netjail + +import "log/slog" + +// newMacOSNetJail is not available on non-macOS platforms +func newMacOSNetJail(config Config, logger *slog.Logger) (NetJail, error) { + panic("macOS network jail not available on this platform") +} diff --git a/netjail/netjail.go b/netjail/netjail.go new file mode 100644 index 0000000..b44961e --- /dev/null +++ b/netjail/netjail.go @@ -0,0 +1,39 @@ +package netjail + +import ( + "fmt" + "log/slog" + "runtime" +) + +// NetJail represents a network isolation mechanism +type NetJail interface { + // Setup configures the network jail for the given proxy ports + Setup(httpPort, httpsPort int) error + + // Execute runs a command within the network jail with additional environment variables + Execute(command []string, extraEnv map[string]string) error + + // Cleanup removes network jail resources + Cleanup() error +} + +// Config holds configuration for network jail +type Config struct { + HTTPPort int + HTTPSPort int + NetJailName string + SkipCleanup bool +} + +// NewNetJail creates a new NetJail instance for the current platform +func NewNetJail(config Config, logger *slog.Logger) (NetJail, error) { + switch runtime.GOOS { + case "darwin": + return newMacOSNetJail(config, logger) + case "linux": + return newLinuxNetJail(config, logger) + default: + return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} \ No newline at end of file diff --git a/proxy/proxy.go b/proxy/proxy.go new file mode 100644 index 0000000..0e25cea --- /dev/null +++ b/proxy/proxy.go @@ -0,0 +1,271 @@ +package proxy + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "time" + + "github.com/coder/jail/rules" +) + +// ProxyServer handles HTTP and HTTPS requests with rule-based filtering +type ProxyServer struct { + httpServer *http.Server + httpsServer *http.Server + ruleEngine *rules.RuleEngine + logger *slog.Logger + tlsConfig *tls.Config + httpPort int + httpsPort int +} + +// Config holds configuration for the proxy server +type Config struct { + HTTPPort int + HTTPSPort int + RuleEngine *rules.RuleEngine + Logger *slog.Logger + TLSConfig *tls.Config +} + +// NewProxyServer creates a new proxy server instance +func NewProxyServer(config Config) *ProxyServer { + return &ProxyServer{ + ruleEngine: config.RuleEngine, + logger: config.Logger, + tlsConfig: config.TLSConfig, + httpPort: config.HTTPPort, + httpsPort: config.HTTPSPort, + } +} + +// Start starts both HTTP and HTTPS proxy servers +func (p *ProxyServer) Start(ctx context.Context) error { + // Create HTTP server + p.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", p.httpPort), + Handler: http.HandlerFunc(p.handleHTTP), + } + + // Create HTTPS server + p.httpsServer = &http.Server{ + Addr: fmt.Sprintf(":%d", p.httpsPort), + Handler: http.HandlerFunc(p.handleHTTPS), + TLSConfig: p.tlsConfig, + } + + // Start HTTP server + go func() { + p.logger.Info("Starting HTTP proxy", "port", p.httpPort) + if err := p.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + p.logger.Error("HTTP proxy server error", "error", err) + } + }() + + // Start HTTPS server + go func() { + p.logger.Info("Starting HTTPS proxy", "port", p.httpsPort) + if err := p.httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + p.logger.Error("HTTPS proxy server error", "error", err) + } + }() + + // Wait for context cancellation + <-ctx.Done() + return p.Stop() +} + +// Stop stops both proxy servers +func (p *ProxyServer) Stop() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var httpErr, httpsErr error + if p.httpServer != nil { + httpErr = p.httpServer.Shutdown(ctx) + } + if p.httpsServer != nil { + httpsErr = p.httpsServer.Shutdown(ctx) + } + + if httpErr != nil { + return httpErr + } + return httpsErr +} + +// handleHTTP handles regular HTTP requests +func (p *ProxyServer) handleHTTP(w http.ResponseWriter, r *http.Request) { + // Check if request should be allowed + action := p.ruleEngine.Evaluate(r.Method, r.URL.String()) + if action == rules.Deny { + p.writeBlockedResponse(w, r) + return + } + + // Forward regular HTTP request + p.forwardHTTPRequest(w, r) +} + +// handleHTTPS handles HTTPS requests (after TLS termination) +func (p *ProxyServer) handleHTTPS(w http.ResponseWriter, r *http.Request) { + // Reconstruct the full URL for HTTPS requests + 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 + action := p.ruleEngine.Evaluate(r.Method, fullURL) + if action == rules.Deny { + p.writeBlockedResponse(w, r) + return + } + + // Forward HTTPS request + p.forwardHTTPSRequest(w, r) +} + +// forwardHTTPRequest forwards a regular HTTP request +func (p *ProxyServer) forwardHTTPRequest(w http.ResponseWriter, r *http.Request) { + // Create a new request to the target server + targetURL := r.URL + if targetURL.Scheme == "" { + targetURL.Scheme = "http" + } + if targetURL.Host == "" { + targetURL.Host = r.Host + } + + // Create HTTP client + client := &http.Client{ + Timeout: 30 * time.Second, + 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 { + http.Error(w, fmt.Sprintf("Failed to create request: %v", err), http.StatusInternalServerError) + return + } + + // Copy headers + for name, values := range r.Header { + for _, value := range values { + req.Header.Add(name, value) + } + } + + // Make the request + resp, err := client.Do(req) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to make request: %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Copy response headers + for name, values := range resp.Header { + for _, value := range values { + w.Header().Add(name, value) + } + } + + // Copy status code + w.WriteHeader(resp.StatusCode) + + // Copy response body + io.Copy(w, resp.Body) +} + +// forwardHTTPSRequest forwards an HTTPS request +func (p *ProxyServer) forwardHTTPSRequest(w http.ResponseWriter, r *http.Request) { + // Create target URL + targetURL := &url.URL{ + Scheme: "https", + Host: r.Host, + Path: r.URL.Path, + RawQuery: r.URL.RawQuery, + } + + // Create HTTPS client + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: false, + }, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + // Create new request + req, err := http.NewRequest(r.Method, targetURL.String(), r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to create request: %v", err), http.StatusInternalServerError) + return + } + + // Copy headers + for name, values := range r.Header { + for _, value := range values { + req.Header.Add(name, value) + } + } + + // Make the request + resp, err := client.Do(req) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to make request: %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Copy response headers + for name, values := range resp.Header { + for _, value := range values { + w.Header().Add(name, value) + } + } + + // Copy status code + w.WriteHeader(resp.StatusCode) + + // Copy response body + io.Copy(w, resp.Body) +} + +// writeBlockedResponse writes a blocked response +func (p *ProxyServer) 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 Coder Jail + +Request: %s %s +Host: %s + +To allow this request, restart jail 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/jail +`, + r.Method, r.URL.Path, host, host, r.Method, host, r.Method) +} diff --git a/rules/rules.go b/rules/rules.go new file mode 100644 index 0000000..ba4d9ba --- /dev/null +++ b/rules/rules.go @@ -0,0 +1,261 @@ +package rules + +import ( + "fmt" + "log/slog" + "strings" +) + +// Action represents whether to allow or deny a request +type Action int + +const ( + Allow Action = iota + Deny +) + +func (a Action) String() string { + switch a { + case Allow: + return "ALLOW" + case Deny: + return "DENY" + default: + return "UNKNOWN" + } +} + +// Rule represents a filtering rule with optional HTTP method restrictions +type Rule struct { + Action Action + Pattern string // wildcard pattern for matching + Methods map[string]bool // nil means all methods allowed + Raw string // rule string for logging +} + +// newRule creates a new rule from a string format like "allow: github.com" or "deny-post: telemetry.*" +func newRule(ruleStr string) (*Rule, error) { + parts := strings.SplitN(ruleStr, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid rule format: %s (expected 'action[-method]: pattern')", ruleStr) + } + + actionPart := strings.TrimSpace(parts[0]) + pattern := strings.TrimSpace(parts[1]) + + // Parse action and optional method + var action Action + var methods map[string]bool + + actionParts := strings.Split(actionPart, "-") + switch strings.ToLower(actionParts[0]) { + case "allow": + action = Allow + case "deny": + action = Deny + default: + return nil, fmt.Errorf("invalid action: %s (must be 'allow' or 'deny')", actionParts[0]) + } + + // Parse optional method restriction + if len(actionParts) > 1 { + methods = make(map[string]bool) + for _, method := range actionParts[1:] { + methods[strings.ToUpper(method)] = true + } + } + + return &Rule{ + Action: action, + Pattern: pattern, + Methods: methods, + Raw: ruleStr, + }, nil +} + +// Matches checks if the rule matches the given method and URL using wildcard patterns +func (r *Rule) Matches(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 { + return wildcardMatchRecursive(pattern, text, 0, 0) +} + +// wildcardMatchRecursive is the recursive implementation of wildcard matching +func wildcardMatchRecursive(pattern, text string, p, t int) bool { + // If we've reached the end of the pattern + if p == len(pattern) { + return t == len(text) // Match if we've also reached the end of text + } + + // If we've reached the end of text but not pattern + if t == len(text) { + // Only match if remaining pattern is all '*' + for i := p; i < len(pattern); i++ { + if pattern[i] != '*' { + return false + } + } + return true + } + + // Handle current character in pattern + switch pattern[p] { + case '*': + // '*' matches zero or more characters + // Try matching zero characters (skip the '*') + if wildcardMatchRecursive(pattern, text, p+1, t) { + return true + } + // Try matching one or more characters + return wildcardMatchRecursive(pattern, text, p, t+1) + + default: + // Regular character must match exactly (case-insensitive for domains) + patternChar := strings.ToLower(string(pattern[p])) + textChar := strings.ToLower(string(text[t])) + if patternChar == textChar { + return wildcardMatchRecursive(pattern, text, p+1, t+1) + } + return false + } +} + +// RuleEngine evaluates HTTP requests against a set of rules +type RuleEngine struct { + rules []*Rule + logger *slog.Logger +} + +// NewRuleEngine creates a new rule engine +func NewRuleEngine(rules []*Rule, logger *slog.Logger) *RuleEngine { + return &RuleEngine{ + rules: rules, + logger: logger, + } +} + +// Evaluate evaluates a request against all rules and returns the action to take +func (re *RuleEngine) Evaluate(method, url string) Action { + // Evaluate rules in order + for _, rule := range re.rules { + if rule.Matches(method, url) { + switch rule.Action { + case Allow: + re.logger.Info("ALLOW", "method", method, "url", url, "rule", rule.Raw) + return Allow + case Deny: + re.logger.Warn("DENY", "method", method, "url", url, "rule", rule.Raw) + return Deny + } + } + } + + // Default deny if no rules match + re.logger.Warn("DENY", "method", method, "url", url, "reason", "no matching rules") + return Deny +} + +// 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 nil, 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 nil, fmt.Errorf("invalid allow spec: missing pattern") + } + + return &Rule{ + Action: Allow, + Pattern: pattern, + Methods: methods, + Raw: "allow " + spec, + }, nil +} + +// 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 +} \ No newline at end of file diff --git a/rules/rules_test.go b/rules/rules_test.go new file mode 100644 index 0000000..a09ca6d --- /dev/null +++ b/rules/rules_test.go @@ -0,0 +1,247 @@ +package rules + +import ( + "log/slog" + "testing" +) + +func TestNewRule(t *testing.T) { + tests := []struct { + name string + ruleStr string + expectError bool + expAction Action + expMethods map[string]bool + expPattern string + }{ + { + name: "simple allow rule", + ruleStr: "allow: github.com", + expectError: false, + expAction: Allow, + expMethods: nil, + expPattern: "github.com", + }, + { + name: "simple deny rule with wildcard", + ruleStr: "deny: telemetry.*", + expectError: false, + expAction: Deny, + expMethods: nil, + expPattern: "telemetry.*", + }, + { + name: "method-specific allow rule", + ruleStr: "allow-get: api.github.com", + expectError: false, + expAction: Allow, + expMethods: map[string]bool{"GET": true}, + expPattern: "api.github.com", + }, + { + name: "multiple methods deny rule", + ruleStr: "deny-post-put: upload.*", + expectError: false, + expAction: Deny, + expMethods: map[string]bool{"POST": true, "PUT": true}, + expPattern: "upload.*", + }, + { + name: "wildcard allow all", + ruleStr: "allow: *", + expectError: false, + expAction: Allow, + expMethods: nil, + expPattern: "*", + }, + { + name: "invalid format", + ruleStr: "invalid rule", + expectError: true, + }, + { + name: "invalid action", + ruleStr: "invalid: pattern", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule, err := newRule(tt.ruleStr) + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if rule.Action != tt.expAction { + t.Errorf("expected action %v, got %v", tt.expAction, rule.Action) + } + + if rule.Pattern != tt.expPattern { + t.Errorf("expected pattern %s, got %s", tt.expPattern, rule.Pattern) + } + + if len(rule.Methods) != len(tt.expMethods) { + t.Errorf("expected %d methods, got %d", len(tt.expMethods), len(rule.Methods)) + return + } + + for method := range tt.expMethods { + if !rule.Methods[method] { + t.Errorf("expected method %s to be allowed", method) + } + } + }) + } +} + +func TestWildcardMatch(t *testing.T) { + tests := []struct { + name string + pattern string + text string + expected bool + }{ + // Basic exact matches + {"exact match", "github.com", "github.com", true}, + {"no match", "github.com", "gitlab.com", false}, + + // Wildcard * tests + {"star matches all", "*", "anything.com", true}, + {"star matches empty", "*", "", true}, + {"prefix star", "github.*", "github.com", true}, + {"prefix star long", "github.*", "github.com/user/repo", true}, + {"suffix star", "*.com", "github.com", true}, + {"suffix star no match", "*.com", "github.org", false}, + {"middle star", "api.*.com", "api.github.com", true}, + {"middle star complex", "api.*.com", "api.v1.github.com", true}, + {"multiple stars", "*github*com*", "api.github.com", true}, + + // URL matching + {"http url exact", "https://api.github.com", "https://api.github.com", true}, + {"http url wildcard", "https://api.github.*", "https://api.github.com", true}, + {"http url prefix", "https://*.github.com", "https://api.github.com", true}, + + // Telemetry examples + {"telemetry wildcard", "telemetry.*", "telemetry.example.com", true}, + {"telemetry no match", "telemetry.*", "api.example.com", false}, + + // Case sensitivity + {"case insensitive", "GitHub.COM", "github.com", true}, + {"case insensitive wildcard", "*.GitHub.COM", "api.github.com", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := wildcardMatch(tt.pattern, tt.text) + if result != tt.expected { + t.Errorf("wildcardMatch(%q, %q) = %v, expected %v", tt.pattern, tt.text, result, tt.expected) + } + }) + } +} + +func TestRuleMatches(t *testing.T) { + rule, err := newRule("allow-get-post: api.github.*") + if err != nil { + t.Fatalf("failed to create rule: %v", err) + } + + tests := []struct { + name string + method string + url string + expected bool + }{ + {"matching GET", "GET", "https://api.github.com/user", true}, + {"matching POST", "POST", "https://api.github.com/repos", true}, + {"non-matching method", "PUT", "https://api.github.com/user", false}, + {"non-matching URL", "GET", "https://github.com/user", false}, + {"case insensitive method", "get", "https://api.github.com/user", true}, + {"wildcard match", "GET", "https://api.github.io/docs", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := rule.Matches(tt.method, tt.url) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestRuleEngine(t *testing.T) { + rules := []*Rule{ + {Action: Allow, Pattern: "github.com", Methods: nil, Raw: "allow: github.com"}, + {Action: Deny, Pattern: "*", Methods: nil, Raw: "deny: *"}, + } + + // Create a logger that discards output during tests + logger := slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{ + Level: slog.LevelError + 1, // Higher than any level to suppress all logs + })) + + engine := NewRuleEngine(rules, logger) + + tests := []struct { + name string + method string + url string + expected Action + }{ + {"allow github", "GET", "https://github.com/user/repo", Allow}, + {"deny other", "GET", "https://example.com", Deny}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := engine.Evaluate(tt.method, tt.url) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestRuleEngineWildcardRules(t *testing.T) { + rules := []*Rule{ + {Action: Deny, Pattern: "telemetry.*", Methods: nil, Raw: "deny: telemetry.*"}, + {Action: Allow, Pattern: "*", Methods: nil, Raw: "allow: *"}, + } + + // Create a logger that discards output during tests + logger := slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{ + Level: slog.LevelError + 1, + })) + + engine := NewRuleEngine(rules, logger) + + tests := []struct { + name string + method string + url string + expected Action + }{ + {"deny telemetry", "GET", "https://telemetry.example.com", Deny}, + {"allow other", "GET", "https://api.github.com", Allow}, + {"deny telemetry subdomain", "POST", "https://telemetry.analytics.com", Deny}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := engine.Evaluate(tt.method, tt.url) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} \ No newline at end of file diff --git a/squeeze/squeeze.go b/squeeze/squeeze.go deleted file mode 100644 index e8490c7..0000000 --- a/squeeze/squeeze.go +++ /dev/null @@ -1,138 +0,0 @@ -package squeeze - -import ( - "fmt" - "os" - "syscall" - - "golang.org/x/sys/unix" -) - -const ( - CLONE_NEWNET = 0x40000000 // Network namespace - CLONE_NEWNS = 0x00020000 // Mount namespace - CLONE_NEWUSER = 0x10000000 // User namespace -) - -// IsolationConfig holds the configuration for running a process in isolated namespaces -type IsolationConfig struct { - ProxyAddr string // Address where the transparent HTTP proxy will listen - AllowedPaths []string // Filesystem paths that will be visible in the mount namespace - Command []string // Command and arguments to execute in isolation - WorkingDir string // Working directory for the isolated process -} - -// Option is a functional option for configuring IsolationConfig -type Option func(*IsolationConfig) - -// WithProxy sets the address where the transparent HTTP proxy will listen. -// All network traffic from the isolated process will be routed through this proxy. -func WithProxy(addr string) Option { - return func(c *IsolationConfig) { - c.ProxyAddr = addr - } -} - -// WithAllowedPath adds a filesystem path that will be visible in the mount namespace. -// This can be called multiple times to allow access to multiple paths. -func WithAllowedPath(path string) Option { - return func(c *IsolationConfig) { - c.AllowedPaths = append(c.AllowedPaths, path) - } -} - -// WithCommand sets the command and arguments to execute in the isolated environment. -func WithCommand(cmd string, args ...string) Option { - return func(c *IsolationConfig) { - c.Command = append([]string{cmd}, args...) - } -} - -// WithWorkingDir sets the working directory for the isolated process. -func WithWorkingDir(dir string) Option { - return func(c *IsolationConfig) { - c.WorkingDir = dir - } -} - -// NewConfig creates a new IsolationConfig with the given options applied. -// It returns a configuration with sensible defaults that can be customized -// using the provided functional options. -func NewConfig(options ...Option) *IsolationConfig { - config := &IsolationConfig{ - ProxyAddr: "127.0.0.1:0", // Let OS choose port - WorkingDir: "/tmp", // Safe default working directory - } - - for _, option := range options { - option(config) - } - - return config -} - -// RunIsolated executes the configured command in isolated namespaces. -// The parent process remains in the original namespaces while the child -// runs in isolation with network, mount, and user namespace separation. -func (c *IsolationConfig) RunIsolated() error { - if len(c.Command) == 0 { - return fmt.Errorf("no command specified") - } - - // Fork a child process - pid, err := syscall.ForkExec( - "/proc/self/exe", // Re-execute ourselves - []string{"squeeze-child"}, // Special arg to indicate child mode - &syscall.ProcAttr{ - Dir: c.WorkingDir, - Env: os.Environ(), - Files: []uintptr{0, 1, 2}, // stdin, stdout, stderr - }, - ) - if err != nil { - return fmt.Errorf("failed to fork child process: %w", err) - } - - // Parent: wait for child to complete - var status syscall.WaitStatus - _, err = syscall.Wait4(pid, &status, 0, nil) - if err != nil { - return fmt.Errorf("failed to wait for child: %w", err) - } - - if !status.Exited() || status.ExitStatus() != 0 { - return fmt.Errorf("child process failed with status: %d", status.ExitStatus()) - } - - return nil -} - -// CreateNamespaces creates new user, mount, and network namespaces for the current process. -// This isolates the process from the host system's users, filesystem, and network. -// Must be called in the child process after fork. -func CreateNamespaces() error { - // Create user namespace first - this allows us to have root privileges - // inside the namespace for subsequent mount/network operations - if err := unshare(CLONE_NEWUSER); err != nil { - return fmt.Errorf("failed to create user namespace: %w", err) - } - - // Create mount namespace - gives us our own view of the filesystem - if err := unshare(CLONE_NEWNS); err != nil { - return fmt.Errorf("failed to create mount namespace: %w", err) - } - - // Create network namespace - isolates network interfaces and routing - if err := unshare(CLONE_NEWNET); err != nil { - return fmt.Errorf("failed to create network namespace: %w", err) - } - - return nil -} - -// unshare is a wrapper around the unshare system call -func unshare(flags int) error { - // On non-Linux systems, return an error indicating it's not supported - // On Linux, this will call the actual unshare syscall - return fmt.Errorf("namespace isolation not supported on this platform") -} diff --git a/test_constants.go b/test_constants.go deleted file mode 100644 index 3135b12..0000000 --- a/test_constants.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "fmt" - "golang.org/x/sys/unix" -) - -func main() { - fmt.Printf("CLONE_NEWUSER: %x\n", unix.CLONE_NEWUSER) - fmt.Printf("CLONE_NEWNS: %x\n", unix.CLONE_NEWNS) - fmt.Printf("CLONE_NEWNET: %x\n", unix.CLONE_NEWNET) -} \ No newline at end of file diff --git a/tls/tls.go b/tls/tls.go new file mode 100644 index 0000000..a9e51b0 --- /dev/null +++ b/tls/tls.go @@ -0,0 +1,313 @@ +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" +) + +// 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 +} + +// NewCertificateManager creates a new certificate manager +func NewCertificateManager(configDir string, logger *slog.Logger) (*CertificateManager, error) { + cm := &CertificateManager{ + certCache: make(map[string]*tls.Certificate), + logger: logger, + configDir: configDir, + } + + // Load or generate CA certificate + if err := cm.loadOrGenerateCA(); err != nil { + return nil, fmt.Errorf("failed to load or generate CA: %v", err) + } + + return cm, nil +} + +// 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 +} + +// 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) +} + +// 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 + if err := os.MkdirAll(cm.configDir, 0700); err != nil { + return fmt.Errorf("failed to create config directory: %v", 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{"boundary"}, + Country: []string{"US"}, + Province: []string{""}, + Locality: []string{""}, + StreetAddress: []string{""}, + PostalCode: []string{""}, + CommonName: "boundary 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 keyFile.Close() + + pem.Encode(keyFile, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + // 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() + + pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + 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{"boundary"}, + 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 +} + +// GetConfigDir returns the configuration directory path +func GetConfigDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %v", err) + } + + // Use platform-specific config directory + var configDir string + switch { + case os.Getenv("XDG_CONFIG_HOME") != "": + configDir = filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "boundary") + default: + configDir = filepath.Join(homeDir, ".config", "boundary") + } + + return configDir, nil +} \ No newline at end of file