├── .dockerignore ├── .github └── workflows │ └── build-and-release.yml ├── .gitignore ├── Dockerfile.build ├── Makefile ├── README.md ├── cmd └── lambda-nat-proxy │ ├── cli.go │ ├── cli_test.go │ ├── cmd_config.go │ ├── cmd_deploy.go │ ├── cmd_destroy.go │ ├── cmd_run.go │ ├── cmd_status.go │ ├── embedded.go │ └── main.go ├── go.mod ├── go.sum ├── internal ├── aws │ ├── clients.go │ └── clients_test.go ├── config │ ├── config.go │ ├── config_test.go │ ├── defaults.go │ ├── loader.go │ └── types.go ├── dashboard │ ├── api.go │ ├── api_dashboard.go │ ├── connection_tracker.go │ └── dashboard.go ├── deploy │ ├── infrastructure.yaml │ ├── lambda.go │ ├── lambda_build.go │ ├── stack.go │ ├── templates.go │ ├── templates_test.go │ └── triggers.go ├── launcher.go ├── manager │ ├── manager.go │ └── manager_test.go ├── metrics │ ├── metrics.go │ └── metrics_test.go ├── nat │ └── traversal.go ├── quic │ └── server.go ├── s3 │ ├── coordinator.go │ └── coordinator_test.go ├── socks5 │ └── proxy.go └── stun │ ├── client.go │ └── client_test.go ├── lambda ├── go.mod ├── go.sum └── main.go ├── media └── dashboard.png ├── pkg └── shared │ ├── aws.go │ ├── constants.go │ ├── control.go │ ├── control_test.go │ ├── errors.go │ ├── logger.go │ ├── nat.go │ ├── network.go │ ├── socks5.go │ ├── tls.go │ ├── types.go │ └── utils.go ├── scripts ├── build-dashboard.sh ├── docker-build-all.sh └── docker-build.sh ├── test └── e2e │ ├── basic_test.go │ └── go.mod └── web ├── index.html ├── package-lock.json ├── package.json ├── src ├── App.tsx ├── components │ ├── ConnectionsTable.tsx │ ├── Dashboard.tsx │ ├── LambdaFleet.tsx │ ├── SimpleChart.tsx │ ├── SimpleDestinations.tsx │ └── SimpleHeader.tsx ├── hooks │ └── useDashboard.ts ├── main.tsx ├── styles │ ├── dashboard.css │ └── globals.css ├── types.ts └── utils │ └── formatters.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | # Docker ignore file for faster builds 2 | 3 | # Build artifacts 4 | build/ 5 | dist/ 6 | *.exe 7 | *.so 8 | *.dylib 9 | 10 | # Temporary files 11 | .tmp/ 12 | tmp/ 13 | 14 | # IDE files 15 | .vscode/ 16 | .idea/ 17 | *.swp 18 | *.swo 19 | *~ 20 | 21 | # OS files 22 | .DS_Store 23 | Thumbs.db 24 | 25 | # Git 26 | .git/ 27 | .gitignore 28 | 29 | # Documentation 30 | *.md 31 | docs/ 32 | 33 | # CI/CD 34 | .github/ 35 | 36 | # Test files 37 | *_test.go 38 | test/ 39 | 40 | # Node modules (we'll install fresh) 41 | web/node_modules/ 42 | web/dist/ 43 | 44 | # Go build cache 45 | .cache/ -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | release: 9 | types: [ published ] 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | test: 17 | name: Run Tests 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Go 24 | uses: actions/setup-go@v4 25 | with: 26 | go-version: '1.21' 27 | 28 | - name: Set up Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: '18' 32 | 33 | - name: Cache Go modules 34 | uses: actions/cache@v3 35 | with: 36 | path: | 37 | ~/.cache/go-build 38 | ~/go/pkg/mod 39 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 40 | restore-keys: | 41 | ${{ runner.os }}-go- 42 | 43 | - name: Cache Node modules 44 | uses: actions/cache@v3 45 | with: 46 | path: web/node_modules 47 | key: ${{ runner.os }}-node-${{ hashFiles('web/package-lock.json') }} 48 | restore-keys: | 49 | ${{ runner.os }}-node- 50 | 51 | - name: Install Node.js dependencies 52 | run: cd web && npm ci 53 | 54 | - name: Run tests 55 | run: make test 56 | 57 | build-native: 58 | name: Build Native Binary 59 | strategy: 60 | matrix: 61 | include: 62 | - os: ubuntu-latest 63 | goos: linux 64 | goarch: amd64 65 | - os: ubuntu-latest 66 | goos: linux 67 | goarch: arm64 68 | - os: macos-latest 69 | goos: darwin 70 | goarch: amd64 71 | - os: macos-latest 72 | goos: darwin 73 | goarch: arm64 74 | runs-on: ${{ matrix.os }} 75 | needs: test 76 | if: github.event_name == 'push' || github.event_name == 'release' 77 | steps: 78 | - name: Checkout code 79 | uses: actions/checkout@v4 80 | 81 | - name: Set up Go 82 | uses: actions/setup-go@v4 83 | with: 84 | go-version: '1.21' 85 | 86 | - name: Set up Node.js 87 | uses: actions/setup-node@v4 88 | with: 89 | node-version: '18' 90 | 91 | - name: Install Node.js dependencies 92 | run: cd web && npm ci 93 | 94 | - name: Build dashboard 95 | run: | 96 | cd web 97 | npm run build 98 | mkdir -p ../internal/dashboard/web 99 | cp -r dist ../internal/dashboard/web/ 100 | 101 | - name: Build Lambda function 102 | run: | 103 | mkdir -p cmd/lambda-nat-proxy/assets 104 | cd lambda 105 | GOOS=linux GOARCH=amd64 go build -o ../cmd/lambda-nat-proxy/assets/bootstrap . 106 | chmod +x ../cmd/lambda-nat-proxy/assets/bootstrap 107 | 108 | - name: Build binary for ${{ matrix.goos }}/${{ matrix.goarch }} 109 | env: 110 | GOOS: ${{ matrix.goos }} 111 | GOARCH: ${{ matrix.goarch }} 112 | run: | 113 | mkdir -p build 114 | CGO_ENABLED=0 go build -a -installsuffix cgo -o build/lambda-nat-proxy ./cmd/lambda-nat-proxy 115 | 116 | - name: Create archive 117 | run: | 118 | cd build 119 | tar -czf lambda-nat-proxy-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz lambda-nat-proxy 120 | mv lambda-nat-proxy-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz ../ 121 | 122 | - name: Upload build artifact 123 | uses: actions/upload-artifact@v4 124 | with: 125 | name: lambda-nat-proxy-${{ matrix.goos }}-${{ matrix.goarch }} 126 | path: lambda-nat-proxy-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz 127 | retention-days: 30 128 | 129 | release: 130 | name: Create Release 131 | runs-on: ubuntu-latest 132 | needs: build-native 133 | if: github.event_name == 'release' 134 | permissions: 135 | contents: write 136 | 137 | steps: 138 | - name: Download all artifacts 139 | uses: actions/download-artifact@v4 140 | with: 141 | path: artifacts 142 | 143 | - name: Display structure of downloaded files 144 | run: ls -la artifacts/ 145 | 146 | - name: Upload release assets 147 | uses: softprops/action-gh-release@v1 148 | with: 149 | files: artifacts/**/*.tar.gz 150 | fail_on_unmatched_files: true 151 | env: 152 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 153 | 154 | docker: 155 | name: Build and Push Docker Image 156 | runs-on: ubuntu-latest 157 | needs: test 158 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') 159 | permissions: 160 | contents: read 161 | packages: write 162 | 163 | steps: 164 | - name: Checkout code 165 | uses: actions/checkout@v4 166 | 167 | - name: Set up Docker Buildx 168 | uses: docker/setup-buildx-action@v3 169 | 170 | - name: Log in to Container Registry 171 | uses: docker/login-action@v3 172 | with: 173 | registry: ${{ env.REGISTRY }} 174 | username: ${{ github.actor }} 175 | password: ${{ secrets.GITHUB_TOKEN }} 176 | 177 | - name: Extract metadata 178 | id: meta 179 | uses: docker/metadata-action@v5 180 | with: 181 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 182 | tags: | 183 | type=ref,event=branch 184 | type=sha,prefix={{branch}}- 185 | type=raw,value=latest,enable={{is_default_branch}} 186 | 187 | - name: Build and push Docker image 188 | uses: docker/build-push-action@v5 189 | with: 190 | context: . 191 | file: ./Dockerfile.build 192 | platforms: linux/amd64,linux/arm64 193 | push: true 194 | tags: ${{ steps.meta.outputs.tags }} 195 | labels: ${{ steps.meta.outputs.labels }} 196 | cache-from: type=gha 197 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Go workspace file 15 | go.work 16 | 17 | # Go module downloads 18 | vendor/ 19 | 20 | # Build directory 21 | build/ 22 | 23 | # Embedded assets (generated during build) 24 | cmd/lambda-nat-proxy/assets/ 25 | 26 | # IDE files 27 | .vscode/ 28 | .idea/ 29 | *.swp 30 | *.swo 31 | *~ 32 | 33 | # OS generated files 34 | .DS_Store 35 | .DS_Store? 36 | ._* 37 | .Spotlight-V100 38 | .Trashes 39 | ehthumbs.db 40 | Thumbs.db 41 | 42 | # Logs 43 | *.log 44 | 45 | # Environment variables 46 | .env 47 | .env.local 48 | .env.*.local 49 | 50 | # AWS credentials (should never be committed) 51 | .aws/ 52 | aws-config 53 | 54 | # Temporary files 55 | tmp/ 56 | temp/ 57 | 58 | # Built binary 59 | lambda-proxy 60 | 61 | # Node modules and build outputs 62 | web/node_modules/ 63 | web/dist/ 64 | 65 | # Embedded web assets (generated during build) 66 | internal/dashboard/web/ -------------------------------------------------------------------------------- /Dockerfile.build: -------------------------------------------------------------------------------- 1 | # Multi-stage Docker build for lambda-nat-proxy 2 | # This ensures reproducible builds with all dependencies included 3 | 4 | # Stage 1: Node.js environment for dashboard build 5 | FROM node:18-alpine AS dashboard-builder 6 | 7 | # Set working directory 8 | WORKDIR /app 9 | 10 | # Copy package files 11 | COPY web/package*.json ./web/ 12 | WORKDIR /app/web 13 | 14 | # Install dependencies and build dashboard 15 | RUN npm ci --only=production 16 | COPY web/ . 17 | RUN npm run build 18 | 19 | # Stage 2: Go environment for binary build 20 | FROM golang:1.21-alpine AS go-builder 21 | 22 | # Build arguments for target platform 23 | ARG TARGETOS=linux 24 | ARG TARGETARCH=amd64 25 | 26 | # Install build dependencies 27 | RUN apk add --no-cache git 28 | 29 | # Set working directory 30 | WORKDIR /app 31 | 32 | # Copy source code first 33 | COPY . . 34 | 35 | # Copy built dashboard from previous stage 36 | COPY --from=dashboard-builder /app/web/dist ./internal/dashboard/web/dist 37 | 38 | # Fix the replace directive in lambda/go.mod to use absolute path 39 | RUN sed -i 's|replace github.com/dan-v/lambda-nat-punch-proxy => ..|replace github.com/dan-v/lambda-nat-punch-proxy => /app|' lambda/go.mod 40 | 41 | # Download dependencies 42 | RUN go mod download 43 | RUN cd lambda && go mod download 44 | 45 | # Build Lambda function 46 | RUN cd lambda && GOOS=linux GOARCH=amd64 go build -o ../cmd/lambda-nat-proxy/assets/bootstrap . 47 | 48 | # Build main binary for target platform 49 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o lambda-nat-proxy ./cmd/lambda-nat-proxy 50 | 51 | # Stage 3: Final minimal image (optional, for running) 52 | FROM alpine:latest AS final 53 | 54 | RUN apk --no-cache add ca-certificates 55 | WORKDIR /root/ 56 | 57 | COPY --from=go-builder /app/lambda-nat-proxy . 58 | COPY --from=go-builder /app/cmd/lambda-nat-proxy/assets/bootstrap ./assets/ 59 | 60 | CMD ["./lambda-nat-proxy"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Lambda NAT Proxy - Makefile 2 | # Simple build and test operations 3 | 4 | # Build configuration 5 | BUILD_DIR := build 6 | LAMBDA_PROXY_BIN := $(BUILD_DIR)/lambda-nat-proxy 7 | LAMBDA_BOOTSTRAP := $(BUILD_DIR)/bootstrap 8 | 9 | .PHONY: help build test e2e clean tidy 10 | 11 | # Default target 12 | all: build 13 | 14 | help: ## Show this help 15 | @echo "Lambda NAT Proxy - Build & Test" 16 | @echo "" 17 | @echo "Build Options:" 18 | @echo " make build Build locally (requires Node.js + Go)" 19 | @echo " make docker-build Build using Docker (no local deps needed)" 20 | @echo " make build-all Build for all platforms using Docker" 21 | @echo "" 22 | @echo "Development:" 23 | @echo " make test Run all tests" 24 | @echo " make e2e Run end-to-end connectivity test" 25 | @echo " make clean Remove build artifacts" 26 | @echo " make tidy Tidy Go modules" 27 | @echo "" 28 | @echo "CLI Usage:" 29 | @echo " ./build/lambda-nat-proxy deploy Deploy infrastructure" 30 | @echo " ./build/lambda-nat-proxy run Start proxy with dashboard" 31 | @echo " ./build/lambda-nat-proxy status Check deployment status" 32 | @echo " ./build/lambda-nat-proxy destroy Remove all resources" 33 | 34 | build: ## Build lambda-nat-proxy CLI with embedded Lambda function and dashboard 35 | @echo "Building dashboard frontend..." 36 | @./scripts/build-dashboard.sh 37 | @echo "Building Lambda function..." 38 | @mkdir -p $(BUILD_DIR) 39 | @mkdir -p cmd/lambda-nat-proxy/assets 40 | @cd lambda && GOOS=linux GOARCH=amd64 go build -o ../cmd/lambda-nat-proxy/assets/bootstrap . 41 | @chmod +x cmd/lambda-nat-proxy/assets/bootstrap 42 | @echo "✅ Built: cmd/lambda-nat-proxy/assets/bootstrap" 43 | @echo "Building lambda-nat-proxy CLI with embedded Lambda and dashboard..." 44 | @go build -o $(LAMBDA_PROXY_BIN) ./cmd/lambda-nat-proxy 45 | @echo "✅ Built: $(LAMBDA_PROXY_BIN) (with embedded Lambda function and dashboard)" 46 | @echo "Copying bootstrap to build directory for consistency..." 47 | @cp cmd/lambda-nat-proxy/assets/bootstrap $(LAMBDA_BOOTSTRAP) 48 | @echo "✅ Built: $(LAMBDA_BOOTSTRAP)" 49 | 50 | docker-build: ## Build using Docker (no local dependencies required) 51 | @echo "🐳 Building with Docker (includes all dependencies)..." 52 | @./scripts/docker-build.sh 53 | 54 | build-all: ## Build for multiple platforms using Docker 55 | @echo "🌍 Building for multiple platforms using Docker..." 56 | @./scripts/docker-build-all.sh 57 | 58 | test: ## Run all tests 59 | @echo "Running all tests..." 60 | @echo "Building dashboard for embedded tests..." 61 | @./scripts/build-dashboard.sh 62 | @echo "Building Lambda function for embedded tests..." 63 | @mkdir -p $(BUILD_DIR) 64 | @mkdir -p cmd/lambda-nat-proxy/assets 65 | @cd lambda && GOOS=linux GOARCH=amd64 go build -o ../cmd/lambda-nat-proxy/assets/bootstrap . 66 | @chmod +x cmd/lambda-nat-proxy/assets/bootstrap 67 | @go test -v ./... 68 | @echo "✅ All tests passed" 69 | 70 | e2e: build ## Run end-to-end connectivity test 71 | @echo "Running end-to-end tests..." 72 | @cd test/e2e && go test -v . 73 | @echo "✅ End-to-end tests passed" 74 | 75 | clean: ## Remove build artifacts 76 | @echo "Cleaning build directory..." 77 | @rm -rf $(BUILD_DIR) 78 | @echo "Cleaning embedded assets..." 79 | @rm -rf cmd/lambda-nat-proxy/assets 80 | @echo "Cleaning dashboard build artifacts..." 81 | @rm -rf web/dist 82 | @rm -rf internal/dashboard/web 83 | @echo "✅ Build artifacts removed" 84 | 85 | tidy: ## Tidy Go modules 86 | @echo "Tidying Go modules..." 87 | @go mod tidy 88 | @cd lambda && go mod tidy 89 | @cd test/e2e && go mod tidy 90 | @echo "✅ Go modules tidied" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lambda NAT Proxy 2 | 3 | A serverless proxy implementation that uses NAT hole punching to establish QUIC tunnels through AWS Lambda functions. By coordinating through S3 and using UDP traversal techniques, it creates encrypted proxy connections without requiring any dedicated servers - just Lambda functions that spin up on demand. 4 | 5 |  6 | 7 | ## About 8 | 9 | This project evolved from exploring an unconventional idea: can AWS Lambda functions work as network proxies? Building on my earlier [awslambdaproxy](https://github.com/dan-v/awslambdaproxy) experiment, this implementation solves the performance and infrastructure challenges using NAT hole punching and QUIC protocol. The result is a serverless proxy that needs no EC2 instances, no SSH tunnels - just Lambda functions and clever networking. 10 | 11 | ## How It Works 12 | 13 | The system uses a three-phase approach to establish NAT traversal: 14 | 15 | **1. Coordination Phase** 16 | - Client discovers public IP via STUN protocol 17 | - Writes session info (IP:port, session ID) to S3 bucket 18 | - S3 event notification triggers Lambda function 19 | 20 | **2. NAT Hole Punching** 21 | - Both client and Lambda send UDP packets to each other's public endpoints 22 | - Creates bidirectional NAT holes for subsequent traffic 23 | - Uses session ID for packet correlation 24 | 25 | **3. QUIC Tunnel Establishment** 26 | - Client starts QUIC server on punched port 27 | - Lambda connects as QUIC client through established hole 28 | - Encrypted, multiplexed tunnel ready for traffic forwarding 29 | 30 | **Traffic Flow:** 31 | ``` 32 | Browser → SOCKS5 → QUIC Tunnel → Lambda → Internet 33 | ``` 34 | 35 | The Lambda function acts as an exit node, forwarding tunneled traffic to destination servers and relaying responses back through the QUIC connection. 36 | 37 | ## Architecture 38 | 39 | ``` 40 | ┌─────────┐ SOCKS5 ┌──────────────┐ QUIC/UDP ┌─────────┐ HTTP/S ┌───────────┐ 41 | │ Browser │ ────────── │ lambda-nat- │ ──────────── │ Lambda │ ────────── │ Internet │ 42 | │ │ :1080 │ proxy │ │ Function│ │ Servers │ 43 | └─────────┘ └──────────────┘ └─────────┘ └───────────┘ 44 | │ ▲ 45 | │ session data │ S3 event 46 | ▼ │ 47 | ┌─────────────┐ │ 48 | │ S3 Bucket │ ────────────────────┘ 49 | │ (coord) │ 50 | └─────────────┘ 51 | ``` 52 | 53 | ## Setup 54 | 55 | **Prerequisites:** 56 | - AWS CLI configured with Lambda, S3, CloudFormation permissions 57 | 58 | **Setup:** 59 | ```bash 60 | lambda-nat-proxy config init 61 | ``` 62 | 63 | **Deploy infrastructure:** 64 | ```bash 65 | lambda-nat-proxy deploy 66 | ``` 67 | 68 | **Run proxy:** 69 | ```bash 70 | lambda-nat-proxy run 71 | ``` 72 | 73 | **Configure browser to use SOCKS5 proxy:** `localhost:1080` 74 | 75 | The system auto-detects deployed resources and handles session management automatically. 76 | 77 | ## Commands 78 | 79 | ```bash 80 | lambda-nat-proxy config init # Create configuration file 81 | lambda-nat-proxy deploy # Deploy AWS infrastructure 82 | lambda-nat-proxy run # Start SOCKS5 proxy server 83 | lambda-nat-proxy status # Show deployment status 84 | lambda-nat-proxy destroy # Remove all AWS resources 85 | ``` 86 | 87 | ## Performance Modes 88 | 89 | - **test**: 128MB Lambda, 2min timeout (development) 90 | - **normal**: 256MB Lambda, 10min timeout (default) 91 | - **performance**: 512MB Lambda, 15min timeout (high throughput) 92 | 93 | ## Configuration 94 | 95 | Default config location: `~/.config/lambda-nat-proxy/lambda-nat-proxy.yaml` 96 | 97 | ```yaml 98 | aws: 99 | region: us-west-2 100 | deployment: 101 | stack_name: lambda-nat-proxy-a1b2c3d4 # auto-generated unique suffix 102 | mode: normal 103 | proxy: 104 | port: 1080 105 | stun_server: stun.l.google.com:19302 106 | ``` 107 | 108 | ## Implementation Details 109 | 110 | **NAT Traversal Algorithm:** 111 | 1. Client binds UDP socket and performs STUN discovery 112 | 2. Client writes session data to S3: `{client_ip, client_port, session_id, timestamp}` 113 | 3. S3 notification triggers Lambda function with session data 114 | 4. Lambda binds UDP socket and performs STUN discovery 115 | 5. Both endpoints send UDP packets to each other's public address 116 | 6. NAT devices create bidirectional port mappings 117 | 7. Client starts QUIC server, Lambda connects as client 118 | 8. QUIC connection established for traffic forwarding 119 | 120 | **Session Management:** 121 | - Unique session IDs prevent collision between concurrent sessions 122 | - S3 lifecycle rules clean up coordination files after 24 hours 123 | - Automatic session rotation when Lambda functions restart 124 | 125 | **QUIC Protocol Benefits:** 126 | - Built-in encryption (TLS 1.3) 127 | - Multiplexed streams (no head-of-line blocking) 128 | - Congestion control optimized for varying network conditions 129 | 130 | ## Building 131 | 132 | ```bash 133 | make build # Build with embedded dashboard 134 | make docker-build # Build using Docker (no local deps) 135 | make test # Run all tests 136 | ``` -------------------------------------------------------------------------------- /cmd/lambda-nat-proxy/cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/dan-v/lambda-nat-punch-proxy/pkg/shared" 8 | ) 9 | 10 | // executeCliCommand executes the cobra CLI 11 | func executeCliCommand() error { 12 | return rootCmd.Execute() 13 | } 14 | 15 | // rootCmd represents the base command when called without any subcommands 16 | var rootCmd = &cobra.Command{ 17 | Use: "lambda-nat-proxy", 18 | Short: "A QUIC NAT Traversal SOCKS5 Proxy using AWS Lambda", 19 | Long: `lambda-nat-proxy is a high-performance SOCKS5 proxy that uses QUIC protocol 20 | and AWS Lambda for NAT traversal. It provides seamless network connectivity 21 | through NAT and firewall restrictions.`, 22 | } 23 | 24 | // versionCmd represents the version command 25 | var versionCmd = &cobra.Command{ 26 | Use: "version", 27 | Short: "Print the version information", 28 | Long: "Print the version information for lambda-nat-proxy", 29 | Run: func(cmd *cobra.Command, args []string) { 30 | fmt.Println("lambda-nat-proxy v1.0.0") 31 | }, 32 | } 33 | 34 | func init() { 35 | // Initialize structured logging for CLI 36 | shared.InitLogger(&shared.LogConfig{ 37 | Level: shared.LevelInfo, 38 | Format: "text", // Human-readable format for CLI 39 | AddSource: false, 40 | ServiceName: "lambda-nat-proxy-cli", 41 | }) 42 | 43 | // Add global flags 44 | rootCmd.PersistentFlags().StringP("config", "c", "", "config file path") 45 | 46 | // Disable completion command 47 | rootCmd.CompletionOptions.DisableDefaultCmd = true 48 | 49 | // Add commands to root 50 | rootCmd.AddCommand(versionCmd) 51 | rootCmd.AddCommand(runCmd) 52 | rootCmd.AddCommand(deployCmd) 53 | rootCmd.AddCommand(destroyCmd) 54 | rootCmd.AddCommand(statusCmd) 55 | rootCmd.AddCommand(configCmd) 56 | } -------------------------------------------------------------------------------- /cmd/lambda-nat-proxy/cmd_config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | "gopkg.in/yaml.v3" 11 | 12 | "github.com/dan-v/lambda-nat-punch-proxy/internal/config" 13 | ) 14 | 15 | // configCmd represents the config command 16 | var configCmd = &cobra.Command{ 17 | Use: "config", 18 | Short: "Configuration management commands", 19 | Long: `Manage lambda-nat-proxy configuration files. 20 | 21 | Configuration is loaded from multiple sources in order of precedence: 22 | 1. Command line flags 23 | 2. Environment variables 24 | 3. Configuration file 25 | 4. Default values 26 | 27 | The configuration file is searched in: 28 | - Current directory (lambda-nat-proxy.yaml) 29 | - ~/.config/lambda-nat-proxy/lambda-nat-proxy.yaml (XDG config home) 30 | - /etc/lambda-nat-proxy/lambda-nat-proxy.yaml (system-wide)`, 31 | } 32 | 33 | // configInitCmd represents the config init command 34 | var configInitCmd = &cobra.Command{ 35 | Use: "init", 36 | Short: "Initialize configuration file", 37 | Long: `Create a new configuration file with default values. 38 | 39 | This command creates a lambda-nat-proxy.yaml file in the user's config directory 40 | (~/.config/lambda-nat-proxy/) with all available configuration options and their 41 | default values. You can then edit this file to customize the proxy settings.`, 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | return runConfigInit(cmd) 44 | }, 45 | } 46 | 47 | // configShowCmd represents the config show command 48 | var configShowCmd = &cobra.Command{ 49 | Use: "show", 50 | Short: "Show current configuration", 51 | Long: `Display the current configuration values. 52 | 53 | This command shows the merged configuration from all sources 54 | (defaults, config file, environment variables, and command line flags) 55 | and indicates where each value comes from.`, 56 | RunE: func(cmd *cobra.Command, args []string) error { 57 | return runConfigShow(cmd) 58 | }, 59 | } 60 | 61 | func init() { 62 | // Add subcommands to config 63 | configCmd.AddCommand(configInitCmd) 64 | configCmd.AddCommand(configShowCmd) 65 | 66 | // Add config init specific flags 67 | configInitCmd.Flags().StringP("output", "o", "", "Output file path (defaults to XDG config directory)") 68 | configInitCmd.Flags().BoolP("force", "f", false, "Overwrite existing config file") 69 | 70 | // Add config show specific flags 71 | configShowCmd.Flags().StringP("format", "", "yaml", "Output format (yaml, json, table)") 72 | } 73 | 74 | // runConfigInit implements the config init command 75 | func runConfigInit(cmd *cobra.Command) error { 76 | outputPath, _ := cmd.Flags().GetString("output") 77 | force, _ := cmd.Flags().GetBool("force") 78 | 79 | // Use default config path if not specified 80 | if outputPath == "" { 81 | outputPath = config.GetDefaultConfigPath() 82 | } 83 | 84 | // Check if file already exists 85 | if _, err := os.Stat(outputPath); err == nil && !force { 86 | fmt.Printf("Configuration file already exists at: %s\n\n", outputPath) 87 | fmt.Println("What would you like to do?") 88 | fmt.Println("1. View current config (recommended)") 89 | fmt.Println("2. Overwrite with fresh defaults") 90 | fmt.Println("3. Cancel") 91 | fmt.Print("\nChoose an option [1/2/3]: ") 92 | 93 | reader := bufio.NewReader(os.Stdin) 94 | response, err := reader.ReadString('\n') 95 | if err != nil { 96 | return fmt.Errorf("failed to read input: %w", err) 97 | } 98 | 99 | response = strings.TrimSpace(response) 100 | switch response { 101 | case "1", "": 102 | // Show current config 103 | fmt.Println("\nCurrent configuration:") 104 | fmt.Println("─────────────────────") 105 | 106 | // Load and display config directly 107 | configPath, _ := cmd.Flags().GetString("config") 108 | cfg, err := config.LoadCLIConfig(configPath) 109 | if err != nil { 110 | return fmt.Errorf("failed to load configuration: %w", err) 111 | } 112 | 113 | // Show config source information 114 | configSource := getConfigSource(configPath) 115 | fmt.Printf("# Configuration loaded from: %s\n\n", configSource) 116 | 117 | // Display config in YAML format 118 | encoder := yaml.NewEncoder(os.Stdout) 119 | encoder.SetIndent(2) 120 | defer encoder.Close() 121 | if err := encoder.Encode(cfg); err != nil { 122 | return fmt.Errorf("failed to display config: %w", err) 123 | } 124 | 125 | fmt.Printf("\nTo edit: %s\n", outputPath) 126 | return nil 127 | case "2": 128 | // Continue with overwrite 129 | fmt.Println("Overwriting existing config file...") 130 | case "3": 131 | fmt.Println("Operation cancelled.") 132 | return nil 133 | default: 134 | fmt.Println("Invalid option. Operation cancelled.") 135 | return nil 136 | } 137 | } 138 | 139 | // Create example config 140 | if err := config.WriteExampleConfig(outputPath); err != nil { 141 | return fmt.Errorf("failed to create config file: %w", err) 142 | } 143 | 144 | fmt.Printf("Configuration file created: %s\n", outputPath) 145 | fmt.Println("Edit this file to customize your lambda-nat-proxy settings.") 146 | 147 | return nil 148 | } 149 | 150 | // runConfigShow implements the config show command 151 | func runConfigShow(cmd *cobra.Command) error { 152 | // Load configuration 153 | configPath, _ := cmd.Flags().GetString("config") 154 | cfg, err := config.LoadCLIConfig(configPath) 155 | if err != nil { 156 | return fmt.Errorf("failed to load configuration: %w", err) 157 | } 158 | 159 | // Show config source information 160 | configSource := getConfigSource(configPath) 161 | fmt.Printf("# Configuration loaded from: %s\n\n", configSource) 162 | 163 | format, _ := cmd.Flags().GetString("format") 164 | 165 | switch format { 166 | case "yaml": 167 | encoder := yaml.NewEncoder(os.Stdout) 168 | encoder.SetIndent(2) 169 | defer encoder.Close() 170 | return encoder.Encode(cfg) 171 | case "json": 172 | // Could implement JSON output here 173 | return fmt.Errorf("JSON format not yet implemented") 174 | case "table": 175 | // Could implement table format here 176 | return fmt.Errorf("table format not yet implemented") 177 | default: 178 | return fmt.Errorf("unsupported format: %s", format) 179 | } 180 | } 181 | 182 | // getConfigSource returns a user-friendly description of where config is loaded from 183 | func getConfigSource(configPath string) string { 184 | if configPath != "" { 185 | // Explicit config file specified 186 | return configPath 187 | } 188 | 189 | // Check if config file exists in standard locations 190 | if foundPath, err := config.FindConfigFile(); err == nil { 191 | return foundPath 192 | } 193 | 194 | // No config file found, using defaults 195 | return "defaults (no config file found)" 196 | } -------------------------------------------------------------------------------- /cmd/lambda-nat-proxy/cmd_deploy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | awsclients "github.com/dan-v/lambda-nat-punch-proxy/internal/aws" 12 | "github.com/dan-v/lambda-nat-punch-proxy/internal/config" 13 | "github.com/dan-v/lambda-nat-punch-proxy/internal/deploy" 14 | ) 15 | 16 | // deployCmd represents the deploy command 17 | var deployCmd = &cobra.Command{ 18 | Use: "deploy", 19 | Short: "Deploy infrastructure and Lambda function", 20 | Long: `Deploy the AWS infrastructure and Lambda function needed for the proxy. 21 | 22 | This command will: 23 | - Deploy CloudFormation stack with S3 bucket and IAM roles 24 | - Build and deploy the Lambda function 25 | - Configure S3 trigger for Lambda invocation 26 | - Set appropriate memory and timeout based on performance mode 27 | 28 | The deployment process typically takes 2-5 minutes.`, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | return runDeploy(cmd) 31 | }, 32 | } 33 | 34 | func runDeploy(cmd *cobra.Command) error { 35 | ctx := context.Background() 36 | 37 | // Load configuration 38 | configPath, _ := cmd.Flags().GetString("config") 39 | cfg, err := config.LoadCLIConfig(configPath) 40 | if err != nil { 41 | return fmt.Errorf("failed to load configuration: %w", err) 42 | } 43 | 44 | // Apply command line flag overrides 45 | if mode, _ := cmd.Flags().GetString("mode"); cmd.Flags().Changed("mode") { 46 | cfg.Deployment.Mode = config.PerformanceMode(mode) 47 | } 48 | if region, _ := cmd.Flags().GetString("region"); cmd.Flags().Changed("region") { 49 | cfg.AWS.Region = region 50 | } 51 | if stackName, _ := cmd.Flags().GetString("stack-name"); cmd.Flags().Changed("stack-name") { 52 | cfg.Deployment.StackName = stackName 53 | } 54 | 55 | // Validate configuration 56 | if errors := config.ValidateCLIConfig(cfg); len(errors) > 0 { 57 | fmt.Printf("❌ Configuration validation failed:\n\n") 58 | for _, err := range errors { 59 | errMsg := err.Error() 60 | fmt.Printf(" • %s\n", errMsg) 61 | // Add specific guidance based on common configuration issues 62 | if strings.Contains(errMsg, "region") { 63 | fmt.Printf(" 💡 Set region with: --region us-west-2 or in config file\n") 64 | } else if strings.Contains(errMsg, "mode") { 65 | fmt.Printf(" 💡 Valid modes: test, normal, performance\n") 66 | } else if strings.Contains(errMsg, "stack") { 67 | fmt.Printf(" 💡 Stack names must be 1-128 chars, letters/numbers/hyphens only\n") 68 | } 69 | } 70 | fmt.Printf("\n💡 Generate a sample config file with: lambda-nat-proxy config init\n") 71 | return fmt.Errorf("please fix the configuration issues above") 72 | } 73 | 74 | dryRun, _ := cmd.Flags().GetBool("dry-run") 75 | if dryRun { 76 | return runDeployDryRun(cfg) 77 | } 78 | 79 | log.Printf("Starting deployment in %s mode...", cfg.Deployment.Mode) 80 | log.Printf("AWS Region: %s", cfg.AWS.Region) 81 | log.Printf("Stack: %s", cfg.Deployment.StackName) 82 | 83 | // Create AWS clients 84 | clientFactory, err := awsclients.NewClientFactory(cfg) 85 | if err != nil { 86 | return fmt.Errorf("failed to create AWS clients: %w", err) 87 | } 88 | 89 | // Validate AWS credentials 90 | if err := clientFactory.ValidateCredentials(ctx); err != nil { 91 | return fmt.Errorf("invalid AWS credentials: %w", err) 92 | } 93 | 94 | clients := clientFactory.GetClients() 95 | 96 | // Step 1: Deploy CloudFormation stack 97 | log.Printf("Step 1/3: Deploying CloudFormation stack...") 98 | stackDeployer := deploy.NewStackDeployer(clients, cfg) 99 | 100 | template, err := deploy.GetCloudFormationTemplate(cfg, "") 101 | if err != nil { 102 | return fmt.Errorf("failed to get CloudFormation template: %w", err) 103 | } 104 | 105 | stackOutput, err := stackDeployer.DeployStack(ctx, template) 106 | if err != nil { 107 | return fmt.Errorf("failed to deploy stack: %w", err) 108 | } 109 | 110 | log.Printf("✅ Stack deployed successfully") 111 | log.Printf(" S3 Bucket: %s", stackOutput.CoordinationBucketName) 112 | 113 | // Step 2: Build and deploy Lambda function 114 | log.Printf("Step 2/3: Building and deploying Lambda function...") 115 | 116 | // Use embedded Lambda binary 117 | provider := &EmbeddedLambdaProvider{} 118 | builder := deploy.NewLambdaBuilderWithProvider(cfg, provider) 119 | buildResult, err := builder.BuildLambdaPackage("build", "lambda") 120 | if err != nil { 121 | return fmt.Errorf("failed to build Lambda package: %w", err) 122 | } 123 | 124 | if buildResult.CacheHit { 125 | log.Printf("✅ Using cached Lambda package (%d bytes)", buildResult.Size) 126 | } else { 127 | log.Printf("✅ Lambda package built in %v (%d bytes)", buildResult.BuildTime, buildResult.Size) 128 | } 129 | 130 | lambdaDeployer := deploy.NewLambdaDeployer(clients, cfg) 131 | lambdaResult, err := lambdaDeployer.DeployLambdaFunction(ctx, buildResult.ZipPath, stackOutput.LambdaExecutionRoleArn) 132 | if err != nil { 133 | return fmt.Errorf("failed to deploy Lambda function: %w", err) 134 | } 135 | 136 | log.Printf("✅ Lambda function deployed successfully") 137 | log.Printf(" Function: %s", lambdaResult.FunctionName) 138 | log.Printf(" Memory: %d MB", lambdaResult.MemorySize) 139 | log.Printf(" Timeout: %d seconds", lambdaResult.Timeout) 140 | 141 | // Step 3: Configure S3 triggers 142 | log.Printf("Step 3/3: Configuring S3 triggers...") 143 | 144 | triggerDeployer := deploy.NewTriggerDeployer(clients, cfg) 145 | if err := triggerDeployer.ConfigureS3Triggers(ctx, stackOutput.CoordinationBucketName, lambdaResult.FunctionArn); err != nil { 146 | return fmt.Errorf("failed to configure S3 triggers: %w", err) 147 | } 148 | 149 | log.Printf("✅ S3 triggers configured successfully") 150 | 151 | // Display deployment summary 152 | fmt.Println("\n🎉 Deployment completed successfully!") 153 | fmt.Printf("Stack Name: %s\n", stackOutput.StackName) 154 | fmt.Printf("Region: %s\n", cfg.AWS.Region) 155 | fmt.Printf("S3 Bucket: %s\n", stackOutput.CoordinationBucketName) 156 | fmt.Printf("Lambda Function: %s\n", lambdaResult.FunctionName) 157 | fmt.Printf("Performance Mode: %s\n", cfg.Deployment.Mode) 158 | fmt.Println("\nYou can now run the proxy with:") 159 | fmt.Printf(" lambda-nat-proxy run\n") 160 | 161 | return nil 162 | } 163 | 164 | func runDeployDryRun(cfg *config.CLIConfig) error { 165 | fmt.Println("🔍 Dry run - showing what would be deployed:") 166 | fmt.Printf("Stack Name: %s\n", cfg.Deployment.StackName) 167 | fmt.Printf("AWS Region: %s\n", cfg.AWS.Region) 168 | fmt.Printf("Performance Mode: %s\n", cfg.Deployment.Mode) 169 | 170 | modeConfig := config.GetModeConfigs()[cfg.Deployment.Mode] 171 | fmt.Printf("Lambda Memory: %d MB\n", modeConfig.LambdaMemory) 172 | fmt.Printf("Lambda Timeout: %d seconds\n", modeConfig.LambdaTimeout) 173 | fmt.Printf("Session TTL: %v\n", modeConfig.SessionTTL) 174 | 175 | fmt.Println("\nDeployment steps that would be performed:") 176 | fmt.Println("1. Deploy CloudFormation stack with S3 bucket and IAM roles") 177 | fmt.Println("2. Build Lambda deployment package") 178 | fmt.Println("3. Deploy Lambda function with performance mode settings") 179 | fmt.Println("4. Configure S3 bucket notifications to trigger Lambda") 180 | 181 | fmt.Println("\nTo perform actual deployment, run without --dry-run flag") 182 | 183 | return nil 184 | } 185 | 186 | func init() { 187 | // Add deploy-specific flags 188 | deployCmd.Flags().StringP("mode", "m", "normal", "Performance mode (test, normal, performance)") 189 | deployCmd.Flags().StringP("region", "r", "", "AWS region (overrides config)") 190 | deployCmd.Flags().StringP("stack-name", "s", "", "CloudFormation stack name") 191 | deployCmd.Flags().BoolP("dry-run", "", false, "Show what would be deployed without actually deploying") 192 | } -------------------------------------------------------------------------------- /cmd/lambda-nat-proxy/embedded.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | // Lambda function binary embedded at build time 8 | //go:embed assets/bootstrap 9 | var embeddedLambdaBinary []byte 10 | 11 | // EmbeddedLambdaProvider implements LambdaBinaryProvider 12 | type EmbeddedLambdaProvider struct{} 13 | 14 | // GetLambdaBinary returns the embedded Lambda function binary 15 | func (p *EmbeddedLambdaProvider) GetLambdaBinary() []byte { 16 | return embeddedLambdaBinary 17 | } -------------------------------------------------------------------------------- /cmd/lambda-nat-proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | ) 7 | 8 | func main() { 9 | // Always use CLI mode 10 | if err := executeCliCommand(); err != nil { 11 | // Provide more context-specific error messages 12 | errMsg := err.Error() 13 | if strings.Contains(errMsg, "AWS credentials") || strings.Contains(errMsg, "credentials") { 14 | log.Fatalf("❌ AWS credentials error: %v\n\n🔧 Troubleshooting:\n- Run 'aws configure' to set up credentials\n- Set AWS_PROFILE environment variable\n- Ensure your AWS credentials have the necessary permissions", err) 15 | } else if strings.Contains(errMsg, "configuration") { 16 | log.Fatalf("❌ Configuration error: %v\n\n💡 Tip: Run 'lambda-nat-proxy config init' to create a sample configuration file", err) 17 | } else if strings.Contains(errMsg, "CloudFormation") || strings.Contains(errMsg, "stack") { 18 | log.Fatalf("❌ Infrastructure error: %v\n\n💡 Try: Run 'lambda-nat-proxy deploy' to set up infrastructure", err) 19 | } else if strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "network") { 20 | log.Fatalf("❌ Network error: %v\n\n🔧 Check your internet connection and firewall settings", err) 21 | } else { 22 | log.Fatalf("❌ Command failed: %v\n\n💡 For help, run: lambda-nat-proxy --help", err) 23 | } 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dan-v/lambda-nat-punch-proxy 2 | 3 | go 1.21.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/adrg/xdg v0.5.3 9 | github.com/aws/aws-sdk-go v1.44.300 10 | github.com/gorilla/websocket v1.5.3 11 | github.com/pion/stun v0.6.1 12 | github.com/quic-go/quic-go v0.40.1 13 | github.com/spf13/cobra v1.9.1 14 | github.com/spf13/viper v1.20.1 15 | gopkg.in/yaml.v2 v2.2.8 16 | gopkg.in/yaml.v3 v3.0.1 17 | ) 18 | 19 | require ( 20 | github.com/fsnotify/fsnotify v1.8.0 // indirect 21 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 22 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 23 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect 24 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 25 | github.com/jmespath/go-jmespath v0.4.0 // indirect 26 | github.com/onsi/ginkgo/v2 v2.9.5 // indirect 27 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 28 | github.com/pion/dtls/v2 v2.2.7 // indirect 29 | github.com/pion/logging v0.2.2 // indirect 30 | github.com/pion/transport/v2 v2.2.1 // indirect 31 | github.com/quic-go/qtls-go1-20 v0.4.1 // indirect 32 | github.com/sagikazarmark/locafero v0.7.0 // indirect 33 | github.com/sourcegraph/conc v0.3.0 // indirect 34 | github.com/spf13/afero v1.12.0 // indirect 35 | github.com/spf13/cast v1.7.1 // indirect 36 | github.com/spf13/pflag v1.0.6 // indirect 37 | github.com/subosito/gotenv v1.6.0 // indirect 38 | go.uber.org/atomic v1.9.0 // indirect 39 | go.uber.org/mock v0.3.0 // indirect 40 | go.uber.org/multierr v1.9.0 // indirect 41 | golang.org/x/crypto v0.32.0 // indirect 42 | golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect 43 | golang.org/x/mod v0.17.0 // indirect 44 | golang.org/x/net v0.33.0 // indirect 45 | golang.org/x/sys v0.29.0 // indirect 46 | golang.org/x/text v0.21.0 // indirect 47 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /internal/aws/clients_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/dan-v/lambda-nat-punch-proxy/internal/config" 9 | ) 10 | 11 | func TestNewClientFactory(t *testing.T) { 12 | cfg := &config.CLIConfig{ 13 | AWS: config.AWSConfig{ 14 | Region: "us-west-2", 15 | }, 16 | } 17 | 18 | factory, err := NewClientFactory(cfg) 19 | if err != nil { 20 | t.Fatalf("Expected no error creating client factory, got %v", err) 21 | } 22 | 23 | if factory == nil { 24 | t.Fatal("Expected client factory to be created") 25 | } 26 | 27 | if factory.session == nil { 28 | t.Fatal("Expected session to be created") 29 | } 30 | } 31 | 32 | func TestGetClients(t *testing.T) { 33 | cfg := &config.CLIConfig{ 34 | AWS: config.AWSConfig{ 35 | Region: "us-west-2", 36 | }, 37 | } 38 | 39 | factory, err := NewClientFactory(cfg) 40 | if err != nil { 41 | t.Fatalf("Failed to create client factory: %v", err) 42 | } 43 | 44 | clients := factory.GetClients() 45 | 46 | // Test that interfaces are properly created 47 | if clients.CloudFormation == nil { 48 | t.Error("Expected CloudFormation client to be created") 49 | } 50 | if clients.Lambda == nil { 51 | t.Error("Expected Lambda client to be created") 52 | } 53 | if clients.S3 == nil { 54 | t.Error("Expected S3 client to be created") 55 | } 56 | if clients.STS == nil { 57 | t.Error("Expected STS client to be created") 58 | } 59 | if clients.CloudWatchLogs == nil { 60 | t.Error("Expected CloudWatchLogs client to be created") 61 | } 62 | } 63 | 64 | func TestGetRegion(t *testing.T) { 65 | cfg := &config.CLIConfig{ 66 | AWS: config.AWSConfig{ 67 | Region: "eu-west-1", 68 | }, 69 | } 70 | 71 | factory, err := NewClientFactory(cfg) 72 | if err != nil { 73 | t.Fatalf("Failed to create client factory: %v", err) 74 | } 75 | 76 | region := factory.GetRegion() 77 | if region != "eu-west-1" { 78 | t.Errorf("Expected region eu-west-1, got %s", region) 79 | } 80 | } 81 | 82 | func TestWaitForOperation(t *testing.T) { 83 | ctx := context.Background() 84 | 85 | // Test successful operation 86 | called := false 87 | checkFn := func() (bool, error) { 88 | called = true 89 | return true, nil 90 | } 91 | 92 | err := WaitForOperation(ctx, checkFn, 1*time.Second) 93 | if err != nil { 94 | t.Errorf("Expected no error, got %v", err) 95 | } 96 | if !called { 97 | t.Error("Expected check function to be called") 98 | } 99 | } 100 | 101 | func TestWaitForOperationTimeout(t *testing.T) { 102 | ctx := context.Background() 103 | 104 | // Test timeout 105 | checkFn := func() (bool, error) { 106 | return false, nil // Never complete 107 | } 108 | 109 | err := WaitForOperation(ctx, checkFn, 10*time.Millisecond) 110 | if err == nil { 111 | t.Error("Expected timeout error") 112 | } 113 | } 114 | 115 | func TestWaitForOperationContextCancel(t *testing.T) { 116 | ctx, cancel := context.WithCancel(context.Background()) 117 | cancel() // Cancel immediately 118 | 119 | checkFn := func() (bool, error) { 120 | return false, nil 121 | } 122 | 123 | err := WaitForOperation(ctx, checkFn, 1*time.Second) 124 | if err != context.Canceled { 125 | t.Errorf("Expected context.Canceled, got %v", err) 126 | } 127 | } -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | 8 | "github.com/dan-v/lambda-nat-punch-proxy/pkg/shared" 9 | ) 10 | 11 | // PerformanceMode defines different operational modes 12 | type PerformanceMode string 13 | 14 | const ( 15 | ModeTest PerformanceMode = "test" // Fast testing, minimal resources 16 | ModeNormal PerformanceMode = "normal" // Balanced performance and cost 17 | ModePerformance PerformanceMode = "performance" // Maximum performance for streaming 18 | ) 19 | 20 | // ModeConfig holds all configuration for a specific performance mode 21 | type ModeConfig struct { 22 | Name string 23 | LambdaTimeout int // Lambda timeout in seconds 24 | LambdaMemory int // Lambda memory in MB 25 | SessionTTL time.Duration // Session time-to-live 26 | OverlapWindow time.Duration // Overlap window for rotation 27 | DrainTimeout time.Duration // Drain timeout 28 | BufferSize int // Data buffer size 29 | MaxStreams int // Maximum concurrent streams 30 | KeepAlive time.Duration // Connection keep-alive 31 | IdleTimeout time.Duration // Connection idle timeout 32 | } 33 | 34 | // RotationConfig holds session rotation configuration 35 | type RotationConfig struct { 36 | OverlapWindow time.Duration 37 | DrainTimeout time.Duration 38 | SessionTTL time.Duration 39 | } 40 | 41 | // Config holds all configuration for the orchestrator 42 | type Config struct { 43 | // AWS configuration 44 | AWSRegion string 45 | S3BucketName string 46 | 47 | // Network configuration 48 | STUNServer string 49 | SOCKS5Port int 50 | 51 | // Timeout configuration 52 | LambdaResponseTimeout time.Duration 53 | NATHolePunchTimeout time.Duration 54 | 55 | // Rotation configuration 56 | Rotation RotationConfig 57 | 58 | // Performance mode configuration 59 | Mode PerformanceMode 60 | ModeConfig ModeConfig 61 | } 62 | 63 | // GetModeConfigs returns predefined mode configurations 64 | func GetModeConfigs() map[PerformanceMode]ModeConfig { 65 | return map[PerformanceMode]ModeConfig{ 66 | ModeTest: { 67 | Name: "Test Mode", 68 | LambdaTimeout: 120, // 2 minutes - quick testing 69 | LambdaMemory: 128, // Minimal memory 70 | SessionTTL: 90 * time.Second, // 1.5 min sessions 71 | OverlapWindow: 30 * time.Second, // 30s overlap 72 | DrainTimeout: 15 * time.Second, // Quick drain 73 | BufferSize: 8 * 1024, // 8KB buffers 74 | MaxStreams: 100, // Limited streams 75 | KeepAlive: 10 * time.Second, // Short keep-alive 76 | IdleTimeout: 2 * time.Minute, // Short idle 77 | }, 78 | ModeNormal: { 79 | Name: "Normal Mode", 80 | LambdaTimeout: 600, // 10 minutes - balanced 81 | LambdaMemory: 256, // Balanced memory 82 | SessionTTL: 8 * time.Minute, // 8 min sessions 83 | OverlapWindow: 90 * time.Second, // 1.5 min overlap 84 | DrainTimeout: 45 * time.Second, // Moderate drain 85 | BufferSize: 32 * 1024, // 32KB buffers 86 | MaxStreams: 500, // Good stream count 87 | KeepAlive: 30 * time.Second, // Standard keep-alive 88 | IdleTimeout: 5 * time.Minute, // Standard idle 89 | }, 90 | ModePerformance: { 91 | Name: "Performance Mode", 92 | LambdaTimeout: 900, // 15 minutes - maximum 93 | LambdaMemory: 512, // High memory 94 | SessionTTL: 12 * time.Minute, // 12 min sessions 95 | OverlapWindow: 2 * time.Minute, // 2 min overlap 96 | DrainTimeout: 60 * time.Second, // Full drain time 97 | BufferSize: 64 * 1024, // 64KB buffers 98 | MaxStreams: 1000, // Maximum streams 99 | KeepAlive: 30 * time.Second, // Optimal keep-alive 100 | IdleTimeout: 5 * time.Minute, // Optimal idle 101 | }, 102 | } 103 | } 104 | 105 | // New creates a new configuration with defaults from environment variables 106 | func New() *Config { 107 | // Determine performance mode from environment 108 | modeStr := os.Getenv("MODE") 109 | if modeStr == "" { 110 | modeStr = "normal" // Default to normal mode 111 | } 112 | 113 | mode := PerformanceMode(modeStr) 114 | modeConfigs := GetModeConfigs() 115 | 116 | // Validate mode 117 | modeConfig, exists := modeConfigs[mode] 118 | if !exists { 119 | log.Printf("⚠️ Invalid mode '%s', using 'normal' mode", modeStr) 120 | mode = ModeNormal 121 | modeConfig = modeConfigs[ModeNormal] 122 | } 123 | 124 | log.Printf("🚀 %s: %s", modeConfig.Name, getModeDescription(mode)) 125 | 126 | config := &Config{ 127 | // Set defaults 128 | AWSRegion: shared.DefaultAWSRegion, 129 | STUNServer: shared.DefaultSTUNServer, 130 | SOCKS5Port: shared.DefaultSOCKS5Port, 131 | LambdaResponseTimeout: shared.DefaultLambdaResponseTimeout, 132 | NATHolePunchTimeout: shared.DefaultNATHolePunchTimeout, 133 | 134 | // Apply mode configuration 135 | Mode: mode, 136 | ModeConfig: modeConfig, 137 | Rotation: RotationConfig{ 138 | OverlapWindow: modeConfig.OverlapWindow, 139 | DrainTimeout: modeConfig.DrainTimeout, 140 | SessionTTL: modeConfig.SessionTTL, 141 | }, 142 | } 143 | 144 | // Override with environment variables 145 | config.S3BucketName = os.Getenv("AWS_S3_BUCKET") 146 | 147 | if region := os.Getenv("AWS_REGION"); region != "" { 148 | config.AWSRegion = region 149 | } 150 | 151 | return config 152 | } 153 | 154 | // getModeDescription returns a description for the given mode 155 | func getModeDescription(mode PerformanceMode) string { 156 | switch mode { 157 | case ModeTest: 158 | return "Fast testing with minimal resources (2min Lambda, 128MB)" 159 | case ModeNormal: 160 | return "Balanced performance and cost (10min Lambda, 256MB)" 161 | case ModePerformance: 162 | return "Maximum performance for streaming (15min Lambda, 512MB)" 163 | default: 164 | return "Unknown mode" 165 | } 166 | } -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestRotationDefaults(t *testing.T) { 12 | cfg := New() 13 | 14 | expectedOverlap := 90 * time.Second 15 | expectedDrain := 45 * time.Second // Updated to match current normal mode config 16 | 17 | if cfg.Rotation.OverlapWindow != expectedOverlap { 18 | t.Errorf("Expected OverlapWindow %v, got %v", expectedOverlap, cfg.Rotation.OverlapWindow) 19 | } 20 | 21 | if cfg.Rotation.DrainTimeout != expectedDrain { 22 | t.Errorf("Expected DrainTimeout %v, got %v", expectedDrain, cfg.Rotation.DrainTimeout) 23 | } 24 | } 25 | 26 | func TestDefaultCLIConfig(t *testing.T) { 27 | cfg := DefaultCLIConfig() 28 | 29 | // Test AWS defaults 30 | if cfg.AWS.Region != "us-west-2" { 31 | t.Errorf("Expected default region us-west-2, got %s", cfg.AWS.Region) 32 | } 33 | 34 | // Test deployment defaults 35 | if !strings.HasPrefix(cfg.Deployment.StackName, "lambda-nat-proxy-") { 36 | t.Errorf("Expected default stack name to start with lambda-nat-proxy-, got %s", cfg.Deployment.StackName) 37 | } 38 | if cfg.Deployment.Mode != ModeNormal { 39 | t.Errorf("Expected default mode normal, got %s", cfg.Deployment.Mode) 40 | } 41 | 42 | // Test proxy defaults 43 | if cfg.Proxy.Port != 1080 { 44 | t.Errorf("Expected default port 1080, got %d", cfg.Proxy.Port) 45 | } 46 | if cfg.Proxy.STUNServer == "" { 47 | t.Error("Expected STUN server to be set by default") 48 | } 49 | } 50 | 51 | func TestLoadCLIConfig(t *testing.T) { 52 | // Test loading with no config file (should use defaults) 53 | cfg, err := LoadCLIConfig("") 54 | if err != nil { 55 | t.Fatalf("Expected no error loading default config, got %v", err) 56 | } 57 | 58 | // Check that defaults are loaded 59 | if cfg.AWS.Region != "us-west-2" { 60 | t.Errorf("Expected default region us-west-2, got %s", cfg.AWS.Region) 61 | } 62 | if cfg.Proxy.Port != 1080 { 63 | t.Errorf("Expected default port 1080, got %d", cfg.Proxy.Port) 64 | } 65 | } 66 | 67 | func TestLoadCLIConfigWithFile(t *testing.T) { 68 | // Create a temporary config file 69 | tempDir := t.TempDir() 70 | configFile := filepath.Join(tempDir, "test-config.yaml") 71 | 72 | configContent := `aws: 73 | region: "us-east-1" 74 | profile: "test-profile" 75 | deployment: 76 | stack_name: "test-stack" 77 | mode: "performance" 78 | proxy: 79 | port: 9090 80 | ` 81 | 82 | if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { 83 | t.Fatalf("Failed to create test config file: %v", err) 84 | } 85 | 86 | // Load config with specific file 87 | cfg, err := LoadCLIConfig(configFile) 88 | if err != nil { 89 | t.Fatalf("Expected no error loading config file, got %v", err) 90 | } 91 | 92 | // Verify custom values were loaded 93 | if cfg.AWS.Region != "us-east-1" { 94 | t.Errorf("Expected region us-east-1, got %s", cfg.AWS.Region) 95 | } 96 | if cfg.AWS.Profile != "test-profile" { 97 | t.Errorf("Expected profile test-profile, got %s", cfg.AWS.Profile) 98 | } 99 | if cfg.Deployment.StackName != "test-stack" { 100 | t.Errorf("Expected stack name test-stack, got %s", cfg.Deployment.StackName) 101 | } 102 | if cfg.Deployment.Mode != ModePerformance { 103 | t.Errorf("Expected mode performance, got %s", cfg.Deployment.Mode) 104 | } 105 | if cfg.Proxy.Port != 9090 { 106 | t.Errorf("Expected port 9090, got %d", cfg.Proxy.Port) 107 | } 108 | } 109 | 110 | func TestValidateCLIConfig(t *testing.T) { 111 | // Test valid config 112 | validCfg := &CLIConfig{ 113 | AWS: AWSConfig{ 114 | Region: "us-west-2", 115 | }, 116 | Deployment: DeploymentConfig{ 117 | StackName: "test-stack", 118 | Mode: ModeNormal, 119 | }, 120 | Proxy: ProxyConfig{ 121 | Port: 8080, 122 | STUNServer: "stun.l.google.com:19302", 123 | }, 124 | } 125 | 126 | if err := ValidateCLIConfig(validCfg); err != nil { 127 | t.Errorf("Expected no error for valid config, got %v", err) 128 | } 129 | 130 | // Test invalid config - empty region 131 | invalidCfg := &CLIConfig{ 132 | AWS: AWSConfig{ 133 | Region: "", 134 | }, 135 | Deployment: DeploymentConfig{ 136 | StackName: "test-stack", 137 | Mode: ModeNormal, 138 | }, 139 | Proxy: ProxyConfig{ 140 | Port: 8080, 141 | STUNServer: "stun.l.google.com:19302", 142 | }, 143 | } 144 | 145 | if err := ValidateCLIConfig(invalidCfg); err == nil { 146 | t.Error("Expected error for config with empty region") 147 | } 148 | 149 | // Test invalid mode 150 | invalidModeCfg := &CLIConfig{ 151 | AWS: AWSConfig{ 152 | Region: "us-west-2", 153 | }, 154 | Deployment: DeploymentConfig{ 155 | StackName: "test-stack", 156 | Mode: "invalid", 157 | }, 158 | Proxy: ProxyConfig{ 159 | Port: 8080, 160 | STUNServer: "stun.l.google.com:19302", 161 | }, 162 | } 163 | 164 | if err := ValidateCLIConfig(invalidModeCfg); err == nil { 165 | t.Error("Expected error for config with invalid mode") 166 | } 167 | } -------------------------------------------------------------------------------- /internal/config/defaults.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "strings" 7 | 8 | "github.com/dan-v/lambda-nat-punch-proxy/pkg/shared" 9 | ) 10 | 11 | // DefaultCLIConfig returns a CLIConfig with all default values 12 | func DefaultCLIConfig() *CLIConfig { 13 | return &CLIConfig{ 14 | AWS: AWSConfig{ 15 | Region: shared.DefaultAWSRegion, 16 | Profile: "", // Use default AWS credential chain 17 | }, 18 | Deployment: DeploymentConfig{ 19 | StackName: generateDefaultStackName(), 20 | Mode: ModeNormal, 21 | }, 22 | Proxy: ProxyConfig{ 23 | Port: shared.DefaultSOCKS5Port, 24 | STUNServer: shared.DefaultSTUNServer, 25 | }, 26 | } 27 | } 28 | 29 | // generateDefaultStackName creates a unique stack name with a random suffix 30 | func generateDefaultStackName() string { 31 | // Generate 4 random bytes for an 8-character hex suffix 32 | bytes := make([]byte, 4) 33 | if _, err := rand.Read(bytes); err != nil { 34 | // Fallback to simple default if random generation fails 35 | return "lambda-nat-proxy" 36 | } 37 | suffix := hex.EncodeToString(bytes) 38 | return "lambda-nat-proxy-" + suffix 39 | } 40 | 41 | // ValidateCLIConfig validates a CLIConfig and returns any errors 42 | func ValidateCLIConfig(cfg *CLIConfig) []error { 43 | var errors []error 44 | 45 | // Validate AWS region 46 | if cfg.AWS.Region == "" { 47 | errors = append(errors, &ConfigError{ 48 | Field: "aws.region", 49 | Value: cfg.AWS.Region, 50 | Message: "AWS region cannot be empty", 51 | }) 52 | } else { 53 | // Validate AWS region format (basic check) 54 | validRegions := []string{ 55 | "us-east-1", "us-east-2", "us-west-1", "us-west-2", 56 | "eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", 57 | "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", 58 | "ca-central-1", "sa-east-1", "ap-south-1", 59 | } 60 | validRegion := false 61 | for _, region := range validRegions { 62 | if cfg.AWS.Region == region { 63 | validRegion = true 64 | break 65 | } 66 | } 67 | if !validRegion { 68 | errors = append(errors, &ConfigError{ 69 | Field: "aws.region", 70 | Value: cfg.AWS.Region, 71 | Message: "invalid AWS region format", 72 | }) 73 | } 74 | } 75 | 76 | // Validate deployment mode 77 | validModes := []PerformanceMode{ModeTest, ModeNormal, ModePerformance} 78 | validMode := false 79 | for _, mode := range validModes { 80 | if cfg.Deployment.Mode == mode { 81 | validMode = true 82 | break 83 | } 84 | } 85 | if !validMode { 86 | errors = append(errors, &ConfigError{ 87 | Field: "deployment.mode", 88 | Value: string(cfg.Deployment.Mode), 89 | Message: "mode must be one of: test, normal, performance", 90 | }) 91 | } 92 | 93 | // Validate proxy port with additional constraints 94 | if cfg.Proxy.Port < 1 || cfg.Proxy.Port > 65535 { 95 | errors = append(errors, &ConfigError{ 96 | Field: "proxy.port", 97 | Value: cfg.Proxy.Port, 98 | Message: "port must be between 1 and 65535", 99 | }) 100 | } else if cfg.Proxy.Port < 1024 { 101 | // Warn about privileged ports 102 | errors = append(errors, &ConfigError{ 103 | Field: "proxy.port", 104 | Value: cfg.Proxy.Port, 105 | Message: "ports below 1024 require root privileges", 106 | }) 107 | } 108 | 109 | // Validate STUN server 110 | if cfg.Proxy.STUNServer == "" { 111 | errors = append(errors, &ConfigError{ 112 | Field: "proxy.stun_server", 113 | Value: cfg.Proxy.STUNServer, 114 | Message: "STUN server cannot be empty", 115 | }) 116 | } else { 117 | // Validate STUN server format (should be host:port) 118 | if !strings.Contains(cfg.Proxy.STUNServer, ":") { 119 | errors = append(errors, &ConfigError{ 120 | Field: "proxy.stun_server", 121 | Value: cfg.Proxy.STUNServer, 122 | Message: "STUN server must be in format host:port", 123 | }) 124 | } 125 | } 126 | 127 | // Validate stack name 128 | if cfg.Deployment.StackName == "" { 129 | errors = append(errors, &ConfigError{ 130 | Field: "deployment.stack_name", 131 | Value: cfg.Deployment.StackName, 132 | Message: "stack name cannot be empty", 133 | }) 134 | } else { 135 | // Validate stack name constraints per CloudFormation requirements 136 | if len(cfg.Deployment.StackName) > 128 { 137 | errors = append(errors, &ConfigError{ 138 | Field: "deployment.stack_name", 139 | Value: cfg.Deployment.StackName, 140 | Message: "stack name must be 128 characters or less", 141 | }) 142 | } 143 | // Check for invalid characters (CloudFormation only allows alphanumeric and hyphens) 144 | for _, char := range cfg.Deployment.StackName { 145 | if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || 146 | (char >= '0' && char <= '9') || char == '-') { 147 | errors = append(errors, &ConfigError{ 148 | Field: "deployment.stack_name", 149 | Value: cfg.Deployment.StackName, 150 | Message: "stack name can only contain letters, numbers, and hyphens", 151 | }) 152 | break 153 | } 154 | } 155 | // Stack name cannot start or end with hyphen 156 | if strings.HasPrefix(cfg.Deployment.StackName, "-") || strings.HasSuffix(cfg.Deployment.StackName, "-") { 157 | errors = append(errors, &ConfigError{ 158 | Field: "deployment.stack_name", 159 | Value: cfg.Deployment.StackName, 160 | Message: "stack name cannot start or end with a hyphen", 161 | }) 162 | } 163 | } 164 | 165 | // S3 bucket name is auto-detected from CloudFormation stack 166 | 167 | return errors 168 | } 169 | 170 | // ConfigError represents a configuration validation error 171 | type ConfigError struct { 172 | Field string 173 | Value interface{} 174 | Message string 175 | } 176 | 177 | func (e *ConfigError) Error() string { 178 | return e.Message 179 | } 180 | 181 | // GetDefaultStackName returns the default stack name 182 | func GetDefaultStackName() string { 183 | return generateDefaultStackName() 184 | } 185 | 186 | // GetDefaultBucketName returns the default S3 bucket name based on stack name and account ID 187 | func GetDefaultBucketName(stackName, accountID string) string { 188 | return stackName + "-coordination-" + accountID 189 | } -------------------------------------------------------------------------------- /internal/config/loader.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/adrg/xdg" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | // LoadCLIConfig loads configuration from files, environment, and returns a merged config 13 | func LoadCLIConfig(configPath string) (*CLIConfig, error) { 14 | cfg := DefaultCLIConfig() 15 | 16 | // Initialize viper 17 | v := viper.New() 18 | v.SetConfigType("yaml") 19 | 20 | // Determine config file to use 21 | var foundConfig bool 22 | if configPath != "" { 23 | // Use specific config file path if provided 24 | v.SetConfigFile(configPath) 25 | foundConfig = true 26 | } else { 27 | // Search for specific config files (not just any file named lambda-nat-proxy) 28 | configFiles := []string{ 29 | "./lambda-nat-proxy.yaml", // Current directory 30 | "./lambda-nat-proxy.yml", // Current directory (alt extension) 31 | filepath.Join(xdg.ConfigHome, "lambda-nat-proxy", "lambda-nat-proxy.yaml"), // User config 32 | filepath.Join(xdg.ConfigHome, "lambda-nat-proxy", "lambda-nat-proxy.yml"), // User config (alt) 33 | "/etc/lambda-nat-proxy/lambda-nat-proxy.yaml", // System directory 34 | "/etc/lambda-nat-proxy/lambda-nat-proxy.yml", // System directory (alt) 35 | } 36 | 37 | // Also check XDG config dirs 38 | for _, dir := range xdg.ConfigDirs { 39 | configFiles = append(configFiles, 40 | filepath.Join(dir, "lambda-nat-proxy", "lambda-nat-proxy.yaml"), 41 | filepath.Join(dir, "lambda-nat-proxy", "lambda-nat-proxy.yml"), 42 | ) 43 | } 44 | 45 | // Find the first existing config file 46 | for _, configFile := range configFiles { 47 | if _, err := os.Stat(configFile); err == nil { 48 | v.SetConfigFile(configFile) 49 | foundConfig = true 50 | break 51 | } 52 | } 53 | } 54 | 55 | // Try to read config file (only if one was found) 56 | if foundConfig { 57 | if err := v.ReadInConfig(); err != nil { 58 | return nil, fmt.Errorf("error reading config file: %w", err) 59 | } 60 | } 61 | 62 | // Set environment variable prefix 63 | v.SetEnvPrefix("LAMBDA_PROXY") 64 | v.AutomaticEnv() 65 | 66 | // Map environment variables to config keys 67 | v.BindEnv("aws.region", "AWS_REGION") 68 | v.BindEnv("aws.profile", "AWS_PROFILE") 69 | v.BindEnv("deployment.mode", "MODE") 70 | v.BindEnv("proxy.port", "SOCKS5_PORT") 71 | 72 | // Unmarshal into our config struct 73 | if err := v.Unmarshal(cfg); err != nil { 74 | return nil, fmt.Errorf("error unmarshaling config: %w", err) 75 | } 76 | 77 | return cfg, nil 78 | } 79 | 80 | // WriteExampleConfig creates an example configuration file 81 | func WriteExampleConfig(filePath string) error { 82 | exampleConfig := `# Lambda NAT Proxy Configuration File 83 | # This file contains all available configuration options with their default values 84 | 85 | # AWS Configuration 86 | aws: 87 | region: "us-west-2" # AWS region to use 88 | profile: "" # AWS profile (leave empty for default credential chain) 89 | 90 | # Deployment Configuration 91 | deployment: 92 | stack_name: "lambda-nat-proxy-a1b2c3d4" # CloudFormation stack name (unique suffix auto-generated) 93 | mode: "normal" # Performance mode: test, normal, performance 94 | 95 | # Proxy Configuration 96 | proxy: 97 | port: 1080 # SOCKS5 proxy port (standard SOCKS port) 98 | stun_server: "stun.l.google.com:19302" # STUN server for NAT traversal 99 | ` 100 | 101 | // Create directory if it doesn't exist 102 | dir := filepath.Dir(filePath) 103 | if err := os.MkdirAll(dir, 0755); err != nil { 104 | return fmt.Errorf("failed to create directory %s: %w", dir, err) 105 | } 106 | 107 | // Write the example config 108 | if err := os.WriteFile(filePath, []byte(exampleConfig), 0644); err != nil { 109 | return fmt.Errorf("failed to write config file %s: %w", filePath, err) 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // FindConfigFile searches for a config file in XDG-compliant locations 116 | func FindConfigFile() (string, error) { 117 | searchPaths := []string{ 118 | "lambda-nat-proxy.yaml", 119 | "lambda-nat-proxy.yml", 120 | filepath.Join(xdg.ConfigHome, "lambda-nat-proxy", "lambda-nat-proxy.yaml"), 121 | filepath.Join(xdg.ConfigHome, "lambda-nat-proxy", "lambda-nat-proxy.yml"), 122 | "/etc/lambda-nat-proxy/lambda-nat-proxy.yaml", 123 | "/etc/lambda-nat-proxy/lambda-nat-proxy.yml", 124 | } 125 | 126 | // Also check XDG config dirs 127 | for _, dir := range xdg.ConfigDirs { 128 | searchPaths = append(searchPaths, 129 | filepath.Join(dir, "lambda-nat-proxy", "lambda-nat-proxy.yaml"), 130 | filepath.Join(dir, "lambda-nat-proxy", "lambda-nat-proxy.yml"), 131 | ) 132 | } 133 | 134 | for _, path := range searchPaths { 135 | if _, err := os.Stat(path); err == nil { 136 | return path, nil 137 | } 138 | } 139 | 140 | return "", fmt.Errorf("no config file found in standard locations") 141 | } 142 | 143 | // GetDefaultConfigPath returns the default path for creating a new config file 144 | func GetDefaultConfigPath() string { 145 | return filepath.Join(xdg.ConfigHome, "lambda-nat-proxy", "lambda-nat-proxy.yaml") 146 | } 147 | 148 | // GetConfigSource returns information about where config values came from 149 | func GetConfigSource(v *viper.Viper, key string) string { 150 | // Check if value was set via command line flag 151 | if v.IsSet(key) { 152 | // This is a simplified check - in reality you'd need to track 153 | // the source more precisely 154 | return "file/env" 155 | } 156 | return "default" 157 | } -------------------------------------------------------------------------------- /internal/config/types.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // CLIConfig represents the complete configuration for lambda-nat-proxy CLI 8 | type CLIConfig struct { 9 | // AWS configuration 10 | AWS AWSConfig `yaml:"aws" json:"aws"` 11 | 12 | // Deployment configuration 13 | Deployment DeploymentConfig `yaml:"deployment" json:"deployment"` 14 | 15 | // Proxy configuration 16 | Proxy ProxyConfig `yaml:"proxy" json:"proxy"` 17 | } 18 | 19 | // AWSConfig holds AWS-specific settings 20 | type AWSConfig struct { 21 | Region string `yaml:"region" json:"region" mapstructure:"region"` 22 | Profile string `yaml:"profile" json:"profile" mapstructure:"profile"` 23 | } 24 | 25 | // DeploymentConfig holds deployment settings 26 | type DeploymentConfig struct { 27 | StackName string `yaml:"stack_name" json:"stack_name" mapstructure:"stack_name"` 28 | Mode PerformanceMode `yaml:"mode" json:"mode" mapstructure:"mode"` 29 | } 30 | 31 | // ProxyConfig holds proxy settings 32 | type ProxyConfig struct { 33 | Port int `yaml:"port" json:"port" mapstructure:"port"` 34 | STUNServer string `yaml:"stun_server" json:"stun_server" mapstructure:"stun_server"` 35 | } 36 | 37 | 38 | // Merge merges another CLIConfig into this one, with the other taking precedence 39 | func (c *CLIConfig) Merge(other *CLIConfig) { 40 | if other.AWS.Region != "" { 41 | c.AWS.Region = other.AWS.Region 42 | } 43 | if other.AWS.Profile != "" { 44 | c.AWS.Profile = other.AWS.Profile 45 | } 46 | 47 | if other.Deployment.StackName != "" { 48 | c.Deployment.StackName = other.Deployment.StackName 49 | } 50 | if other.Deployment.Mode != "" { 51 | c.Deployment.Mode = other.Deployment.Mode 52 | } 53 | 54 | if other.Proxy.Port != 0 { 55 | c.Proxy.Port = other.Proxy.Port 56 | } 57 | if other.Proxy.STUNServer != "" { 58 | c.Proxy.STUNServer = other.Proxy.STUNServer 59 | } 60 | } 61 | 62 | // ToLegacyConfig converts CLIConfig to the legacy Config format 63 | // The S3 bucket name should be passed separately since it's auto-detected 64 | func (c *CLIConfig) ToLegacyConfig(s3BucketName string) *Config { 65 | // Get mode configuration 66 | modeConfigs := GetModeConfigs() 67 | modeConfig := modeConfigs[c.Deployment.Mode] 68 | 69 | return &Config{ 70 | AWSRegion: c.AWS.Region, 71 | S3BucketName: s3BucketName, 72 | STUNServer: c.Proxy.STUNServer, 73 | SOCKS5Port: c.Proxy.Port, 74 | LambdaResponseTimeout: 30 * time.Second, // Keep existing defaults 75 | NATHolePunchTimeout: 30 * time.Second, 76 | Rotation: RotationConfig{ 77 | OverlapWindow: modeConfig.OverlapWindow, 78 | DrainTimeout: modeConfig.DrainTimeout, 79 | SessionTTL: modeConfig.SessionTTL, 80 | }, 81 | Mode: c.Deployment.Mode, 82 | ModeConfig: modeConfig, 83 | } 84 | } -------------------------------------------------------------------------------- /internal/dashboard/api.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "sync" 7 | "time" 8 | 9 | "github.com/gorilla/websocket" 10 | "github.com/dan-v/lambda-nat-punch-proxy/internal/manager" 11 | "github.com/dan-v/lambda-nat-punch-proxy/pkg/shared" 12 | ) 13 | 14 | // DashboardServer serves the dashboard API and static files 15 | type DashboardServer struct { 16 | collector *DashboardCollector 17 | mux *http.ServeMux 18 | upgrader websocket.Upgrader 19 | clients map[*websocket.Conn]bool 20 | clientsMu sync.RWMutex 21 | broadcast chan []byte 22 | shutdown chan struct{} 23 | } 24 | 25 | // NewDashboardServer creates a new dashboard server 26 | func NewDashboardServer(cm *manager.ConnManager) *DashboardServer { 27 | server := &DashboardServer{ 28 | collector: NewDashboardCollector(cm), 29 | mux: http.NewServeMux(), 30 | upgrader: websocket.Upgrader{ 31 | CheckOrigin: func(r *http.Request) bool { 32 | return true // Allow all origins for development 33 | }, 34 | }, 35 | clients: make(map[*websocket.Conn]bool), 36 | broadcast: make(chan []byte), 37 | shutdown: make(chan struct{}), 38 | } 39 | 40 | server.setupRoutes() 41 | server.startBroadcaster() 42 | return server 43 | } 44 | 45 | // setupRoutes configures all API routes 46 | func (ds *DashboardServer) setupRoutes() { 47 | // API endpoints 48 | ds.mux.HandleFunc("/api/dashboard", ds.handleDashboardData) 49 | ds.mux.HandleFunc("/api/connections", ds.handleConnections) 50 | ds.mux.HandleFunc("/api/sessions", ds.handleSessions) 51 | ds.mux.HandleFunc("/api/destinations", ds.handleDestinations) 52 | ds.mux.HandleFunc("/ws", ds.handleWebSocket) 53 | 54 | // Static files - we'll serve our React app here 55 | ds.mux.HandleFunc("/", ds.handleStaticFiles) 56 | } 57 | 58 | // ServeHTTP implements the http.Handler interface 59 | func (ds *DashboardServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 60 | // Add CORS headers for development 61 | w.Header().Set("Access-Control-Allow-Origin", "*") 62 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") 63 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type") 64 | 65 | if r.Method == "OPTIONS" { 66 | w.WriteHeader(http.StatusOK) 67 | return 68 | } 69 | 70 | ds.mux.ServeHTTP(w, r) 71 | } 72 | 73 | // handleDashboardData serves the complete dashboard data 74 | func (ds *DashboardServer) handleDashboardData(w http.ResponseWriter, r *http.Request) { 75 | if r.Method != "GET" { 76 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 77 | return 78 | } 79 | 80 | data := ds.collector.CollectDashboardData() 81 | 82 | w.Header().Set("Content-Type", "application/json") 83 | if err := json.NewEncoder(w).Encode(data); err != nil { 84 | shared.LogErrorf("Failed to encode dashboard data: %v", err) 85 | http.Error(w, "Internal server error", http.StatusInternalServerError) 86 | return 87 | } 88 | } 89 | 90 | // handleConnections serves just the connections data (lighter endpoint) 91 | func (ds *DashboardServer) handleConnections(w http.ResponseWriter, r *http.Request) { 92 | if r.Method != "GET" { 93 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 94 | return 95 | } 96 | 97 | connections := GlobalConnectionTracker.GetActiveConnections() 98 | 99 | w.Header().Set("Content-Type", "application/json") 100 | if err := json.NewEncoder(w).Encode(connections); err != nil { 101 | shared.LogErrorf("Failed to encode connections data: %v", err) 102 | http.Error(w, "Internal server error", http.StatusInternalServerError) 103 | return 104 | } 105 | } 106 | 107 | // handleSessions serves session information 108 | func (ds *DashboardServer) handleSessions(w http.ResponseWriter, r *http.Request) { 109 | if r.Method != "GET" { 110 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 111 | return 112 | } 113 | 114 | sessions := ds.collector.collectSessionInfo() 115 | 116 | w.Header().Set("Content-Type", "application/json") 117 | if err := json.NewEncoder(w).Encode(sessions); err != nil { 118 | shared.LogErrorf("Failed to encode sessions data: %v", err) 119 | http.Error(w, "Internal server error", http.StatusInternalServerError) 120 | return 121 | } 122 | } 123 | 124 | // handleDestinations serves destination statistics 125 | func (ds *DashboardServer) handleDestinations(w http.ResponseWriter, r *http.Request) { 126 | if r.Method != "GET" { 127 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 128 | return 129 | } 130 | 131 | connections := GlobalConnectionTracker.GetActiveConnections() 132 | destinations := ds.collector.calculateDestinationStats(connections) 133 | 134 | w.Header().Set("Content-Type", "application/json") 135 | if err := json.NewEncoder(w).Encode(destinations); err != nil { 136 | shared.LogErrorf("Failed to encode destinations data: %v", err) 137 | http.Error(w, "Internal server error", http.StatusInternalServerError) 138 | return 139 | } 140 | } 141 | 142 | // handleWebSocket handles WebSocket connections for real-time updates 143 | func (ds *DashboardServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { 144 | conn, err := ds.upgrader.Upgrade(w, r, nil) 145 | if err != nil { 146 | shared.LogErrorf("Failed to upgrade WebSocket connection: %v", err) 147 | return 148 | } 149 | 150 | ds.clientsMu.Lock() 151 | ds.clients[conn] = true 152 | ds.clientsMu.Unlock() 153 | 154 | shared.LogInfof("New WebSocket client connected, total clients: %d", len(ds.clients)) 155 | 156 | // Handle client disconnection 157 | defer func() { 158 | ds.clientsMu.Lock() 159 | delete(ds.clients, conn) 160 | ds.clientsMu.Unlock() 161 | conn.Close() 162 | shared.LogInfof("WebSocket client disconnected, remaining clients: %d", len(ds.clients)) 163 | }() 164 | 165 | // Send initial dashboard data 166 | data := ds.collector.CollectDashboardData() 167 | if jsonData, err := json.Marshal(data); err == nil { 168 | conn.WriteMessage(websocket.TextMessage, jsonData) 169 | } 170 | 171 | // Keep connection alive and handle pings 172 | for { 173 | _, _, err := conn.ReadMessage() 174 | if err != nil { 175 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 176 | shared.LogErrorf("WebSocket error: %v", err) 177 | } 178 | break 179 | } 180 | } 181 | } 182 | 183 | // startBroadcaster starts the background broadcaster for WebSocket updates 184 | func (ds *DashboardServer) startBroadcaster() { 185 | // Start broadcaster goroutine 186 | go func() { 187 | for { 188 | select { 189 | case message := <-ds.broadcast: 190 | ds.clientsMu.RLock() 191 | for client := range ds.clients { 192 | err := client.WriteMessage(websocket.TextMessage, message) 193 | if err != nil { 194 | shared.LogErrorf("Failed to send WebSocket message: %v", err) 195 | client.Close() 196 | delete(ds.clients, client) 197 | } 198 | } 199 | ds.clientsMu.RUnlock() 200 | case <-ds.shutdown: 201 | shared.LogInfof("Dashboard broadcaster shutting down") 202 | return 203 | } 204 | } 205 | }() 206 | 207 | // Start periodic updates 208 | go func() { 209 | ticker := time.NewTicker(1 * time.Second) // Update every second 210 | defer ticker.Stop() 211 | 212 | for { 213 | select { 214 | case <-ticker.C: 215 | if len(ds.clients) > 0 { 216 | data := ds.collector.CollectDashboardData() 217 | if jsonData, err := json.Marshal(data); err == nil { 218 | select { 219 | case ds.broadcast <- jsonData: 220 | case <-ds.shutdown: 221 | return 222 | } 223 | } 224 | } 225 | case <-ds.shutdown: 226 | shared.LogInfof("Dashboard periodic updater shutting down") 227 | return 228 | } 229 | } 230 | }() 231 | } 232 | 233 | // Shutdown gracefully shuts down the dashboard server 234 | func (ds *DashboardServer) Shutdown() { 235 | close(ds.shutdown) 236 | 237 | // Close all WebSocket connections 238 | ds.clientsMu.Lock() 239 | for client := range ds.clients { 240 | client.Close() 241 | } 242 | ds.clients = make(map[*websocket.Conn]bool) 243 | ds.clientsMu.Unlock() 244 | 245 | shared.LogInfof("Dashboard server shutdown complete") 246 | } 247 | 248 | // StartDashboardServer starts the dashboard HTTP server (legacy function for compatibility) 249 | func StartDashboardServer(addr string, cm *manager.ConnManager) error { 250 | server := NewDashboardServer(cm) 251 | 252 | shared.LogInfof("Starting dashboard server on %s", addr) 253 | shared.LogInfof("Dashboard available at: http://localhost%s", addr) 254 | 255 | httpServer := &http.Server{ 256 | Addr: addr, 257 | Handler: server, 258 | ReadTimeout: 15 * time.Second, 259 | WriteTimeout: 15 * time.Second, 260 | } 261 | 262 | return httpServer.ListenAndServe() 263 | } -------------------------------------------------------------------------------- /internal/dashboard/api_dashboard.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io/fs" 7 | "net/http" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | //go:embed web/dist/* 13 | var webFiles embed.FS 14 | 15 | const DashboardEnabled = true 16 | 17 | // handleStaticFiles serves the React frontend 18 | func (ds *DashboardServer) handleStaticFiles(w http.ResponseWriter, r *http.Request) { 19 | // Get the requested path, default to index.html for root 20 | path := strings.TrimPrefix(r.URL.Path, "/") 21 | if path == "" { 22 | path = "index.html" 23 | } 24 | 25 | // For SPA routing, serve index.html for non-asset requests 26 | if !strings.Contains(path, ".") && !strings.HasPrefix(path, "api/") { 27 | path = "index.html" 28 | } 29 | 30 | // Construct file path for embedded filesystem 31 | filePath := filepath.Join("web/dist", path) 32 | 33 | // Set appropriate content type based on file extension 34 | ext := filepath.Ext(path) 35 | switch ext { 36 | case ".html": 37 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 38 | case ".js": 39 | w.Header().Set("Content-Type", "application/javascript") 40 | case ".css": 41 | w.Header().Set("Content-Type", "text/css") 42 | case ".png": 43 | w.Header().Set("Content-Type", "image/png") 44 | case ".jpg", ".jpeg": 45 | w.Header().Set("Content-Type", "image/jpeg") 46 | case ".svg": 47 | w.Header().Set("Content-Type", "image/svg+xml") 48 | } 49 | 50 | // Set cache headers for static assets 51 | if ext != ".html" { 52 | w.Header().Set("Cache-Control", "public, max-age=31536000") // 1 year 53 | } 54 | 55 | // Read and serve the file content 56 | content, err := fs.ReadFile(webFiles, filePath) 57 | if err != nil { 58 | content, err = fs.ReadFile(webFiles, "web/dist/index.html") 59 | if err != nil { 60 | http.Error(w, "File read error", http.StatusInternalServerError) 61 | return 62 | } 63 | } 64 | 65 | // Set content length 66 | w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) 67 | 68 | // Write the content 69 | w.Write(content) 70 | } -------------------------------------------------------------------------------- /internal/dashboard/connection_tracker.go: -------------------------------------------------------------------------------- 1 | package dashboard 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // TrackedConnection represents a monitored connection 10 | type TrackedConnection struct { 11 | ID string `json:"id"` 12 | ClientAddr string `json:"client"` 13 | Destination string `json:"destination"` 14 | StartTime time.Time `json:"start_time"` 15 | BytesIn int64 `json:"bytes_in"` 16 | BytesOut int64 `json:"bytes_out"` 17 | LastActivity time.Time `json:"last_activity"` 18 | Latency float64 `json:"latency_ms"` 19 | State string `json:"state"` // active, closing, error 20 | } 21 | 22 | // ConnectionTracker manages active connections for dashboard monitoring 23 | type ConnectionTracker struct { 24 | mu sync.RWMutex 25 | connections map[string]*TrackedConnection 26 | // Historical data for graphs (ring buffer) 27 | history *MetricHistory 28 | } 29 | 30 | // MetricHistory stores time-series data in a ring buffer 31 | type MetricHistory struct { 32 | mu sync.RWMutex 33 | timestamps []time.Time 34 | connCounts []int 35 | byteRates []float64 36 | latencies []float64 37 | maxPoints int 38 | writeIndex int 39 | } 40 | 41 | // NewConnectionTracker creates a new connection tracker 42 | func NewConnectionTracker() *ConnectionTracker { 43 | return &ConnectionTracker{ 44 | connections: make(map[string]*TrackedConnection), 45 | history: NewMetricHistory(300), // 5 minutes at 1 second intervals 46 | } 47 | } 48 | 49 | // NewMetricHistory creates a new metric history with specified capacity 50 | func NewMetricHistory(maxPoints int) *MetricHistory { 51 | return &MetricHistory{ 52 | timestamps: make([]time.Time, maxPoints), 53 | connCounts: make([]int, maxPoints), 54 | byteRates: make([]float64, maxPoints), 55 | latencies: make([]float64, maxPoints), 56 | maxPoints: maxPoints, 57 | } 58 | } 59 | 60 | // AddConnection registers a new connection 61 | func (ct *ConnectionTracker) AddConnection(id, clientAddr, destination string) { 62 | ct.mu.Lock() 63 | defer ct.mu.Unlock() 64 | 65 | ct.connections[id] = &TrackedConnection{ 66 | ID: id, 67 | ClientAddr: clientAddr, 68 | Destination: destination, 69 | StartTime: time.Now(), 70 | LastActivity: time.Now(), 71 | State: "active", 72 | } 73 | 74 | // Debug logging 75 | fmt.Printf("🔗 Dashboard: Added connection %s: %s -> %s (total: %d)\n", id, clientAddr, destination, len(ct.connections)) 76 | } 77 | 78 | // UpdateConnection updates connection metrics 79 | func (ct *ConnectionTracker) UpdateConnection(id string, bytesIn, bytesOut int64, latency float64) { 80 | ct.mu.Lock() 81 | defer ct.mu.Unlock() 82 | 83 | if conn, exists := ct.connections[id]; exists { 84 | conn.BytesIn += bytesIn 85 | conn.BytesOut += bytesOut 86 | conn.LastActivity = time.Now() 87 | if latency > 0 { 88 | conn.Latency = latency 89 | } 90 | } 91 | } 92 | 93 | // RemoveConnection removes a connection 94 | func (ct *ConnectionTracker) RemoveConnection(id string) { 95 | ct.mu.Lock() 96 | defer ct.mu.Unlock() 97 | 98 | if conn, exists := ct.connections[id]; exists { 99 | conn.State = "closing" 100 | fmt.Printf("🔚 Dashboard: Closing connection %s: %s -> %s\n", id, conn.ClientAddr, conn.Destination) 101 | // Keep it for a short time for UI transitions 102 | go func() { 103 | time.Sleep(2 * time.Second) 104 | ct.mu.Lock() 105 | delete(ct.connections, id) 106 | fmt.Printf("🗑️ Dashboard: Removed connection %s (remaining: %d)\n", id, len(ct.connections)) 107 | ct.mu.Unlock() 108 | }() 109 | } 110 | } 111 | 112 | // SetConnectionError marks a connection as having an error 113 | func (ct *ConnectionTracker) SetConnectionError(id string) { 114 | ct.mu.Lock() 115 | defer ct.mu.Unlock() 116 | 117 | if conn, exists := ct.connections[id]; exists { 118 | conn.State = "error" 119 | } 120 | } 121 | 122 | // GetActiveConnections returns all currently tracked connections 123 | func (ct *ConnectionTracker) GetActiveConnections() []*TrackedConnection { 124 | ct.mu.RLock() 125 | defer ct.mu.RUnlock() 126 | 127 | connections := make([]*TrackedConnection, 0, len(ct.connections)) 128 | for _, conn := range ct.connections { 129 | // Create a copy to avoid data races 130 | connCopy := *conn 131 | connections = append(connections, &connCopy) 132 | } 133 | 134 | return connections 135 | } 136 | 137 | // GetConnectionCount returns the current number of active connections 138 | func (ct *ConnectionTracker) GetConnectionCount() int { 139 | ct.mu.RLock() 140 | defer ct.mu.RUnlock() 141 | 142 | activeCount := 0 143 | for _, conn := range ct.connections { 144 | if conn.State == "active" { 145 | activeCount++ 146 | } 147 | } 148 | return activeCount 149 | } 150 | 151 | // GetTotalBytes returns total bytes transferred across all connections 152 | func (ct *ConnectionTracker) GetTotalBytes() (int64, int64) { 153 | ct.mu.RLock() 154 | defer ct.mu.RUnlock() 155 | 156 | var totalIn, totalOut int64 157 | for _, conn := range ct.connections { 158 | totalIn += conn.BytesIn 159 | totalOut += conn.BytesOut 160 | } 161 | 162 | return totalIn, totalOut 163 | } 164 | 165 | // GetAverageLatency returns the average latency across active connections 166 | func (ct *ConnectionTracker) GetAverageLatency() float64 { 167 | ct.mu.RLock() 168 | defer ct.mu.RUnlock() 169 | 170 | var totalLatency float64 171 | var count int 172 | 173 | for _, conn := range ct.connections { 174 | if conn.State == "active" && conn.Latency > 0 { 175 | totalLatency += conn.Latency 176 | count++ 177 | } 178 | } 179 | 180 | if count == 0 { 181 | return 0 182 | } 183 | 184 | return totalLatency / float64(count) 185 | } 186 | 187 | // RecordMetrics adds a data point to the historical metrics 188 | func (ct *ConnectionTracker) RecordMetrics(byteRate float64) { 189 | ct.history.mu.Lock() 190 | defer ct.history.mu.Unlock() 191 | 192 | now := time.Now() 193 | connCount := ct.GetConnectionCount() 194 | avgLatency := ct.GetAverageLatency() 195 | 196 | // Add to ring buffer 197 | ct.history.timestamps[ct.history.writeIndex] = now 198 | ct.history.connCounts[ct.history.writeIndex] = connCount 199 | ct.history.byteRates[ct.history.writeIndex] = byteRate 200 | ct.history.latencies[ct.history.writeIndex] = avgLatency 201 | 202 | ct.history.writeIndex = (ct.history.writeIndex + 1) % ct.history.maxPoints 203 | } 204 | 205 | // GetHistory returns historical metrics data 206 | func (ct *ConnectionTracker) GetHistory() ([]time.Time, []int, []float64, []float64) { 207 | ct.history.mu.RLock() 208 | defer ct.history.mu.RUnlock() 209 | 210 | // Return copies to avoid data races 211 | timestamps := make([]time.Time, ct.history.maxPoints) 212 | connCounts := make([]int, ct.history.maxPoints) 213 | byteRates := make([]float64, ct.history.maxPoints) 214 | latencies := make([]float64, ct.history.maxPoints) 215 | 216 | copy(timestamps, ct.history.timestamps) 217 | copy(connCounts, ct.history.connCounts) 218 | copy(byteRates, ct.history.byteRates) 219 | copy(latencies, ct.history.latencies) 220 | 221 | return timestamps, connCounts, byteRates, latencies 222 | } 223 | 224 | // Global instance 225 | var GlobalConnectionTracker = NewConnectionTracker() 226 | 227 | // Metrics collection control 228 | var ( 229 | metricsStopCh = make(chan struct{}) 230 | metricsRunning = false 231 | ) 232 | 233 | // StartMetricsCollection begins collecting metrics at regular intervals 234 | func StartMetricsCollection() { 235 | if metricsRunning { 236 | return 237 | } 238 | metricsRunning = true 239 | 240 | go func() { 241 | ticker := time.NewTicker(1 * time.Second) 242 | defer ticker.Stop() 243 | 244 | var lastTotalBytes int64 245 | var lastTime time.Time = time.Now() 246 | 247 | for { 248 | select { 249 | case <-metricsStopCh: 250 | return 251 | case <-ticker.C: 252 | totalIn, totalOut := GlobalConnectionTracker.GetTotalBytes() 253 | currentTotalBytes := totalIn + totalOut 254 | 255 | now := time.Now() 256 | duration := now.Sub(lastTime).Seconds() 257 | 258 | var byteRate float64 259 | if duration > 0 && currentTotalBytes >= lastTotalBytes { 260 | byteRate = float64(currentTotalBytes-lastTotalBytes) / duration 261 | } 262 | 263 | GlobalConnectionTracker.RecordMetrics(byteRate) 264 | 265 | lastTotalBytes = currentTotalBytes 266 | lastTime = now 267 | } 268 | } 269 | }() 270 | } 271 | 272 | // StopMetricsCollection stops the metrics collection goroutine 273 | func StopMetricsCollection() { 274 | if !metricsRunning { 275 | return 276 | } 277 | metricsRunning = false 278 | 279 | select { 280 | case metricsStopCh <- struct{}{}: 281 | default: // Channel might be full, that's ok 282 | } 283 | } -------------------------------------------------------------------------------- /internal/deploy/infrastructure.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: 'QUIC NAT Traversal SOCKS5 Proxy Infrastructure' 3 | 4 | Parameters: 5 | StackName: 6 | Type: String 7 | Default: 'quic-nat-proxy' 8 | Description: 'Name for the stack (used in resource naming)' 9 | AllowedPattern: '^[a-zA-Z][a-zA-Z0-9-]*$' 10 | ConstraintDescription: 'Must start with a letter and contain only alphanumeric characters and hyphens' 11 | 12 | 13 | Resources: 14 | # S3 Bucket for coordination between orchestrator and lambda 15 | CoordinationBucket: 16 | Type: AWS::S3::Bucket 17 | Properties: 18 | BucketName: !Sub '${StackName}-coordination-${AWS::AccountId}' 19 | PublicAccessBlockConfiguration: 20 | BlockPublicAcls: true 21 | BlockPublicPolicy: true 22 | IgnorePublicAcls: true 23 | RestrictPublicBuckets: true 24 | LifecycleConfiguration: 25 | Rules: 26 | - Id: DeleteOldCoordinationFiles 27 | Status: Enabled 28 | ExpirationInDays: 1 29 | Prefix: 'coordination/' 30 | - Id: DeleteOldResponseFiles 31 | Status: Enabled 32 | ExpirationInDays: 1 33 | Prefix: 'punch-response/' 34 | Tags: 35 | - Key: Project 36 | Value: 'lambda-nat-proxy' 37 | - Key: Component 38 | Value: 'coordination-bucket' 39 | - Key: ManagedBy 40 | Value: 'CloudFormation' 41 | - Key: Environment 42 | Value: 'production' 43 | - Key: CostCenter 44 | Value: 'lambda-nat-proxy' 45 | - Key: Owner 46 | Value: 'lambda-nat-proxy-cli' 47 | 48 | # IAM Role for Lambda Function 49 | LambdaExecutionRole: 50 | Type: AWS::IAM::Role 51 | Properties: 52 | RoleName: !Sub '${StackName}-lambda-role' 53 | AssumeRolePolicyDocument: 54 | Version: '2012-10-17' 55 | Statement: 56 | - Effect: Allow 57 | Principal: 58 | Service: lambda.amazonaws.com 59 | Action: sts:AssumeRole 60 | ManagedPolicyArns: 61 | - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 62 | Policies: 63 | - PolicyName: S3AccessPolicy 64 | PolicyDocument: 65 | Version: '2012-10-17' 66 | Statement: 67 | - Effect: Allow 68 | Action: 69 | - s3:GetObject 70 | - s3:PutObject 71 | Resource: !Sub '${CoordinationBucket.Arn}/*' 72 | Tags: 73 | - Key: Project 74 | Value: 'lambda-nat-proxy' 75 | - Key: Component 76 | Value: 'lambda-execution-role' 77 | - Key: ManagedBy 78 | Value: 'CloudFormation' 79 | - Key: Environment 80 | Value: 'production' 81 | - Key: CostCenter 82 | Value: 'lambda-nat-proxy' 83 | - Key: Owner 84 | Value: 'lambda-nat-proxy-cli' 85 | 86 | # Note: Lambda function, permissions, and S3 notifications will be configured via SDK 87 | # This allows us to deploy the lambda as a zip file without S3 intermediate storage 88 | 89 | Outputs: 90 | StackName: 91 | Description: 'CloudFormation Stack Name' 92 | Value: !Ref 'AWS::StackName' 93 | Export: 94 | Name: !Sub '${AWS::StackName}-StackName' 95 | 96 | 97 | CoordinationBucketName: 98 | Description: 'S3 bucket name for coordination' 99 | Value: !Ref CoordinationBucket 100 | Export: 101 | Name: !Sub '${AWS::StackName}-CoordinationBucket' 102 | 103 | CoordinationBucketArn: 104 | Description: 'S3 bucket ARN for coordination' 105 | Value: !GetAtt CoordinationBucket.Arn 106 | Export: 107 | Name: !Sub '${AWS::StackName}-CoordinationBucketArn' 108 | 109 | LambdaExecutionRoleArn: 110 | Description: 'Lambda execution role ARN' 111 | Value: !GetAtt LambdaExecutionRole.Arn 112 | Export: 113 | Name: !Sub '${AWS::StackName}-LambdaExecutionRoleArn' 114 | 115 | LambdaExecutionRoleName: 116 | Description: 'Lambda execution role name' 117 | Value: !Ref LambdaExecutionRole 118 | Export: 119 | Name: !Sub '${AWS::StackName}-LambdaExecutionRoleName' 120 | 121 | LambdaFunctionName: 122 | Description: 'Expected Lambda function name (for SDK deployment)' 123 | Value: !Sub '${StackName}-lambda' 124 | Export: 125 | Name: !Sub '${AWS::StackName}-LambdaFunctionName' 126 | 127 | Region: 128 | Description: 'AWS Region' 129 | Value: !Ref 'AWS::Region' 130 | Export: 131 | Name: !Sub '${AWS::StackName}-Region' -------------------------------------------------------------------------------- /internal/deploy/templates.go: -------------------------------------------------------------------------------- 1 | package deploy 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "text/template" 9 | 10 | "github.com/dan-v/lambda-nat-punch-proxy/internal/config" 11 | ) 12 | 13 | //go:embed infrastructure.yaml 14 | var embeddedTemplate string 15 | 16 | // TemplateParams holds parameters for CloudFormation template substitution 17 | type TemplateParams struct { 18 | StackName string 19 | } 20 | 21 | // GetCloudFormationTemplate returns the CloudFormation template content 22 | func GetCloudFormationTemplate(cfg *config.CLIConfig, customTemplatePath string) (string, error) { 23 | var templateContent string 24 | 25 | // Use custom template file if provided 26 | if customTemplatePath != "" { 27 | content, err := os.ReadFile(customTemplatePath) 28 | if err != nil { 29 | return "", fmt.Errorf("failed to read custom template file %s: %w", customTemplatePath, err) 30 | } 31 | templateContent = string(content) 32 | } else { 33 | // Use embedded template 34 | templateContent = embeddedTemplate 35 | } 36 | 37 | // Perform parameter substitution 38 | params := TemplateParams{ 39 | StackName: cfg.Deployment.StackName, 40 | } 41 | 42 | substitutedTemplate, err := substituteTemplateParams(templateContent, params) 43 | if err != nil { 44 | return "", fmt.Errorf("failed to substitute template parameters: %w", err) 45 | } 46 | 47 | return substitutedTemplate, nil 48 | } 49 | 50 | // substituteTemplateParams performs basic parameter substitution in the template 51 | func substituteTemplateParams(templateContent string, params TemplateParams) (string, error) { 52 | tmpl, err := template.New("cloudformation").Parse(templateContent) 53 | if err != nil { 54 | return "", fmt.Errorf("failed to parse template: %w", err) 55 | } 56 | 57 | var result strings.Builder 58 | err = tmpl.Execute(&result, params) 59 | if err != nil { 60 | return "", fmt.Errorf("failed to execute template: %w", err) 61 | } 62 | 63 | return result.String(), nil 64 | } 65 | 66 | // ValidateTemplate performs basic validation on the CloudFormation template 67 | func ValidateTemplate(templateContent string) error { 68 | // Basic validation - check for required sections 69 | requiredSections := []string{ 70 | "AWSTemplateFormatVersion", 71 | "Resources:", 72 | } 73 | 74 | for _, section := range requiredSections { 75 | if !strings.Contains(templateContent, section) { 76 | return fmt.Errorf("template missing required section: %s", section) 77 | } 78 | } 79 | 80 | return nil 81 | } -------------------------------------------------------------------------------- /internal/deploy/templates_test.go: -------------------------------------------------------------------------------- 1 | package deploy 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/dan-v/lambda-nat-punch-proxy/internal/config" 10 | ) 11 | 12 | func TestGetCloudFormationTemplate(t *testing.T) { 13 | cfg := &config.CLIConfig{ 14 | Deployment: config.DeploymentConfig{ 15 | StackName: "test-stack", 16 | }, 17 | } 18 | 19 | template, err := GetCloudFormationTemplate(cfg, "") 20 | if err != nil { 21 | t.Fatalf("Expected no error getting template, got %v", err) 22 | } 23 | 24 | if template == "" { 25 | t.Error("Expected template content, got empty string") 26 | } 27 | 28 | // Check that template contains expected CloudFormation content 29 | if !strings.Contains(template, "AWSTemplateFormatVersion") { 30 | t.Error("Expected template to contain AWSTemplateFormatVersion") 31 | } 32 | } 33 | 34 | func TestGetCloudFormationTemplateWithCustomFile(t *testing.T) { 35 | cfg := &config.CLIConfig{ 36 | Deployment: config.DeploymentConfig{ 37 | StackName: "test-stack", 38 | }, 39 | } 40 | 41 | // Create a temporary custom template file 42 | tempDir := t.TempDir() 43 | customTemplatePath := filepath.Join(tempDir, "custom.yaml") 44 | 45 | customContent := `AWSTemplateFormatVersion: '2010-09-09' 46 | Description: Custom test template for {{.StackName}} 47 | Resources: 48 | TestResource: 49 | Type: AWS::S3::Bucket 50 | ` 51 | 52 | err := os.WriteFile(customTemplatePath, []byte(customContent), 0644) 53 | if err != nil { 54 | t.Fatalf("Failed to create custom template file: %v", err) 55 | } 56 | 57 | template, err := GetCloudFormationTemplate(cfg, customTemplatePath) 58 | if err != nil { 59 | t.Fatalf("Expected no error getting custom template, got %v", err) 60 | } 61 | 62 | // Check that parameter substitution worked 63 | if !strings.Contains(template, "test-stack") { 64 | t.Error("Expected template to contain substituted stack name") 65 | } 66 | } 67 | 68 | func TestGetCloudFormationTemplateWithMissingFile(t *testing.T) { 69 | cfg := &config.CLIConfig{ 70 | Deployment: config.DeploymentConfig{ 71 | StackName: "test-stack", 72 | }, 73 | } 74 | 75 | _, err := GetCloudFormationTemplate(cfg, "nonexistent.yaml") 76 | if err == nil { 77 | t.Error("Expected error for missing template file") 78 | } 79 | } 80 | 81 | func TestValidateTemplate(t *testing.T) { 82 | validTemplate := `AWSTemplateFormatVersion: '2010-09-09' 83 | Description: Test template 84 | Resources: 85 | TestBucket: 86 | Type: AWS::S3::Bucket 87 | ` 88 | 89 | err := ValidateTemplate(validTemplate) 90 | if err != nil { 91 | t.Errorf("Expected no error for valid template, got %v", err) 92 | } 93 | 94 | invalidTemplate := `Description: Missing AWSTemplateFormatVersion and Resources` 95 | 96 | err = ValidateTemplate(invalidTemplate) 97 | if err == nil { 98 | t.Error("Expected error for invalid template") 99 | } 100 | } 101 | 102 | func TestSubstituteTemplateParams(t *testing.T) { 103 | templateContent := `Stack: {{.StackName}} 104 | ` 105 | 106 | params := TemplateParams{ 107 | StackName: "my-stack", 108 | } 109 | 110 | result, err := substituteTemplateParams(templateContent, params) 111 | if err != nil { 112 | t.Fatalf("Expected no error substituting params, got %v", err) 113 | } 114 | 115 | if !strings.Contains(result, "my-stack") { 116 | t.Error("Expected result to contain stack name") 117 | } 118 | } -------------------------------------------------------------------------------- /internal/launcher.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "time" 9 | 10 | "github.com/dan-v/lambda-nat-punch-proxy/internal/config" 11 | "github.com/dan-v/lambda-nat-punch-proxy/internal/manager" 12 | "github.com/dan-v/lambda-nat-punch-proxy/internal/metrics" 13 | "github.com/dan-v/lambda-nat-punch-proxy/internal/nat" 14 | "github.com/dan-v/lambda-nat-punch-proxy/internal/quic" 15 | "github.com/dan-v/lambda-nat-punch-proxy/internal/s3" 16 | "github.com/dan-v/lambda-nat-punch-proxy/internal/stun" 17 | "github.com/dan-v/lambda-nat-punch-proxy/pkg/shared" 18 | ) 19 | 20 | // Launcher implements the SessionLauncher interface 21 | type Launcher struct { 22 | config *config.Config 23 | stunClient stun.Client 24 | s3Coord s3.Coordinator 25 | natTraversal nat.Traversal 26 | quicServer *quic.Server 27 | } 28 | 29 | // NewLauncher creates a new Launcher instance 30 | func NewLauncher(cfg *config.Config, stunClient stun.Client, s3Coord s3.Coordinator, natTraversal nat.Traversal, quicServer *quic.Server) *Launcher { 31 | return &Launcher{ 32 | config: cfg, 33 | stunClient: stunClient, 34 | s3Coord: s3Coord, 35 | natTraversal: natTraversal, 36 | quicServer: quicServer, 37 | } 38 | } 39 | 40 | // Launch creates a new session by performing the NAT traversal workflow 41 | func (l *Launcher) Launch(ctx context.Context) (*manager.Session, error) { 42 | log.Println("Launcher: Starting new session launch") 43 | 44 | // 1. Discover public IP via STUN 45 | stunStart := time.Now() 46 | publicIP, err := l.stunClient.DiscoverPublicIP(ctx, l.config.STUNServer) 47 | stunLatency := time.Since(stunStart) 48 | metrics.RecordSTUNLatency(stunLatency) 49 | 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to discover public IP: %w", err) 52 | } 53 | log.Printf("Launcher: Public IP: %s", publicIP) 54 | 55 | // 2. Create UDP socket for hole punching 56 | udpConn, localPort, err := l.natTraversal.CreateUDPSocket() 57 | if err != nil { 58 | return nil, fmt.Errorf("failed to create UDP socket: %w", err) 59 | } 60 | // Note: udpConn ownership will be transferred to QUIC server 61 | 62 | // 3. Write coordination to S3 (triggers Lambda) 63 | sessionID := shared.GenerateSessionID() 64 | if err := l.s3Coord.WriteCoordination(ctx, sessionID, publicIP, localPort); err != nil { 65 | udpConn.Close() 66 | return nil, fmt.Errorf("failed to write coordination to S3: %w", err) 67 | } 68 | log.Printf("Launcher: Coordination written for session: %s", sessionID) 69 | 70 | // 4. Wait for Lambda response 71 | lambdaResp, err := l.s3Coord.WaitForLambdaResponse(ctx, sessionID, l.config.LambdaResponseTimeout) 72 | if err != nil { 73 | udpConn.Close() 74 | return nil, fmt.Errorf("failed to get Lambda response: %w", err) 75 | } 76 | log.Printf("Launcher: Lambda endpoint: %s:%d", lambdaResp.LambdaPublicIP, lambdaResp.LambdaPublicPort) 77 | 78 | // 5. Perform NAT hole punching 79 | lambdaAddr := &net.UDPAddr{ 80 | IP: net.ParseIP(lambdaResp.LambdaPublicIP), 81 | Port: lambdaResp.LambdaPublicPort, 82 | } 83 | 84 | natStart := time.Now() 85 | if err := l.natTraversal.PerformHolePunch(udpConn, sessionID, lambdaAddr, l.config.NATHolePunchTimeout); err != nil { 86 | udpConn.Close() 87 | return nil, fmt.Errorf("NAT hole punching failed: %w", err) 88 | } 89 | natTraversalTime := time.Since(natStart) 90 | metrics.RecordNATTraversalTime(natTraversalTime) 91 | log.Println("Launcher: NAT hole punched successfully!") 92 | 93 | // 6. Start QUIC server and wait for Lambda connection 94 | quicStart := time.Now() 95 | quicConn, err := l.quicServer.StartAndAccept(ctx, udpConn, l.config) 96 | if err != nil { 97 | metrics.RecordQUICConnectionError() 98 | return nil, fmt.Errorf("failed to start QUIC server: %w", err) 99 | } 100 | quicHandshakeTime := time.Since(quicStart) 101 | metrics.RecordQUICHandshakeTime(quicHandshakeTime) 102 | 103 | log.Printf("Launcher: Session %s established with QUIC connection", sessionID) 104 | 105 | // Open control stream (stream 0) 106 | controlStream, err := quicConn.OpenStreamSync(ctx) 107 | if err != nil { 108 | metrics.RecordQUICConnectionError() 109 | quicConn.CloseWithError(0, "failed to open control stream") 110 | return nil, fmt.Errorf("failed to open control stream: %w", err) 111 | } 112 | 113 | // Record QUIC stream creation 114 | metrics.IncrementActiveQUICStreams() 115 | 116 | // Create the session 117 | session := &manager.Session{ 118 | ID: sessionID, 119 | QuicConn: quicConn, 120 | StartedAt: time.Now(), 121 | ControlStream: controlStream, 122 | TTL: l.config.Rotation.SessionTTL, 123 | LambdaPublicIP: lambdaResp.LambdaPublicIP, 124 | } 125 | session.SetHealthy(true) // Start as healthy 126 | 127 | // Start health check loop 128 | go l.startHealthCheck(ctx, session) 129 | 130 | return session, nil 131 | } 132 | 133 | // startHealthCheck runs the health check loop for a session 134 | func (l *Launcher) startHealthCheck(ctx context.Context, session *manager.Session) { 135 | defer func() { 136 | if r := recover(); r != nil { 137 | shared.LogErrorf("Panic in health check for session %s: %v", session.ID, r) 138 | session.SetHealthy(false) 139 | } 140 | shared.LogInfof("Health check for session %s stopped", session.ID) 141 | }() 142 | 143 | ticker := time.NewTicker(10 * time.Second) 144 | defer ticker.Stop() 145 | defer session.ControlStream.Close() 146 | 147 | var nonce uint64 148 | 149 | for { 150 | select { 151 | case <-ctx.Done(): 152 | shared.LogInfof("Health check for session %s stopping due to context cancellation", session.ID) 153 | return 154 | case <-session.QuicConn.Context().Done(): 155 | shared.LogInfof("Health check for session %s stopping due to QUIC connection closure", session.ID) 156 | return 157 | case <-ticker.C: 158 | nonce++ 159 | 160 | // Record ping start time for RTT calculation 161 | pingStart := time.Now() 162 | 163 | // Check context before sending ping 164 | select { 165 | case <-ctx.Done(): 166 | shared.LogInfof("Health check for session %s cancelling during ping", session.ID) 167 | return 168 | default: 169 | } 170 | 171 | // Send ping 172 | metrics.RecordPingSent() 173 | if err := shared.WritePing(session.ControlStream, nonce); err != nil { 174 | shared.LogErrorf("Failed to send ping to session %s: %v", session.ID, err) 175 | session.SetHealthy(false) 176 | metrics.SetSessionHealthy(false) 177 | return 178 | } 179 | 180 | // Set read deadline for pong with shorter timeout to be more responsive 181 | session.ControlStream.SetReadDeadline(time.Now().Add(3 * time.Second)) 182 | 183 | // Read response with context check 184 | opcode, receivedNonce, err := shared.ReadControlMessage(session.ControlStream) 185 | 186 | // Always clear read deadline first 187 | session.ControlStream.SetReadDeadline(time.Time{}) 188 | 189 | // Check context again after read 190 | select { 191 | case <-ctx.Done(): 192 | shared.LogInfof("Health check for session %s cancelling after ping response", session.ID) 193 | return 194 | default: 195 | } 196 | 197 | if err != nil { 198 | missedCount := session.IncrementMissedPings() 199 | metrics.RecordMissedPing() 200 | shared.LogErrorf("Failed to receive pong from session %s (missed: %d): %v", session.ID, missedCount, err) 201 | 202 | if missedCount >= 3 { 203 | shared.LogErrorf("Session %s marked unhealthy after 3 missed pings", session.ID) 204 | session.SetHealthy(false) 205 | metrics.SetSessionHealthy(false) 206 | return 207 | } 208 | continue 209 | } 210 | 211 | if opcode == shared.OpPong && receivedNonce == nonce { 212 | // Calculate and record RTT 213 | rtt := time.Since(pingStart) 214 | metrics.RecordRTT(rtt) 215 | 216 | session.ResetMissedPings() 217 | session.SetHealthy(true) 218 | metrics.SetSessionHealthy(true) 219 | 220 | shared.LogInfof("Session %s health check: RTT %v", session.ID, rtt) 221 | } else if opcode == shared.OpShutdown { 222 | // Handle shutdown signal gracefully during health check 223 | shared.LogInfof("Session %s received shutdown signal during health check", session.ID) 224 | session.SetHealthy(false) 225 | metrics.SetSessionHealthy(false) 226 | return 227 | } else { 228 | shared.LogErrorf("Unexpected control message from session %s: opcode=%02x, nonce=%d (expected %d)", 229 | session.ID, opcode, receivedNonce, nonce) 230 | } 231 | } 232 | } 233 | } -------------------------------------------------------------------------------- /internal/manager/manager_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/dan-v/lambda-nat-punch-proxy/pkg/shared" 8 | ) 9 | 10 | func TestSession_RoleMethods(t *testing.T) { 11 | session := &Session{ 12 | ID: "test-session", 13 | Role: RolePrimary, 14 | } 15 | 16 | if !session.IsPrimary() { 17 | t.Error("Expected session to be primary") 18 | } 19 | if session.IsSecondary() { 20 | t.Error("Expected session not to be secondary") 21 | } 22 | if session.IsDraining() { 23 | t.Error("Expected session not to be draining") 24 | } 25 | 26 | session.Role = RoleSecondary 27 | if session.IsPrimary() { 28 | t.Error("Expected session not to be primary") 29 | } 30 | if !session.IsSecondary() { 31 | t.Error("Expected session to be secondary") 32 | } 33 | if session.IsDraining() { 34 | t.Error("Expected session not to be draining") 35 | } 36 | 37 | session.Role = RoleDraining 38 | if session.IsPrimary() { 39 | t.Error("Expected session not to be primary") 40 | } 41 | if session.IsSecondary() { 42 | t.Error("Expected session not to be secondary") 43 | } 44 | if !session.IsDraining() { 45 | t.Error("Expected session to be draining") 46 | } 47 | } 48 | 49 | func TestSession_RemainingTTL(t *testing.T) { 50 | session := &Session{ 51 | ID: "test-session", 52 | StartedAt: time.Now().Add(-2 * time.Minute), // Started 2 minutes ago 53 | TTL: 5 * time.Minute, 54 | } 55 | 56 | remaining := session.RemainingTTL() 57 | expected := 3 * time.Minute // 5 minute TTL - 2 minutes elapsed = 3 minutes 58 | 59 | // Allow for some timing tolerance 60 | if remaining < expected-time.Second || remaining > expected+time.Second { 61 | t.Errorf("Expected remaining TTL around %v, got %v", expected, remaining) 62 | } 63 | 64 | // Test expired session 65 | expiredSession := &Session{ 66 | ID: "expired-session", 67 | StartedAt: time.Now().Add(-6 * time.Minute), // Started 6 minutes ago 68 | TTL: 5 * time.Minute, 69 | } 70 | 71 | if expiredSession.RemainingTTL() != 0 { 72 | t.Errorf("Expected expired session to have 0 remaining TTL, got %v", expiredSession.RemainingTTL()) 73 | } 74 | } 75 | 76 | func TestGenerateSessionID(t *testing.T) { 77 | id1 := shared.GenerateSessionID() 78 | id2 := shared.GenerateSessionID() 79 | 80 | if id1 == id2 { 81 | t.Error("Expected different session IDs") 82 | } 83 | 84 | if len(id1) == 0 { 85 | t.Error("Expected non-empty session ID") 86 | } 87 | } -------------------------------------------------------------------------------- /internal/metrics/metrics_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestGetLastRTT(t *testing.T) { 9 | testRTT := 123 * time.Millisecond 10 | RecordRTT(testRTT) 11 | 12 | if got := GetLastRTT(); got != testRTT { 13 | t.Errorf("Expected last RTT %v, got %v", testRTT, got) 14 | } 15 | } 16 | 17 | func TestMetricsRecording(t *testing.T) { 18 | // Test basic metric recording without HTTP server overhead 19 | RecordPingSent() 20 | RecordRTT(50 * time.Millisecond) 21 | SetSessionHealthy(true) 22 | 23 | // Verify RTT was recorded 24 | if got := GetLastRTT(); got != 50*time.Millisecond { 25 | t.Errorf("Expected RTT 50ms, got %v", got) 26 | } 27 | } -------------------------------------------------------------------------------- /internal/nat/traversal.go: -------------------------------------------------------------------------------- 1 | package nat 2 | 3 | import ( 4 | "net" 5 | "time" 6 | 7 | "github.com/dan-v/lambda-nat-punch-proxy/pkg/shared" 8 | ) 9 | 10 | // Traversal handles UDP hole punching 11 | type Traversal interface { 12 | CreateUDPSocket() (*net.UDPConn, int, error) 13 | PerformHolePunch(conn *net.UDPConn, sessionID string, lambdaAddr *net.UDPAddr, timeout time.Duration) error 14 | } 15 | 16 | // DefaultTraversal implements Traversal 17 | type DefaultTraversal struct{} 18 | 19 | // New creates a new NAT traversal client 20 | func New() Traversal { 21 | return &DefaultTraversal{} 22 | } 23 | 24 | // CreateUDPSocket creates a UDP socket for hole punching 25 | func (n *DefaultTraversal) CreateUDPSocket() (*net.UDPConn, int, error) { 26 | return shared.CreateUDPSocket() 27 | } 28 | 29 | // PerformHolePunch performs NAT hole punching with the Lambda 30 | func (n *DefaultTraversal) PerformHolePunch(conn *net.UDPConn, sessionID string, lambdaAddr *net.UDPAddr, timeout time.Duration) error { 31 | return shared.PerformNATHolePunch(conn, sessionID, lambdaAddr, timeout, true) 32 | } -------------------------------------------------------------------------------- /internal/quic/server.go: -------------------------------------------------------------------------------- 1 | package quic 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "time" 9 | 10 | "github.com/dan-v/lambda-nat-punch-proxy/internal/config" 11 | "github.com/dan-v/lambda-nat-punch-proxy/pkg/shared" 12 | "github.com/quic-go/quic-go" 13 | ) 14 | 15 | // ServerAPI defines the interface for QUIC server operations 16 | type ServerAPI interface { 17 | StartAndAccept(ctx context.Context, udpConn *net.UDPConn, cfg *config.Config) (quic.Connection, error) 18 | } 19 | 20 | // Server manages QUIC server functionality 21 | type Server struct{} 22 | 23 | // New creates a new QUIC server 24 | func New() *Server { 25 | return &Server{} 26 | } 27 | 28 | // StartAndAccept starts QUIC server and waits for Lambda connection 29 | func (s *Server) StartAndAccept(ctx context.Context, udpConn *net.UDPConn, cfg *config.Config) (quic.Connection, error) { 30 | // Get the local address from our UDP socket (same port used for hole punching) 31 | localAddr := udpConn.LocalAddr().(*net.UDPAddr) 32 | 33 | // Close UDP socket to free the port for QUIC server 34 | udpConn.Close() 35 | 36 | // Small delay to ensure port is released 37 | time.Sleep(shared.DefaultSocketReleaseDelay) 38 | 39 | // Generate TLS config for server 40 | tlsConfig, err := shared.GenerateTLSConfig(shared.TLSConfigOptions{ 41 | Organization: "Orchestrator QUIC Server", 42 | DNSNames: []string{"orchestrator.local"}, 43 | }) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to generate TLS config: %w", err) 46 | } 47 | 48 | log.Printf("🔗 Starting QUIC server on %s (same port as hole punch)", localAddr.String()) 49 | 50 | // Get mode-based QUIC configuration 51 | streamWindow, connWindow, maxIncomingStreams, maxIncomingUniStreams := shared.GetQUICConfig( 52 | cfg.ModeConfig.BufferSize, 53 | cfg.ModeConfig.MaxStreams, 54 | ) 55 | 56 | log.Printf("🔧 QUIC config for %s mode: stream=%dMB, conn=%dMB, streams=%d", 57 | cfg.Mode, streamWindow/(1024*1024), connWindow/(1024*1024), maxIncomingStreams) 58 | 59 | // Create mode-optimized QUIC configuration 60 | quicConfig := &quic.Config{ 61 | // Flow control optimization based on mode 62 | InitialStreamReceiveWindow: uint64(streamWindow / 2), 63 | MaxStreamReceiveWindow: uint64(streamWindow), 64 | InitialConnectionReceiveWindow: uint64(connWindow / 2), 65 | MaxConnectionReceiveWindow: uint64(connWindow), 66 | 67 | // Stream limits from mode configuration 68 | MaxIncomingStreams: int64(maxIncomingStreams), 69 | MaxIncomingUniStreams: maxIncomingUniStreams, 70 | 71 | // Timeout optimization based on mode 72 | MaxIdleTimeout: cfg.ModeConfig.IdleTimeout, 73 | HandshakeIdleTimeout: shared.QUICHandshakeTimeout, 74 | KeepAlivePeriod: cfg.ModeConfig.KeepAlive, 75 | 76 | // Enable connection migration for better reliability 77 | DisablePathMTUDiscovery: false, 78 | EnableDatagrams: false, // Focus on stream performance 79 | } 80 | 81 | // Create QUIC listener on the same port with optimized config 82 | listener, err := quic.ListenAddr(localAddr.String(), tlsConfig, quicConfig) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to create QUIC listener: %w", err) 85 | } 86 | 87 | // Set up graceful shutdown of listener on context cancellation 88 | go func() { 89 | <-ctx.Done() 90 | shared.LogNetwork("Shutting down QUIC listener") 91 | listener.Close() 92 | }() 93 | 94 | shared.LogNetwork("QUIC server ready to accept Lambda connection") 95 | 96 | // Wait for Lambda to connect 97 | quicConn, err := listener.Accept(ctx) 98 | if err != nil { 99 | // Check if this is due to context cancellation (expected) 100 | if ctx.Err() != nil { 101 | return nil, ctx.Err() 102 | } 103 | return nil, fmt.Errorf("failed to accept Lambda connection: %w", err) 104 | } 105 | 106 | log.Printf("✅ Lambda connected from %s!", quicConn.RemoteAddr()) 107 | 108 | return quicConn, nil 109 | } -------------------------------------------------------------------------------- /internal/s3/coordinator.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/aws/awserr" 12 | "github.com/aws/aws-sdk-go/service/s3" 13 | awsclients "github.com/dan-v/lambda-nat-punch-proxy/internal/aws" 14 | "github.com/dan-v/lambda-nat-punch-proxy/internal/metrics" 15 | "github.com/dan-v/lambda-nat-punch-proxy/pkg/shared" 16 | ) 17 | 18 | // Coordinator handles coordination with Lambda via S3 19 | type Coordinator interface { 20 | WriteCoordination(ctx context.Context, sessionID, publicIP string, port int) error 21 | WaitForLambdaResponse(ctx context.Context, sessionID string, timeout time.Duration) (*shared.LambdaResponse, error) 22 | } 23 | 24 | // DefaultCoordinator implements Coordinator 25 | type DefaultCoordinator struct { 26 | s3Client awsclients.S3API 27 | bucketName string 28 | } 29 | 30 | // New creates a new S3 coordinator 31 | func New(s3Client awsclients.S3API, bucketName string) Coordinator { 32 | return &DefaultCoordinator{ 33 | s3Client: s3Client, 34 | bucketName: bucketName, 35 | } 36 | } 37 | 38 | // WriteCoordination writes coordination data to S3 to trigger Lambda 39 | func (c *DefaultCoordinator) WriteCoordination(ctx context.Context, sessionID, publicIP string, port int) error { 40 | coord := shared.CoordinationData{ 41 | SessionID: sessionID, 42 | LaptopPublicIP: publicIP, 43 | LaptopPublicPort: port, 44 | Timestamp: time.Now().Unix(), 45 | } 46 | 47 | coordData, err := json.Marshal(coord) 48 | if err != nil { 49 | return fmt.Errorf("failed to marshal coordination data: %w", err) 50 | } 51 | 52 | s3Key := fmt.Sprintf(shared.CoordinationKeyPattern, sessionID) 53 | 54 | start := time.Now() 55 | _, err = c.s3Client.PutObjectWithContext(ctx, &s3.PutObjectInput{ 56 | Bucket: aws.String(c.bucketName), 57 | Key: aws.String(s3Key), 58 | Body: bytes.NewReader(coordData), 59 | }) 60 | 61 | // Record S3 operation metrics 62 | metrics.RecordS3Operation() 63 | metrics.RecordAWSAPILatency(time.Since(start)) 64 | 65 | if err != nil { 66 | metrics.RecordS3Error() 67 | if awsErr, ok := err.(awserr.Error); ok { 68 | switch awsErr.Code() { 69 | case s3.ErrCodeNoSuchBucket: 70 | return fmt.Errorf("S3 bucket '%s' does not exist. Please run 'lambda-nat-proxy deploy' to create infrastructure", c.bucketName) 71 | case "AccessDenied": 72 | return fmt.Errorf("access denied to S3 bucket '%s'. Please check AWS credentials have S3 permissions:\n\n"+ 73 | "Required permissions:\n"+ 74 | "- s3:PutObject\n"+ 75 | "- s3:GetObject\n"+ 76 | "- s3:DeleteObject", c.bucketName) 77 | case "InvalidBucketName": 78 | return fmt.Errorf("invalid S3 bucket name '%s'. Bucket names must be DNS-compliant", c.bucketName) 79 | default: 80 | return fmt.Errorf("S3 operation failed (%s): %v\nBucket: %s\nKey: coordination/%s", 81 | awsErr.Code(), awsErr.Message(), c.bucketName, sessionID) 82 | } 83 | } 84 | return fmt.Errorf("failed to write to S3: %w", err) 85 | } 86 | 87 | return nil 88 | } 89 | 90 | // WaitForLambdaResponse polls S3 for Lambda response 91 | func (c *DefaultCoordinator) WaitForLambdaResponse(ctx context.Context, sessionID string, timeout time.Duration) (*shared.LambdaResponse, error) { 92 | deadline := time.Now().Add(timeout) 93 | responseKey := fmt.Sprintf(shared.ResponseKeyPattern, sessionID) 94 | 95 | for time.Now().Before(deadline) { 96 | select { 97 | case <-ctx.Done(): 98 | return nil, ctx.Err() 99 | default: 100 | } 101 | start := time.Now() 102 | obj, err := c.s3Client.GetObjectWithContext(ctx, &s3.GetObjectInput{ 103 | Bucket: aws.String(c.bucketName), 104 | Key: aws.String(responseKey), 105 | }) 106 | 107 | // Record S3 operation metrics 108 | metrics.RecordS3Operation() 109 | metrics.RecordAWSAPILatency(time.Since(start)) 110 | 111 | if err == nil { 112 | defer obj.Body.Close() 113 | 114 | var response shared.LambdaResponse 115 | if err := json.NewDecoder(obj.Body).Decode(&response); err == nil { 116 | metrics.RecordLambdaInvocation() 117 | return &response, nil 118 | } 119 | } else { 120 | // Only record S3 error for actual errors, not "not found" which is expected 121 | if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() != s3.ErrCodeNoSuchKey { 122 | metrics.RecordS3Error() 123 | } 124 | } 125 | 126 | select { 127 | case <-ctx.Done(): 128 | return nil, ctx.Err() 129 | case <-time.After(shared.ResponsePollInterval): 130 | } 131 | } 132 | 133 | return nil, fmt.Errorf("timeout waiting for Lambda response") 134 | } -------------------------------------------------------------------------------- /internal/s3/coordinator_test.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import ( 4 | "testing" 5 | 6 | awsclients "github.com/dan-v/lambda-nat-punch-proxy/internal/aws" 7 | ) 8 | 9 | func TestNew(t *testing.T) { 10 | // Test that New creates a coordinator successfully 11 | // Using nil client since we're just testing construction 12 | coord := New(nil, "test-bucket") 13 | 14 | if coord == nil { 15 | t.Error("Expected coordinator to be created") 16 | } 17 | 18 | // Test that it implements the interface 19 | var _ Coordinator = coord 20 | } 21 | 22 | func TestCoordinatorInterface(t *testing.T) { 23 | // Test that DefaultCoordinator implements Coordinator interface 24 | var s3Client awsclients.S3API 25 | coord := New(s3Client, "test-bucket") 26 | 27 | // This will compile only if DefaultCoordinator implements Coordinator 28 | var _ Coordinator = coord 29 | } -------------------------------------------------------------------------------- /internal/stun/client.go: -------------------------------------------------------------------------------- 1 | package stun 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/pion/stun" 9 | ) 10 | 11 | // Client handles public IP discovery via STUN servers 12 | type Client interface { 13 | DiscoverPublicIP(ctx context.Context, stunServer string) (string, error) 14 | } 15 | 16 | // DefaultClient implements Client 17 | type DefaultClient struct{} 18 | 19 | // New creates a new STUN client 20 | func New() Client { 21 | return &DefaultClient{} 22 | } 23 | 24 | // DiscoverPublicIP discovers the public IP address using STUN 25 | func (c *DefaultClient) DiscoverPublicIP(ctx context.Context, stunServer string) (string, error) { 26 | dialer := &net.Dialer{} 27 | conn, err := dialer.DialContext(ctx, "udp", stunServer) 28 | if err != nil { 29 | return "", fmt.Errorf("failed to dial STUN server: %w", err) 30 | } 31 | defer conn.Close() 32 | 33 | client, err := stun.NewClient(conn) 34 | if err != nil { 35 | return "", fmt.Errorf("failed to create STUN client: %w", err) 36 | } 37 | defer client.Close() 38 | 39 | message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) 40 | 41 | var publicIP string 42 | var stunErr error 43 | err = client.Do(message, func(res stun.Event) { 44 | if res.Error != nil { 45 | stunErr = res.Error 46 | return 47 | } 48 | 49 | var xorAddr stun.XORMappedAddress 50 | if err := xorAddr.GetFrom(res.Message); err != nil { 51 | stunErr = err 52 | return 53 | } 54 | 55 | publicIP = xorAddr.IP.String() 56 | }) 57 | 58 | if stunErr != nil { 59 | return "", stunErr 60 | } 61 | 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | return publicIP, nil 67 | } -------------------------------------------------------------------------------- /internal/stun/client_test.go: -------------------------------------------------------------------------------- 1 | package stun 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestDiscoverPublicIP_ContextCancel(t *testing.T) { 10 | client := New() 11 | 12 | ctx, cancel := context.WithCancel(context.Background()) 13 | cancel() // Cancel immediately 14 | 15 | _, err := client.DiscoverPublicIP(ctx, "stun.l.google.com:19302") 16 | 17 | if err == nil { 18 | t.Error("Expected error due to context cancellation") 19 | } 20 | } 21 | 22 | func TestDiscoverPublicIP_InvalidServer(t *testing.T) { 23 | client := New() 24 | 25 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 26 | defer cancel() 27 | 28 | _, err := client.DiscoverPublicIP(ctx, "invalid.server:12345") 29 | 30 | if err == nil { 31 | t.Error("Expected error for invalid STUN server") 32 | } 33 | } 34 | 35 | func TestNew(t *testing.T) { 36 | client := New() 37 | 38 | if client == nil { 39 | t.Error("Expected client to be created") 40 | } 41 | 42 | // Test that it implements the interface 43 | var _ Client = client 44 | } -------------------------------------------------------------------------------- /lambda/go.mod: -------------------------------------------------------------------------------- 1 | module lambda 2 | 3 | go 1.21.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/aws/aws-lambda-go v1.41.0 9 | github.com/aws/aws-sdk-go v1.44.300 10 | github.com/dan-v/lambda-nat-punch-proxy v0.0.0 11 | github.com/quic-go/quic-go v0.40.1 12 | ) 13 | 14 | replace github.com/dan-v/lambda-nat-punch-proxy => .. 15 | 16 | require ( 17 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 18 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect 19 | github.com/jmespath/go-jmespath v0.4.0 // indirect 20 | github.com/onsi/ginkgo/v2 v2.9.5 // indirect 21 | github.com/quic-go/qtls-go1-20 v0.4.1 // indirect 22 | go.uber.org/mock v0.3.0 // indirect 23 | golang.org/x/crypto v0.32.0 // indirect 24 | golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect 25 | golang.org/x/mod v0.17.0 // indirect 26 | golang.org/x/net v0.33.0 // indirect 27 | golang.org/x/sys v0.29.0 // indirect 28 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /lambda/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-lambda-go v1.41.0 h1:l/5fyVb6Ud9uYd411xdHZzSf2n86TakxzpvIoz7l+3Y= 2 | github.com/aws/aws-lambda-go v1.41.0/go.mod h1:jwFe2KmMsHmffA1X2R09hH6lFzJQxzI8qK17ewzbQMM= 3 | github.com/aws/aws-sdk-go v1.44.300 h1:Zn+3lqgYahIf9yfrwZ+g+hq/c3KzUBaQ8wqY/ZXiAbY= 4 | github.com/aws/aws-sdk-go v1.44.300/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= 5 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 6 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 7 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 12 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 13 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 14 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 15 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 16 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 17 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 18 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= 20 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 21 | github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 22 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 23 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 24 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 25 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 26 | github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= 27 | github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= 28 | github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= 29 | github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 30 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 31 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/quic-go/qtls-go1-20 v0.4.1 h1:D33340mCNDAIKBqXuAvexTNMUByrYmFYVfKfDN5nfFs= 34 | github.com/quic-go/qtls-go1-20 v0.4.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k= 35 | github.com/quic-go/quic-go v0.40.1 h1:X3AGzUNFs0jVuO3esAGnTfvdgvL4fq655WaOi1snv1Q= 36 | github.com/quic-go/quic-go v0.40.1/go.mod h1:PeN7kuVJ4xZbxSv/4OX6S1USOX8MJvydwpTx31vx60c= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 39 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 40 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 41 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 42 | go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo= 43 | go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= 44 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 45 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 46 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 47 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 48 | golang.org/x/exp v0.0.0-20221205204356-47842c84f3db h1:D/cFflL63o2KSLJIwjlcIt8PR064j/xsmdEJL/YvY/o= 49 | golang.org/x/exp v0.0.0-20221205204356-47842c84f3db/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= 50 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 51 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 52 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 53 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 54 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 55 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 56 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 57 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 58 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 59 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 60 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 61 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 62 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 63 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 64 | golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 65 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 66 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 71 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 72 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 73 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 74 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 75 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 76 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 77 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 78 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 79 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 80 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 81 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 82 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 83 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 84 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 85 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 86 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 87 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 88 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 89 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 91 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 92 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 94 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | -------------------------------------------------------------------------------- /media/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dan-v/lambda-nat-proxy/HEAD/media/dashboard.png -------------------------------------------------------------------------------- /pkg/shared/aws.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/service/s3" 12 | ) 13 | 14 | // CreateAWSSession creates a new AWS session with the specified region 15 | func CreateAWSSession(region string) (*session.Session, error) { 16 | return session.NewSession(&aws.Config{ 17 | Region: aws.String(region), 18 | }) 19 | } 20 | 21 | // CreateS3Client creates a new S3 client with the specified region 22 | func CreateS3Client(region string) (*s3.S3, error) { 23 | sess, err := CreateAWSSession(region) 24 | if err != nil { 25 | return nil, fmt.Errorf("failed to create AWS session: %w", err) 26 | } 27 | return s3.New(sess), nil 28 | } 29 | 30 | // PutCoordinationData writes coordination data to S3 31 | func PutCoordinationData(s3Client *s3.S3, bucket, sessionID string, data CoordinationData) error { 32 | coordinationData, err := json.Marshal(data) 33 | if err != nil { 34 | return fmt.Errorf("failed to marshal coordination data: %w", err) 35 | } 36 | 37 | coordinationKey := fmt.Sprintf(CoordinationKeyPattern, sessionID) 38 | _, err = s3Client.PutObject(&s3.PutObjectInput{ 39 | Bucket: aws.String(bucket), 40 | Key: aws.String(coordinationKey), 41 | Body: strings.NewReader(string(coordinationData)), 42 | }) 43 | if err != nil { 44 | return fmt.Errorf("failed to write coordination data to S3: %w", err) 45 | } 46 | 47 | return nil 48 | } 49 | 50 | // GetCoordinationData reads and parses coordination data from S3 51 | func GetCoordinationData(s3Client *s3.S3, bucket, key string) (*CoordinationData, error) { 52 | obj, err := s3Client.GetObject(&s3.GetObjectInput{ 53 | Bucket: aws.String(bucket), 54 | Key: aws.String(key), 55 | }) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to read S3 object: %w", err) 58 | } 59 | defer obj.Body.Close() 60 | 61 | var coord CoordinationData 62 | if err := json.NewDecoder(obj.Body).Decode(&coord); err != nil { 63 | return nil, fmt.Errorf("failed to decode coordination data: %w", err) 64 | } 65 | 66 | return &coord, nil 67 | } 68 | 69 | // PutLambdaResponse writes lambda response data to S3 70 | func PutLambdaResponse(s3Client *s3.S3, bucket, sessionID string, response LambdaResponse) error { 71 | responseData, err := json.Marshal(response) 72 | if err != nil { 73 | return fmt.Errorf("failed to marshal lambda response: %w", err) 74 | } 75 | 76 | responseKey := fmt.Sprintf(ResponseKeyPattern, sessionID) 77 | _, err = s3Client.PutObject(&s3.PutObjectInput{ 78 | Bucket: aws.String(bucket), 79 | Key: aws.String(responseKey), 80 | Body: strings.NewReader(string(responseData)), 81 | }) 82 | if err != nil { 83 | return fmt.Errorf("failed to write lambda response to S3: %w", err) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | // WaitForS3Object polls for an S3 object until it exists or timeout is reached 90 | func WaitForS3Object(s3Client *s3.S3, bucket, key string, timeout time.Duration) ([]byte, error) { 91 | deadline := time.Now().Add(timeout) 92 | 93 | for time.Now().Before(deadline) { 94 | obj, err := s3Client.GetObject(&s3.GetObjectInput{ 95 | Bucket: aws.String(bucket), 96 | Key: aws.String(key), 97 | }) 98 | if err == nil { 99 | defer obj.Body.Close() 100 | // Object exists, read and return content 101 | content := make([]byte, 0) 102 | buffer := make([]byte, 1024) 103 | for { 104 | n, readErr := obj.Body.Read(buffer) 105 | if n > 0 { 106 | content = append(content, buffer[:n]...) 107 | } 108 | if readErr != nil { 109 | break 110 | } 111 | } 112 | return content, nil 113 | } 114 | 115 | // Sleep before next poll 116 | time.Sleep(DefaultPollingInterval) 117 | } 118 | 119 | return nil, fmt.Errorf("timeout waiting for S3 object %s/%s", bucket, key) 120 | } 121 | 122 | // GetLambdaResponse reads and parses lambda response data from S3 123 | func GetLambdaResponse(s3Client *s3.S3, bucket, sessionID string) (*LambdaResponse, error) { 124 | responseKey := fmt.Sprintf(ResponseKeyPattern, sessionID) 125 | 126 | obj, err := s3Client.GetObject(&s3.GetObjectInput{ 127 | Bucket: aws.String(bucket), 128 | Key: aws.String(responseKey), 129 | }) 130 | if err != nil { 131 | return nil, fmt.Errorf("failed to read lambda response from S3: %w", err) 132 | } 133 | defer obj.Body.Close() 134 | 135 | var response LambdaResponse 136 | if err := json.NewDecoder(obj.Body).Decode(&response); err != nil { 137 | return nil, fmt.Errorf("failed to decode lambda response: %w", err) 138 | } 139 | 140 | return &response, nil 141 | } -------------------------------------------------------------------------------- /pkg/shared/constants.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import "time" 4 | 5 | // Network constants 6 | const ( 7 | DefaultAWSRegion = "us-west-2" 8 | DefaultSOCKS5Port = 1080 9 | DefaultSTUNServer = "stun.l.google.com:19302" 10 | DefaultSocketReleaseDelay = 100 * time.Millisecond 11 | ) 12 | 13 | // Timeout constants 14 | const ( 15 | DefaultLambdaResponseTimeout = 10 * time.Second 16 | DefaultNATHolePunchTimeout = 6 * time.Second 17 | DefaultConnectionTimeout = 10 * time.Second 18 | DefaultPollingInterval = 500 * time.Millisecond 19 | HolePunchInterval = 100 * time.Millisecond 20 | ResponsePollInterval = 500 * time.Millisecond 21 | UDPReadTimeout = 200 * time.Millisecond 22 | ) 23 | 24 | // NAT traversal constants 25 | const ( 26 | HolePunchPacketCount = 50 27 | UDPBufferSize = 1500 28 | MaxTargetAddressLength = 1024 29 | ) 30 | 31 | // Buffer size constants (mode-aware defaults) 32 | const ( 33 | OptimizedBufferSize = 32 * 1024 // 32KB default, overridden by mode 34 | ) 35 | 36 | // S3 key patterns 37 | const ( 38 | CoordinationKeyPattern = "coordination/%s.json" 39 | ResponseKeyPattern = "punch-response/%s.json" 40 | ) 41 | 42 | // SOCKS5 protocol constants 43 | const ( 44 | SOCKS5Version = 0x05 45 | SOCKS5Connect = 0x01 46 | SOCKS5NoAuth = 0x00 47 | SOCKS5Success = 0x00 48 | SOCKS5Failed = 0x01 49 | SOCKS5IPv4 = 0x01 50 | SOCKS5DomainName = 0x03 51 | ) 52 | 53 | // TLS certificate constants 54 | const ( 55 | TLSKeyBits = 2048 56 | CertValidityPeriod = 365 * 24 * time.Hour 57 | ) 58 | 59 | // QUIC performance constants (mode-aware) 60 | const ( 61 | // Base QUIC settings (scaled by mode) 62 | QUICBaseStreamReceiveWindow = 16 * 1024 * 1024 // 16MB per stream (base) 63 | QUICBaseConnectionReceiveWindow = 64 * 1024 * 1024 // 64MB per connection (base) 64 | 65 | // Default QUIC settings 66 | QUICHandshakeTimeout = 10 * time.Second 67 | QUICMaxIncomingUniStreams = 100 68 | ) 69 | 70 | // Legacy QUIC constants (will be replaced by mode-based config) 71 | const ( 72 | QUICInitialStreamReceiveWindow = 16 * 1024 * 1024 // 16MB per stream 73 | QUICMaxStreamReceiveWindow = 32 * 1024 * 1024 // 32MB max per stream 74 | QUICInitialConnectionReceiveWindow = 64 * 1024 * 1024 // 64MB initial connection 75 | QUICMaxConnectionReceiveWindow = 128 * 1024 * 1024 // 128MB max connection 76 | QUICMaxIncomingStreams = 1000 // Max concurrent streams 77 | QUICIdleTimeout = 5 * time.Minute // Connection idle timeout 78 | QUICKeepAlive = 30 * time.Second // Keep-alive period 79 | ) 80 | 81 | // GetQUICConfig returns QUIC configuration values based on buffer size and max streams 82 | func GetQUICConfig(bufferSize, maxStreams int) (streamWindow, connWindow, maxIncomingStreams, maxIncomingUniStreams int64) { 83 | // Scale flow control windows based on buffer size 84 | scale := float64(bufferSize) / float64(8*1024) // 8KB is base 85 | if scale < 1 { 86 | scale = 1 87 | } 88 | 89 | streamWindow = int64(float64(QUICBaseStreamReceiveWindow) * scale) 90 | connWindow = int64(float64(QUICBaseConnectionReceiveWindow) * scale) 91 | maxIncomingStreams = int64(maxStreams) 92 | maxIncomingUniStreams = int64(QUICMaxIncomingUniStreams) 93 | 94 | return 95 | } 96 | 97 | // SOCKS5 response templates 98 | var ( 99 | SOCKS5AuthResponse = []byte{SOCKS5Version, SOCKS5NoAuth} 100 | SOCKS5SuccessResponse = []byte{SOCKS5Version, SOCKS5Success, 0x00, SOCKS5IPv4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} 101 | SOCKS5FailureResponse = []byte{SOCKS5Version, SOCKS5Failed, 0x00, SOCKS5IPv4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} 102 | ) -------------------------------------------------------------------------------- /pkg/shared/control.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | // Control message opcodes 10 | const ( 11 | OpPing byte = 0x01 12 | OpPong byte = 0x02 13 | OpShutdown byte = 0x03 14 | ) 15 | 16 | // Ping represents a ping message with a nonce 17 | type Ping struct { 18 | Nonce uint64 19 | } 20 | 21 | // WritePing writes a ping message to the writer 22 | func WritePing(w io.Writer, nonce uint64) error { 23 | if err := writeByte(w, OpPing); err != nil { 24 | return fmt.Errorf("failed to write ping opcode: %w", err) 25 | } 26 | if err := writeUint64(w, nonce); err != nil { 27 | return fmt.Errorf("failed to write ping nonce: %w", err) 28 | } 29 | return nil 30 | } 31 | 32 | // WritePong writes a pong message to the writer 33 | func WritePong(w io.Writer, nonce uint64) error { 34 | if err := writeByte(w, OpPong); err != nil { 35 | return fmt.Errorf("failed to write pong opcode: %w", err) 36 | } 37 | if err := writeUint64(w, nonce); err != nil { 38 | return fmt.Errorf("failed to write pong nonce: %w", err) 39 | } 40 | return nil 41 | } 42 | 43 | // WriteShutdown writes a shutdown message to the writer 44 | func WriteShutdown(w io.Writer) error { 45 | return writeByte(w, OpShutdown) 46 | } 47 | 48 | // ReadControlMessage reads a control message from the reader 49 | func ReadControlMessage(r io.Reader) (opcode byte, nonce uint64, err error) { 50 | opcode, err = readByte(r) 51 | if err != nil { 52 | return 0, 0, fmt.Errorf("failed to read opcode: %w", err) 53 | } 54 | 55 | switch opcode { 56 | case OpPing, OpPong: 57 | nonce, err = readUint64(r) 58 | if err != nil { 59 | return opcode, 0, fmt.Errorf("failed to read nonce: %w", err) 60 | } 61 | case OpShutdown: 62 | // No additional data for shutdown 63 | default: 64 | return opcode, 0, fmt.Errorf("unknown opcode: %02x", opcode) 65 | } 66 | 67 | return opcode, nonce, nil 68 | } 69 | 70 | // Helper functions for reading/writing 71 | func writeByte(w io.Writer, b byte) error { 72 | _, err := w.Write([]byte{b}) 73 | return err 74 | } 75 | 76 | func writeUint64(w io.Writer, v uint64) error { 77 | buf := make([]byte, 8) 78 | binary.BigEndian.PutUint64(buf, v) 79 | _, err := w.Write(buf) 80 | return err 81 | } 82 | 83 | func readByte(r io.Reader) (byte, error) { 84 | buf := make([]byte, 1) 85 | _, err := io.ReadFull(r, buf) 86 | return buf[0], err 87 | } 88 | 89 | func readUint64(r io.Reader) (uint64, error) { 90 | buf := make([]byte, 8) 91 | _, err := io.ReadFull(r, buf) 92 | if err != nil { 93 | return 0, err 94 | } 95 | return binary.BigEndian.Uint64(buf), nil 96 | } -------------------------------------------------------------------------------- /pkg/shared/control_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestPingPongRoundTrip(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | nonce uint64 12 | }{ 13 | {"zero nonce", 0}, 14 | {"small nonce", 42}, 15 | {"large nonce", 0xDEADBEEFCAFEBABE}, 16 | } 17 | 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | // Write ping 21 | var buf bytes.Buffer 22 | if err := WritePing(&buf, tt.nonce); err != nil { 23 | t.Fatalf("WritePing failed: %v", err) 24 | } 25 | 26 | // Read it back 27 | opcode, nonce, err := ReadControlMessage(&buf) 28 | if err != nil { 29 | t.Fatalf("ReadControlMessage failed: %v", err) 30 | } 31 | 32 | if opcode != OpPing { 33 | t.Errorf("Expected OpPing (0x%02x), got 0x%02x", OpPing, opcode) 34 | } 35 | if nonce != tt.nonce { 36 | t.Errorf("Expected nonce %d, got %d", tt.nonce, nonce) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func TestPongMessage(t *testing.T) { 43 | var buf bytes.Buffer 44 | testNonce := uint64(12345) 45 | 46 | // Write pong 47 | if err := WritePong(&buf, testNonce); err != nil { 48 | t.Fatalf("WritePong failed: %v", err) 49 | } 50 | 51 | // Read it back 52 | opcode, nonce, err := ReadControlMessage(&buf) 53 | if err != nil { 54 | t.Fatalf("ReadControlMessage failed: %v", err) 55 | } 56 | 57 | if opcode != OpPong { 58 | t.Errorf("Expected OpPong (0x%02x), got 0x%02x", OpPong, opcode) 59 | } 60 | if nonce != testNonce { 61 | t.Errorf("Expected nonce %d, got %d", testNonce, nonce) 62 | } 63 | } 64 | 65 | func TestShutdownMessage(t *testing.T) { 66 | var buf bytes.Buffer 67 | 68 | // Write shutdown 69 | if err := WriteShutdown(&buf); err != nil { 70 | t.Fatalf("WriteShutdown failed: %v", err) 71 | } 72 | 73 | // Read it back 74 | opcode, _, err := ReadControlMessage(&buf) 75 | if err != nil { 76 | t.Fatalf("ReadControlMessage failed: %v", err) 77 | } 78 | 79 | if opcode != OpShutdown { 80 | t.Errorf("Expected OpShutdown (0x%02x), got 0x%02x", OpShutdown, opcode) 81 | } 82 | } 83 | 84 | func TestUnknownOpcode(t *testing.T) { 85 | var buf bytes.Buffer 86 | 87 | // Write invalid opcode 88 | buf.WriteByte(0xFF) 89 | 90 | // Should fail to read 91 | _, _, err := ReadControlMessage(&buf) 92 | if err == nil { 93 | t.Error("Expected error for unknown opcode, got nil") 94 | } 95 | } -------------------------------------------------------------------------------- /pkg/shared/errors.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "time" 8 | ) 9 | 10 | // LogError logs an error with consistent formatting and emoji prefix 11 | func LogError(operation string, err error) { 12 | msg := fmt.Sprintf("❌ %s: %v", operation, err) 13 | GetLogger().Error(msg, 14 | slog.String("operation", operation), 15 | slog.String("error", err.Error()), 16 | slog.Time("timestamp", time.Now()), 17 | ) 18 | } 19 | 20 | // LogErrorf logs a formatted error message with emoji prefix 21 | func LogErrorf(format string, args ...interface{}) { 22 | msg := fmt.Sprintf("❌ "+format, args...) 23 | GetLogger().Error(msg, 24 | slog.String("formatted_message", fmt.Sprintf(format, args...)), 25 | slog.Time("timestamp", time.Now()), 26 | ) 27 | } 28 | 29 | // LogSuccess logs a success message with emoji prefix 30 | func LogSuccess(operation string, details ...interface{}) { 31 | var msg string 32 | attrs := []slog.Attr{ 33 | slog.String("operation", operation), 34 | slog.Time("timestamp", time.Now()), 35 | } 36 | 37 | if len(details) > 0 { 38 | detailStr := fmt.Sprint(details...) 39 | msg = fmt.Sprintf("✅ %s: %v", operation, detailStr) 40 | attrs = append(attrs, slog.String("details", detailStr)) 41 | } else { 42 | msg = fmt.Sprintf("✅ %s", operation) 43 | } 44 | 45 | GetLogger().LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...) 46 | } 47 | 48 | // LogSuccessf logs a formatted success message with emoji prefix 49 | func LogSuccessf(format string, args ...interface{}) { 50 | msg := fmt.Sprintf("✅ "+format, args...) 51 | GetLogger().Info(msg, 52 | slog.String("formatted_message", fmt.Sprintf(format, args...)), 53 | slog.Time("timestamp", time.Now()), 54 | ) 55 | } 56 | 57 | // LogInfo logs an informational message with emoji prefix 58 | func LogInfo(operation string, details ...interface{}) { 59 | var msg string 60 | attrs := []slog.Attr{ 61 | slog.String("operation", operation), 62 | slog.Time("timestamp", time.Now()), 63 | } 64 | 65 | if len(details) > 0 { 66 | detailStr := fmt.Sprint(details...) 67 | msg = fmt.Sprintf("ℹ️ %s: %v", operation, detailStr) 68 | attrs = append(attrs, slog.String("details", detailStr)) 69 | } else { 70 | msg = fmt.Sprintf("ℹ️ %s", operation) 71 | } 72 | 73 | GetLogger().LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...) 74 | } 75 | 76 | // LogInfof logs a formatted informational message with emoji prefix 77 | func LogInfof(format string, args ...interface{}) { 78 | msg := fmt.Sprintf("ℹ️ "+format, args...) 79 | GetLogger().Info(msg, 80 | slog.String("formatted_message", fmt.Sprintf(format, args...)), 81 | slog.Time("timestamp", time.Now()), 82 | ) 83 | } 84 | 85 | // LogProgress logs a progress/activity message with emoji prefix 86 | func LogProgress(operation string, details ...interface{}) { 87 | var msg string 88 | attrs := []slog.Attr{ 89 | slog.String("operation", operation), 90 | slog.Time("timestamp", time.Now()), 91 | } 92 | 93 | if len(details) > 0 { 94 | detailStr := fmt.Sprint(details...) 95 | msg = fmt.Sprintf("🔄 %s: %v", operation, detailStr) 96 | attrs = append(attrs, slog.String("details", detailStr)) 97 | } else { 98 | msg = fmt.Sprintf("🔄 %s", operation) 99 | } 100 | 101 | GetLogger().LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...) 102 | } 103 | 104 | // LogProgressf logs a formatted progress message with emoji prefix 105 | func LogProgressf(format string, args ...interface{}) { 106 | msg := fmt.Sprintf("🔄 "+format, args...) 107 | GetLogger().Info(msg, 108 | slog.String("formatted_message", fmt.Sprintf(format, args...)), 109 | slog.Time("timestamp", time.Now()), 110 | ) 111 | } 112 | 113 | // LogTarget logs a target/action message with emoji prefix 114 | func LogTarget(operation string, details ...interface{}) { 115 | var msg string 116 | attrs := []slog.Attr{ 117 | slog.String("operation", operation), 118 | slog.Time("timestamp", time.Now()), 119 | } 120 | 121 | if len(details) > 0 { 122 | detailStr := fmt.Sprint(details...) 123 | msg = fmt.Sprintf("🎯 %s: %v", operation, detailStr) 124 | attrs = append(attrs, slog.String("details", detailStr)) 125 | } else { 126 | msg = fmt.Sprintf("🎯 %s", operation) 127 | } 128 | 129 | GetLogger().LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...) 130 | } 131 | 132 | // LogTargetf logs a formatted target message with emoji prefix 133 | func LogTargetf(format string, args ...interface{}) { 134 | msg := fmt.Sprintf("🎯 "+format, args...) 135 | GetLogger().Info(msg, 136 | slog.String("formatted_message", fmt.Sprintf(format, args...)), 137 | slog.Time("timestamp", time.Now()), 138 | ) 139 | } 140 | 141 | // LogNetwork logs a network-related message with emoji prefix 142 | func LogNetwork(operation string, details ...interface{}) { 143 | var msg string 144 | attrs := []slog.Attr{ 145 | slog.String("operation", operation), 146 | slog.Time("timestamp", time.Now()), 147 | } 148 | 149 | if len(details) > 0 { 150 | detailStr := fmt.Sprint(details...) 151 | msg = fmt.Sprintf("🌐 %s: %v", operation, detailStr) 152 | attrs = append(attrs, slog.String("details", detailStr)) 153 | } else { 154 | msg = fmt.Sprintf("🌐 %s", operation) 155 | } 156 | 157 | GetLogger().LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...) 158 | } 159 | 160 | // LogNetworkf logs a formatted network message with emoji prefix 161 | func LogNetworkf(format string, args ...interface{}) { 162 | msg := fmt.Sprintf("🌐 "+format, args...) 163 | GetLogger().Info(msg, 164 | slog.String("formatted_message", fmt.Sprintf(format, args...)), 165 | slog.Time("timestamp", time.Now()), 166 | ) 167 | } 168 | 169 | // LogConnection logs a connection-related message with emoji prefix 170 | func LogConnection(operation string, details ...interface{}) { 171 | var msg string 172 | attrs := []slog.Attr{ 173 | slog.String("operation", operation), 174 | slog.Time("timestamp", time.Now()), 175 | } 176 | 177 | if len(details) > 0 { 178 | detailStr := fmt.Sprint(details...) 179 | msg = fmt.Sprintf("🔗 %s: %v", operation, detailStr) 180 | attrs = append(attrs, slog.String("details", detailStr)) 181 | } else { 182 | msg = fmt.Sprintf("🔗 %s", operation) 183 | } 184 | 185 | GetLogger().LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...) 186 | } 187 | 188 | // LogConnectionf logs a formatted connection message with emoji prefix 189 | func LogConnectionf(format string, args ...interface{}) { 190 | msg := fmt.Sprintf("🔗 "+format, args...) 191 | GetLogger().Info(msg, 192 | slog.String("formatted_message", fmt.Sprintf(format, args...)), 193 | slog.Time("timestamp", time.Now()), 194 | ) 195 | } 196 | 197 | // LogStorage logs a storage-related message with emoji prefix 198 | func LogStorage(operation string, details ...interface{}) { 199 | var msg string 200 | attrs := []slog.Attr{ 201 | slog.String("operation", operation), 202 | slog.Time("timestamp", time.Now()), 203 | } 204 | 205 | if len(details) > 0 { 206 | detailStr := fmt.Sprint(details...) 207 | msg = fmt.Sprintf("📂 %s: %v", operation, detailStr) 208 | attrs = append(attrs, slog.String("details", detailStr)) 209 | } else { 210 | msg = fmt.Sprintf("📂 %s", operation) 211 | } 212 | 213 | GetLogger().LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...) 214 | } 215 | 216 | // LogStoragef logs a formatted storage message with emoji prefix 217 | func LogStoragef(format string, args ...interface{}) { 218 | msg := fmt.Sprintf("📂 "+format, args...) 219 | GetLogger().Info(msg, 220 | slog.String("formatted_message", fmt.Sprintf(format, args...)), 221 | slog.Time("timestamp", time.Now()), 222 | ) 223 | } 224 | 225 | // LogClose logs a closure/end message with emoji prefix 226 | func LogClose(operation string, details ...interface{}) { 227 | var msg string 228 | attrs := []slog.Attr{ 229 | slog.String("operation", operation), 230 | slog.Time("timestamp", time.Now()), 231 | } 232 | 233 | if len(details) > 0 { 234 | detailStr := fmt.Sprint(details...) 235 | msg = fmt.Sprintf("🔚 %s: %v", operation, detailStr) 236 | attrs = append(attrs, slog.String("details", detailStr)) 237 | } else { 238 | msg = fmt.Sprintf("🔚 %s", operation) 239 | } 240 | 241 | GetLogger().LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...) 242 | } 243 | 244 | // LogClosef logs a formatted closure message with emoji prefix 245 | func LogClosef(format string, args ...interface{}) { 246 | msg := fmt.Sprintf("🔚 "+format, args...) 247 | GetLogger().Info(msg, 248 | slog.String("formatted_message", fmt.Sprintf(format, args...)), 249 | slog.Time("timestamp", time.Now()), 250 | ) 251 | } 252 | 253 | // WrapError wraps an error with additional context 254 | func WrapError(err error, operation string) error { 255 | if err == nil { 256 | return nil 257 | } 258 | return fmt.Errorf("%s: %w", operation, err) 259 | } 260 | 261 | // WrapErrorf wraps an error with formatted additional context 262 | func WrapErrorf(err error, format string, args ...interface{}) error { 263 | if err == nil { 264 | return nil 265 | } 266 | return fmt.Errorf(format+": %w", append(args, err)...) 267 | } -------------------------------------------------------------------------------- /pkg/shared/logger.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | "time" 8 | ) 9 | 10 | var ( 11 | // Global structured logger 12 | logger *slog.Logger 13 | 14 | // Log levels 15 | LevelDebug = slog.LevelDebug 16 | LevelInfo = slog.LevelInfo 17 | LevelWarn = slog.LevelWarn 18 | LevelError = slog.LevelError 19 | ) 20 | 21 | // LogConfig holds configuration for the logger 22 | type LogConfig struct { 23 | Level slog.Level 24 | Format string // "json" or "text" 25 | AddSource bool 26 | ServiceName string 27 | } 28 | 29 | // DefaultLogConfig returns a default logger configuration 30 | func DefaultLogConfig() *LogConfig { 31 | return &LogConfig{ 32 | Level: slog.LevelInfo, 33 | Format: "text", 34 | AddSource: false, 35 | ServiceName: "lambda-nat-proxy", 36 | } 37 | } 38 | 39 | // InitLogger initializes the structured logger 40 | func InitLogger(config *LogConfig) { 41 | if config == nil { 42 | config = DefaultLogConfig() 43 | } 44 | 45 | var handler slog.Handler 46 | 47 | opts := &slog.HandlerOptions{ 48 | Level: config.Level, 49 | AddSource: config.AddSource, 50 | } 51 | 52 | if config.Format == "json" { 53 | handler = slog.NewJSONHandler(os.Stdout, opts) 54 | } else { 55 | handler = slog.NewTextHandler(os.Stdout, opts) 56 | } 57 | 58 | logger = slog.New(handler).With( 59 | "service", config.ServiceName, 60 | "version", "1.0.0", 61 | ) 62 | 63 | // Set as default logger 64 | slog.SetDefault(logger) 65 | } 66 | 67 | // GetLogger returns the global structured logger 68 | func GetLogger() *slog.Logger { 69 | if logger == nil { 70 | InitLogger(nil) // Initialize with defaults 71 | } 72 | return logger 73 | } 74 | 75 | // Structured logging functions with context support 76 | 77 | // LogWithContext logs a message with context and structured fields 78 | func LogWithContext(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) { 79 | GetLogger().LogAttrs(ctx, level, msg, attrs...) 80 | } 81 | 82 | // LogDebug logs a debug message with structured fields 83 | func LogDebug(msg string, attrs ...slog.Attr) { 84 | GetLogger().LogAttrs(context.Background(), slog.LevelDebug, msg, attrs...) 85 | } 86 | 87 | // StructuredInfo logs an info message with structured fields 88 | func StructuredInfo(msg string, attrs ...slog.Attr) { 89 | GetLogger().LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...) 90 | } 91 | 92 | // StructuredWarn logs a warning message with structured fields 93 | func StructuredWarn(msg string, attrs ...slog.Attr) { 94 | GetLogger().LogAttrs(context.Background(), slog.LevelWarn, msg, attrs...) 95 | } 96 | 97 | // StructuredError logs an error message with structured fields 98 | func StructuredError(msg string, attrs ...slog.Attr) { 99 | GetLogger().LogAttrs(context.Background(), slog.LevelError, msg, attrs...) 100 | } 101 | 102 | // Convenience functions for common operations 103 | 104 | // LogErrorWithDetails logs an error with operation context 105 | func LogErrorWithDetails(operation string, err error, attrs ...slog.Attr) { 106 | allAttrs := append([]slog.Attr{ 107 | slog.String("operation", operation), 108 | slog.String("error", err.Error()), 109 | slog.Time("timestamp", time.Now()), 110 | }, attrs...) 111 | StructuredError("Operation failed", allAttrs...) 112 | } 113 | 114 | // LogSuccessWithDetails logs a successful operation 115 | func LogSuccessWithDetails(operation string, attrs ...slog.Attr) { 116 | allAttrs := append([]slog.Attr{ 117 | slog.String("operation", operation), 118 | slog.Time("timestamp", time.Now()), 119 | }, attrs...) 120 | StructuredInfo("Operation completed successfully", allAttrs...) 121 | } 122 | 123 | // LogConnectionEvent logs connection-related events 124 | func LogConnectionEvent(event string, remote string, attrs ...slog.Attr) { 125 | allAttrs := append([]slog.Attr{ 126 | slog.String("event", event), 127 | slog.String("remote_addr", remote), 128 | slog.Time("timestamp", time.Now()), 129 | }, attrs...) 130 | StructuredInfo("Connection event", allAttrs...) 131 | } 132 | 133 | // LogMetrics logs performance metrics 134 | func LogMetrics(component string, metrics map[string]interface{}) { 135 | attrs := []slog.Attr{ 136 | slog.String("component", component), 137 | slog.Time("timestamp", time.Now()), 138 | } 139 | 140 | for key, value := range metrics { 141 | switch v := value.(type) { 142 | case string: 143 | attrs = append(attrs, slog.String(key, v)) 144 | case int: 145 | attrs = append(attrs, slog.Int(key, v)) 146 | case int64: 147 | attrs = append(attrs, slog.Int64(key, v)) 148 | case float64: 149 | attrs = append(attrs, slog.Float64(key, v)) 150 | case bool: 151 | attrs = append(attrs, slog.Bool(key, v)) 152 | case time.Duration: 153 | attrs = append(attrs, slog.Duration(key, v)) 154 | default: 155 | attrs = append(attrs, slog.Any(key, v)) 156 | } 157 | } 158 | 159 | StructuredInfo("Performance metrics", attrs...) 160 | } 161 | 162 | // SetLogLevel dynamically sets the log level 163 | func SetLogLevel(level slog.Level) { 164 | // Note: slog doesn't support dynamic level changes easily 165 | // This would require reinitializing the handler 166 | config := DefaultLogConfig() 167 | config.Level = level 168 | InitLogger(config) 169 | } -------------------------------------------------------------------------------- /pkg/shared/nat.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // PerformNATHolePunch performs NAT hole punching between two UDP endpoints 12 | func PerformNATHolePunch(conn *net.UDPConn, sessionID string, remoteAddr *net.UDPAddr, timeout time.Duration, isServer bool) error { 13 | role := "client" 14 | if isServer { 15 | role = "server" 16 | } 17 | log.Printf("🔨 [%s] Starting NAT hole punching to %s", role, remoteAddr) 18 | 19 | // Send punch packets 20 | punchDone := make(chan bool) 21 | go func() { 22 | for i := 0; i < HolePunchPacketCount; i++ { 23 | message := fmt.Sprintf("PUNCH:%s:%d", sessionID, i) 24 | conn.WriteToUDP([]byte(message), remoteAddr) 25 | time.Sleep(HolePunchInterval) 26 | } 27 | close(punchDone) 28 | }() 29 | 30 | // Listen for remote's punch packets 31 | successChan := make(chan bool) 32 | go func() { 33 | buf := make([]byte, UDPBufferSize) 34 | for { 35 | conn.SetReadDeadline(time.Now().Add(UDPReadTimeout)) 36 | n, addr, err := conn.ReadFromUDP(buf) 37 | 38 | if err == nil && addr.IP.Equal(remoteAddr.IP) && addr.Port == remoteAddr.Port { 39 | data := string(buf[:n]) 40 | if strings.HasPrefix(data, "PUNCH:") { 41 | log.Printf("✅ [%s] Received punch packet from remote: %s", role, data) 42 | successChan <- true 43 | return 44 | } 45 | } 46 | } 47 | }() 48 | 49 | // Wait for success or timeout 50 | select { 51 | case <-successChan: 52 | <-punchDone // Wait for sender to finish 53 | conn.SetReadDeadline(time.Time{}) // Clear deadline 54 | return nil 55 | case <-time.After(timeout): 56 | conn.SetReadDeadline(time.Time{}) // Clear deadline 57 | return fmt.Errorf("NAT hole punching timeout") 58 | } 59 | } 60 | 61 | // CreateUDPSocket creates a UDP socket for NAT traversal 62 | func CreateUDPSocket() (*net.UDPConn, int, error) { 63 | conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: 0}) 64 | if err != nil { 65 | return nil, 0, fmt.Errorf("failed to create UDP socket: %w", err) 66 | } 67 | 68 | port := conn.LocalAddr().(*net.UDPAddr).Port 69 | return conn, port, nil 70 | } -------------------------------------------------------------------------------- /pkg/shared/socks5.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "io" 7 | "net" 8 | "time" 9 | ) 10 | 11 | // SOCKS5Response represents the response codes 12 | type SOCKS5Response byte 13 | 14 | const ( 15 | SOCKS5ResponseSuccess SOCKS5Response = 0x00 16 | SOCKS5ResponseError SOCKS5Response = 0x01 17 | ) 18 | 19 | // SOCKS5TargetRequest represents a parsed target request 20 | type SOCKS5TargetRequest struct { 21 | Address string 22 | Port uint16 23 | } 24 | 25 | // ReadSOCKS5TargetAddress reads the target address from a SOCKS5-style stream 26 | // Format: [4 bytes length][target address string] 27 | func ReadSOCKS5TargetAddress(stream io.Reader) (string, error) { 28 | // Read target address length (4 bytes, big endian) 29 | lengthBuf := make([]byte, 4) 30 | if _, err := io.ReadFull(stream, lengthBuf); err != nil { 31 | return "", fmt.Errorf("failed to read target length: %w", err) 32 | } 33 | 34 | targetLen := binary.BigEndian.Uint32(lengthBuf) 35 | if targetLen > MaxTargetAddressLength { 36 | return "", fmt.Errorf("target address too long: %d bytes (max %d)", targetLen, MaxTargetAddressLength) 37 | } 38 | 39 | // Read target address 40 | targetBuf := make([]byte, targetLen) 41 | if _, err := io.ReadFull(stream, targetBuf); err != nil { 42 | return "", fmt.Errorf("failed to read target address: %w", err) 43 | } 44 | 45 | target := string(targetBuf) 46 | 47 | // Basic validation 48 | if target == "" { 49 | return "", fmt.Errorf("empty target address") 50 | } 51 | 52 | return target, nil 53 | } 54 | 55 | // WriteSOCKS5Response writes a SOCKS5 response code to the stream 56 | func WriteSOCKS5Response(stream io.Writer, response SOCKS5Response) error { 57 | if _, err := stream.Write([]byte{byte(response)}); err != nil { 58 | return fmt.Errorf("failed to write SOCKS5 response: %w", err) 59 | } 60 | return nil 61 | } 62 | 63 | // WriteSOCKS5TargetAddress writes a target address in SOCKS5 format 64 | // Format: [4 bytes length][target address string] 65 | func WriteSOCKS5TargetAddress(stream io.Writer, target string) error { 66 | targetBytes := []byte(target) 67 | targetLen := uint32(len(targetBytes)) 68 | 69 | // Write length 70 | lengthBuf := make([]byte, 4) 71 | binary.BigEndian.PutUint32(lengthBuf, targetLen) 72 | 73 | if _, err := stream.Write(lengthBuf); err != nil { 74 | return fmt.Errorf("failed to write target length: %w", err) 75 | } 76 | 77 | // Write target address 78 | if _, err := stream.Write(targetBytes); err != nil { 79 | return fmt.Errorf("failed to write target address: %w", err) 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // ConnectToTarget establishes a TCP connection to the target address with timeout 86 | func ConnectToTarget(target string, timeout time.Duration) (net.Conn, error) { 87 | if timeout == 0 { 88 | timeout = DefaultConnectionTimeout 89 | } 90 | 91 | conn, err := net.DialTimeout("tcp", target, timeout) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed to connect to target %s: %w", target, err) 94 | } 95 | 96 | return conn, nil 97 | } 98 | 99 | // ForwardData handles bidirectional data forwarding between two connections 100 | func ForwardData(conn1, conn2 io.ReadWriteCloser) { 101 | // Start forwarding in both directions 102 | done := make(chan struct{}, 2) 103 | 104 | // conn1 -> conn2 105 | go func() { 106 | defer func() { done <- struct{}{} }() 107 | io.Copy(conn2, conn1) 108 | conn2.Close() 109 | }() 110 | 111 | // conn2 -> conn1 112 | go func() { 113 | defer func() { done <- struct{}{} }() 114 | io.Copy(conn1, conn2) 115 | conn1.Close() 116 | }() 117 | 118 | // Wait for one direction to complete 119 | <-done 120 | } 121 | 122 | // ValidateTargetAddress performs basic validation on a target address 123 | func ValidateTargetAddress(target string) error { 124 | if target == "" { 125 | return fmt.Errorf("empty target address") 126 | } 127 | 128 | if len(target) > MaxTargetAddressLength { 129 | return fmt.Errorf("target address too long: %d chars", len(target)) 130 | } 131 | 132 | // Try to parse as host:port 133 | host, port, err := net.SplitHostPort(target) 134 | if err != nil { 135 | return fmt.Errorf("invalid target format (expected host:port): %w", err) 136 | } 137 | 138 | if host == "" { 139 | return fmt.Errorf("empty host in target address") 140 | } 141 | 142 | if port == "" { 143 | return fmt.Errorf("empty port in target address") 144 | } 145 | 146 | return nil 147 | } -------------------------------------------------------------------------------- /pkg/shared/tls.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "crypto/x509/pkix" 9 | "fmt" 10 | "math/big" 11 | "net" 12 | "time" 13 | ) 14 | 15 | // TLSConfigOptions holds configuration options for TLS certificate generation 16 | type TLSConfigOptions struct { 17 | Organization string 18 | DNSNames []string 19 | IPAddresses []net.IP 20 | } 21 | 22 | // GenerateTLSConfig generates a TLS configuration with a self-signed certificate 23 | func GenerateTLSConfig(opts TLSConfigOptions) (*tls.Config, error) { 24 | // Generate private key 25 | key, err := rsa.GenerateKey(rand.Reader, TLSKeyBits) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to generate private key: %w", err) 28 | } 29 | 30 | // Set default values 31 | if opts.Organization == "" { 32 | opts.Organization = "QUIC Server" 33 | } 34 | if len(opts.IPAddresses) == 0 { 35 | opts.IPAddresses = []net.IP{net.IPv4(127, 0, 0, 1)} 36 | } 37 | 38 | // Create certificate template 39 | template := x509.Certificate{ 40 | SerialNumber: big.NewInt(1), 41 | Subject: pkix.Name{ 42 | Organization: []string{opts.Organization}, 43 | }, 44 | NotBefore: time.Now(), 45 | NotAfter: time.Now().Add(CertValidityPeriod), 46 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 47 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 48 | IPAddresses: opts.IPAddresses, 49 | DNSNames: opts.DNSNames, 50 | } 51 | 52 | // Generate certificate 53 | certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to create certificate: %w", err) 56 | } 57 | 58 | // Create TLS certificate 59 | cert := tls.Certificate{ 60 | Certificate: [][]byte{certDER}, 61 | PrivateKey: key, 62 | } 63 | 64 | return &tls.Config{ 65 | Certificates: []tls.Certificate{cert}, 66 | NextProtos: []string{"h3"}, 67 | }, nil 68 | } -------------------------------------------------------------------------------- /pkg/shared/types.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | // CoordinationData represents the coordination information sent from orchestrator to lambda 4 | type CoordinationData struct { 5 | SessionID string `json:"session_id"` 6 | LaptopPublicIP string `json:"laptop_public_ip"` 7 | LaptopPublicPort int `json:"laptop_public_port"` 8 | Timestamp int64 `json:"timestamp"` 9 | } 10 | 11 | // LambdaResponse represents the response sent from lambda back to orchestrator 12 | type LambdaResponse struct { 13 | SessionID string `json:"session_id"` 14 | LambdaPublicIP string `json:"lambda_public_ip"` 15 | LambdaPublicPort int `json:"lambda_public_port"` 16 | Status string `json:"status"` 17 | Timestamp int64 `json:"timestamp"` 18 | } -------------------------------------------------------------------------------- /pkg/shared/utils.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | // GenerateSessionID creates a unique session identifier 11 | func GenerateSessionID() string { 12 | bytes := make([]byte, 8) 13 | if _, err := rand.Read(bytes); err != nil { 14 | // This should never happen with crypto/rand, but handle gracefully 15 | // Fall back to a timestamp-based ID if crypto/rand fails 16 | LogError("Failed to generate cryptographic session ID, falling back to timestamp", err) 17 | return GenerateTimestampID() 18 | } 19 | return hex.EncodeToString(bytes) 20 | } 21 | 22 | // GenerateTimestampID creates a session ID based on current time as fallback 23 | func GenerateTimestampID() string { 24 | // Use nanosecond timestamp as fallback ID 25 | timestamp := time.Now().UnixNano() 26 | return hex.EncodeToString([]byte(fmt.Sprintf("%d", timestamp))) 27 | } -------------------------------------------------------------------------------- /scripts/build-dashboard.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Build Dashboard Script 4 | # This script builds the React frontend and prepares it for embedding 5 | 6 | set -e 7 | 8 | echo "🔨 Building React frontend..." 9 | cd web 10 | npm run build 11 | 12 | echo "📁 Copying build files to Go embed location..." 13 | cd .. 14 | rm -rf internal/dashboard/web/dist 15 | mkdir -p internal/dashboard/web 16 | cp -r web/dist internal/dashboard/web/ 17 | 18 | echo "✅ Dashboard frontend build complete!" 19 | echo "💡 Dashboard is now ready for embedding in the Go binary" -------------------------------------------------------------------------------- /scripts/docker-build-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Multi-platform Docker build script for lambda-nat-proxy 4 | # Builds binaries for multiple operating systems and architectures 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | # Build directory 16 | BUILD_DIR="build" 17 | 18 | # Platforms to build for (compatible with older bash) 19 | PLATFORMS=( 20 | "linux-amd64:linux:amd64" 21 | "linux-arm64:linux:arm64" 22 | "darwin-amd64:darwin:amd64" 23 | "darwin-arm64:darwin:arm64" 24 | "windows-amd64:windows:amd64" 25 | ) 26 | 27 | echo -e "${BLUE}🌍 Building lambda-nat-proxy for multiple platforms using Docker...${NC}" 28 | 29 | # Check if buildx is available 30 | BUILDX_AVAILABLE=false 31 | if docker buildx version >/dev/null 2>&1; then 32 | echo -e "${GREEN}✅ Docker buildx detected - using advanced multi-platform build${NC}" 33 | BUILDX_AVAILABLE=true 34 | else 35 | echo -e "${YELLOW}⚠️ Docker buildx not available - using sequential builds${NC}" 36 | echo -e "${YELLOW}💡 For faster builds, consider updating Docker to support buildx${NC}" 37 | fi 38 | 39 | # Create build directory 40 | mkdir -p ${BUILD_DIR} 41 | 42 | if [[ "$BUILDX_AVAILABLE" == "true" ]]; then 43 | # Use buildx for efficient multi-platform builds 44 | echo -e "${BLUE}🚀 Using Docker buildx for efficient multi-platform build...${NC}" 45 | 46 | # Create buildx builder if it doesn't exist 47 | docker buildx create --name multiarch-builder --use 2>/dev/null || docker buildx use multiarch-builder 2>/dev/null || true 48 | 49 | # Create multi-stage Dockerfile for cross-compilation 50 | cat > Dockerfile.multiarch << 'EOF' 51 | FROM --platform=$BUILDPLATFORM node:18-alpine AS dashboard-builder 52 | WORKDIR /app 53 | COPY web/package*.json ./web/ 54 | WORKDIR /app/web 55 | RUN npm ci --only=production 56 | COPY web/ . 57 | RUN npm run build 58 | 59 | FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS go-builder 60 | ARG TARGETOS 61 | ARG TARGETARCH 62 | RUN apk add --no-cache git 63 | WORKDIR /app 64 | 65 | # Copy all source code first (needed for replace directive) 66 | COPY . . 67 | COPY --from=dashboard-builder /app/web/dist ./internal/dashboard/web/dist 68 | 69 | # Fix the replace directive in lambda/go.mod to use absolute path 70 | RUN sed -i 's|replace github.com/dan-v/lambda-nat-punch-proxy => ..|replace github.com/dan-v/lambda-nat-punch-proxy => /app|' lambda/go.mod 71 | 72 | # Download dependencies (replace directive works now) 73 | RUN go mod download && cd lambda && go mod download 74 | 75 | # Build Lambda function (always Linux/amd64 for AWS) 76 | RUN cd lambda && GOOS=linux GOARCH=amd64 go build -o ../cmd/lambda-nat-proxy/assets/bootstrap . 77 | 78 | # Build main binary for target platform 79 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o lambda-nat-proxy ./cmd/lambda-nat-proxy 80 | 81 | FROM scratch 82 | COPY --from=go-builder /app/lambda-nat-proxy . 83 | COPY --from=go-builder /app/cmd/lambda-nat-proxy/assets/bootstrap ./bootstrap 84 | EOF 85 | 86 | # Build for each platform using buildx 87 | for platform_spec in "${PLATFORMS[@]}"; do 88 | IFS=':' read -r platform_key os arch <<< "$platform_spec" 89 | 90 | echo -e "${BLUE}🔨 Building for ${os}/${arch}...${NC}" 91 | 92 | binary_name="lambda-nat-proxy" 93 | if [[ "$os" == "windows" ]]; then 94 | binary_name="lambda-nat-proxy.exe" 95 | fi 96 | 97 | output_dir="${BUILD_DIR}/${platform_key}" 98 | mkdir -p "$output_dir" 99 | 100 | docker buildx build \ 101 | --platform "${os}/${arch}" \ 102 | --file Dockerfile.multiarch \ 103 | --output "type=local,dest=${output_dir}" \ 104 | . 105 | 106 | # Rename binary 107 | mv "${output_dir}/lambda-nat-proxy" "${output_dir}/${binary_name}" 2>/dev/null || true 108 | 109 | # Copy bootstrap (first time only) 110 | cp "${output_dir}/bootstrap" "${BUILD_DIR}/bootstrap" 2>/dev/null || true 111 | 112 | # Make executable (not needed for Windows) 113 | if [[ "$os" != "windows" ]]; then 114 | chmod +x "${output_dir}/${binary_name}" 115 | fi 116 | 117 | echo -e "${GREEN} ✅ Built: ${output_dir}/${binary_name}${NC}" 118 | done 119 | 120 | rm -f Dockerfile.multiarch 121 | 122 | else 123 | # Fallback: Build using standard Docker with GOOS/GOARCH 124 | echo -e "${BLUE}🔨 Using standard Docker builds with Go cross-compilation...${NC}" 125 | 126 | # Create standard Dockerfile for cross-compilation 127 | cat > Dockerfile.standard << 'EOF' 128 | FROM node:18-alpine AS dashboard-builder 129 | WORKDIR /app 130 | COPY web/package*.json ./web/ 131 | WORKDIR /app/web 132 | RUN npm ci --only=production 133 | COPY web/ . 134 | RUN npm run build 135 | 136 | FROM golang:1.21-alpine AS go-builder 137 | ARG TARGETOS 138 | ARG TARGETARCH 139 | RUN apk add --no-cache git 140 | WORKDIR /app 141 | 142 | # Copy all source code first (needed for replace directive) 143 | COPY . . 144 | COPY --from=dashboard-builder /app/web/dist ./internal/dashboard/web/dist 145 | 146 | # Fix the replace directive in lambda/go.mod to use absolute path 147 | RUN sed -i 's|replace github.com/dan-v/lambda-nat-punch-proxy => ..|replace github.com/dan-v/lambda-nat-punch-proxy => /app|' lambda/go.mod 148 | 149 | # Download dependencies (replace directive works now) 150 | RUN go mod download && cd lambda && go mod download 151 | 152 | # Build Lambda function (always Linux/amd64 for AWS) 153 | RUN cd lambda && GOOS=linux GOARCH=amd64 go build -o ../cmd/lambda-nat-proxy/assets/bootstrap . 154 | 155 | # Build main binary for target platform 156 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o lambda-nat-proxy ./cmd/lambda-nat-proxy 157 | EOF 158 | 159 | # Build for each platform 160 | for platform_spec in "${PLATFORMS[@]}"; do 161 | IFS=':' read -r platform_key os arch <<< "$platform_spec" 162 | 163 | echo -e "${BLUE}🔨 Building for ${os}/${arch}...${NC}" 164 | 165 | binary_name="lambda-nat-proxy" 166 | if [[ "$os" == "windows" ]]; then 167 | binary_name="lambda-nat-proxy.exe" 168 | fi 169 | 170 | output_dir="${BUILD_DIR}/${platform_key}" 171 | mkdir -p "$output_dir" 172 | 173 | # Build Docker image with target OS/ARCH 174 | docker build \ 175 | --build-arg TARGETOS="${os}" \ 176 | --build-arg TARGETARCH="${arch}" \ 177 | --file Dockerfile.standard \ 178 | --tag "lambda-nat-proxy-${platform_key}" \ 179 | . 180 | 181 | # Extract binaries from built image 182 | CONTAINER_ID=$(docker create "lambda-nat-proxy-${platform_key}") 183 | docker cp "${CONTAINER_ID}:/app/lambda-nat-proxy" "${output_dir}/${binary_name}" 184 | docker cp "${CONTAINER_ID}:/app/cmd/lambda-nat-proxy/assets/bootstrap" "${output_dir}/bootstrap" 2>/dev/null || true 185 | docker rm "${CONTAINER_ID}" 186 | docker rmi "lambda-nat-proxy-${platform_key}" 187 | 188 | # Copy bootstrap (first time only) 189 | cp "${output_dir}/bootstrap" "${BUILD_DIR}/bootstrap" 2>/dev/null || true 190 | 191 | # Make executable (not needed for Windows) 192 | if [[ "$os" != "windows" ]]; then 193 | chmod +x "${output_dir}/${binary_name}" 194 | fi 195 | 196 | echo -e "${GREEN} ✅ Built: ${output_dir}/${binary_name}${NC}" 197 | done 198 | 199 | rm -f Dockerfile.standard 200 | fi 201 | 202 | echo -e "${GREEN}🎉 Multi-platform build complete!${NC}" 203 | echo -e "${GREEN}📁 Binaries available in: ${BUILD_DIR}/*/lambda-nat-proxy*${NC}" 204 | echo "" 205 | echo -e "${BLUE}Built platforms:${NC}" 206 | for platform_spec in "${PLATFORMS[@]}"; do 207 | IFS=':' read -r platform_key os arch <<< "$platform_spec" 208 | binary_name="lambda-nat-proxy" 209 | if [[ "$os" == "windows" ]]; then 210 | binary_name="lambda-nat-proxy.exe" 211 | fi 212 | echo -e " • ${BUILD_DIR}/${platform_key}/${binary_name}" 213 | done 214 | echo -e " • ${BUILD_DIR}/bootstrap (Lambda function - Linux only)" 215 | 216 | # Detect current platform for run suggestion 217 | CURRENT_OS=$(uname -s | tr '[:upper:]' '[:lower:]') 218 | CURRENT_ARCH=$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') 219 | echo -e "${YELLOW}💡 To run on this system: ./${BUILD_DIR}/${CURRENT_OS}-${CURRENT_ARCH}/lambda-nat-proxy --help${NC}" -------------------------------------------------------------------------------- /scripts/docker-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Docker-based build script for lambda-nat-proxy 4 | # This script builds the entire project using Docker, eliminating host dependencies 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | # Build directory 16 | BUILD_DIR="build" 17 | 18 | echo -e "${BLUE}🐳 Building lambda-nat-proxy using Docker...${NC}" 19 | echo -e "${YELLOW}📦 This includes all dependencies (Node.js, Go, etc.)${NC}" 20 | 21 | # Create build directory 22 | mkdir -p ${BUILD_DIR} 23 | 24 | # Build using Docker 25 | # Detect host platform for building native binary 26 | HOST_OS=$(uname -s | tr '[:upper:]' '[:lower:]') 27 | HOST_ARCH=$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') 28 | 29 | echo -e "${BLUE}🔨 Building Docker image for ${HOST_OS}/${HOST_ARCH}...${NC}" 30 | 31 | # Build the Docker image with host platform targeting 32 | docker build \ 33 | --build-arg TARGETOS="${HOST_OS}" \ 34 | --build-arg TARGETARCH="${HOST_ARCH}" \ 35 | -f Dockerfile.build \ 36 | -t lambda-nat-proxy-builder \ 37 | . 38 | 39 | # Extract binaries from the built image 40 | echo -e "${BLUE}📤 Extracting built binaries...${NC}" 41 | 42 | # Create a temporary container and copy files 43 | CONTAINER_ID=$(docker create lambda-nat-proxy-builder) 44 | 45 | # Copy main binary 46 | docker cp ${CONTAINER_ID}:/root/lambda-nat-proxy ./${BUILD_DIR}/lambda-nat-proxy 47 | 48 | # Copy lambda bootstrap 49 | docker cp ${CONTAINER_ID}:/root/assets/bootstrap ./${BUILD_DIR}/bootstrap 50 | 51 | # Clean up container 52 | docker rm ${CONTAINER_ID} 53 | 54 | # Make binaries executable 55 | chmod +x ${BUILD_DIR}/lambda-nat-proxy 56 | chmod +x ${BUILD_DIR}/bootstrap 57 | 58 | echo -e "${GREEN}✅ Build complete!${NC}" 59 | echo -e "${GREEN}📁 Binaries available in: ${BUILD_DIR}/${NC}" 60 | echo "" 61 | echo -e "${BLUE}Built artifacts:${NC}" 62 | echo -e " • ${BUILD_DIR}/lambda-nat-proxy (CLI with embedded dashboard and Lambda)" 63 | echo -e " • ${BUILD_DIR}/bootstrap (Lambda function)" 64 | echo "" 65 | echo -e "${YELLOW}💡 To run: ./${BUILD_DIR}/lambda-nat-proxy --help${NC}" 66 | 67 | # Optional: Clean up Docker image 68 | if [[ "${CLEANUP:-yes}" == "yes" ]]; then 69 | echo -e "${BLUE}🧹 Cleaning up Docker image...${NC}" 70 | docker rmi lambda-nat-proxy-builder 71 | fi -------------------------------------------------------------------------------- /test/e2e/basic_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "os/exec" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | // TestBasicConnectivity tests that the proxy can be deployed and passes basic traffic 14 | func TestBasicConnectivity(t *testing.T) { 15 | if testing.Short() { 16 | t.Skip("Skipping e2e test in short mode") 17 | } 18 | 19 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 20 | defer cancel() 21 | 22 | // Step 1: Build lambda-nat-proxy 23 | t.Log("Building lambda-nat-proxy...") 24 | if err := buildLambdaProxy(); err != nil { 25 | t.Fatalf("Failed to build lambda-nat-proxy: %v", err) 26 | } 27 | 28 | // Step 2: Deploy infrastructure 29 | t.Log("Deploying infrastructure...") 30 | if err := deployInfrastructure(); err != nil { 31 | t.Fatalf("Failed to deploy infrastructure: %v", err) 32 | } 33 | 34 | // Step 3: Start proxy 35 | t.Log("Starting lambda-nat-proxy...") 36 | proxyCmd, err := startLambdaProxy(ctx) 37 | if err != nil { 38 | t.Fatalf("Failed to start lambda-nat-proxy: %v", err) 39 | } 40 | defer func() { 41 | if proxyCmd.Process != nil { 42 | proxyCmd.Process.Kill() 43 | } 44 | }() 45 | 46 | // Step 4: Wait for proxy to be ready 47 | t.Log("Waiting for proxy to be ready...") 48 | if err := waitForSOCKS5(ctx, 8080, 2*time.Minute); err != nil { 49 | t.Fatalf("Proxy failed to start: %v", err) 50 | } 51 | 52 | // Step 5: Test basic HTTP traffic through proxy 53 | t.Log("Testing HTTP traffic through proxy...") 54 | if err := testHTTPTraffic(); err != nil { 55 | t.Fatalf("HTTP traffic test failed: %v", err) 56 | } 57 | 58 | t.Log("✅ Basic connectivity test passed!") 59 | } 60 | 61 | // buildLambdaProxy builds the lambda-nat-proxy binary 62 | func buildLambdaProxy() error { 63 | cmd := exec.Command("make", "build") 64 | cmd.Dir = "../.." 65 | return cmd.Run() 66 | } 67 | 68 | // deployInfrastructure deploys AWS infrastructure using CLI 69 | func deployInfrastructure() error { 70 | cmd := exec.Command("./build/lambda-nat-proxy", "deploy") 71 | cmd.Dir = "../.." 72 | return cmd.Run() 73 | } 74 | 75 | // startLambdaProxy starts the lambda-nat-proxy process 76 | func startLambdaProxy(ctx context.Context) (*exec.Cmd, error) { 77 | cmd := exec.CommandContext(ctx, "./build/lambda-nat-proxy", "run", "--mode", "test") 78 | cmd.Dir = "../.." 79 | 80 | if err := cmd.Start(); err != nil { 81 | return nil, err 82 | } 83 | 84 | return cmd, nil 85 | } 86 | 87 | // waitForSOCKS5 waits for the SOCKS5 proxy to be available 88 | func waitForSOCKS5(ctx context.Context, port int, timeout time.Duration) error { 89 | deadline := time.Now().Add(timeout) 90 | 91 | for time.Now().Before(deadline) { 92 | select { 93 | case <-ctx.Done(): 94 | return ctx.Err() 95 | default: 96 | } 97 | 98 | // Try to connect to the SOCKS5 proxy 99 | proxyURL, _ := url.Parse("socks5://localhost:8080") 100 | client := &http.Client{ 101 | Transport: &http.Transport{ 102 | Proxy: http.ProxyURL(proxyURL), 103 | }, 104 | Timeout: 5 * time.Second, 105 | } 106 | 107 | // Try a simple HTTP request through the proxy 108 | resp, err := client.Get("http://httpbin.org/ip") 109 | if err == nil { 110 | resp.Body.Close() 111 | return nil 112 | } 113 | 114 | time.Sleep(5 * time.Second) 115 | } 116 | 117 | return context.DeadlineExceeded 118 | } 119 | 120 | // testHTTPTraffic tests that HTTP requests work through the proxy 121 | func testHTTPTraffic() error { 122 | // Set up HTTP client with SOCKS5 proxy 123 | proxyURL, _ := url.Parse("socks5://localhost:8080") 124 | client := &http.Client{ 125 | Transport: &http.Transport{ 126 | Proxy: http.ProxyURL(proxyURL), 127 | }, 128 | Timeout: 30 * time.Second, 129 | } 130 | 131 | // Test HTTP request 132 | resp, err := client.Get("http://httpbin.org/ip") 133 | if err != nil { 134 | return err 135 | } 136 | defer resp.Body.Close() 137 | 138 | // Read response to make sure it works 139 | _, err = io.ReadAll(resp.Body) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | if resp.StatusCode != 200 { 145 | return http.ErrNotSupported 146 | } 147 | 148 | return nil 149 | } -------------------------------------------------------------------------------- /test/e2e/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dan-v/lambda-nat-punch-proxy/test/e2e 2 | 3 | go 1.21 4 | 5 | replace github.com/dan-v/lambda-nat-punch-proxy => ../.. 6 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 |Unable to connect to Lambda NAT Proxy
17 |{error}
18 | 24 |Initializing secure tunnel...
39 |No active connections
117 |No destinations yet
121 |