├── .github └── workflows │ └── go-tests.yml ├── LICENCE ├── README.md ├── checks.go ├── cmd ├── config.yaml ├── main.go └── openapi.yaml ├── config-schema.json ├── dev ├── client │ └── main.go ├── otel │ ├── docker-compose.yaml │ └── otel-collector-config.yaml └── server │ └── main.go ├── gatego.go ├── go.mod ├── go.sum ├── handler.go ├── internal ├── config │ ├── config.go │ └── config_test.go ├── contextvalues │ ├── tracer.go │ └── version.go ├── handlers │ ├── balancer.go │ ├── balancer_test.go │ ├── files.go │ ├── files_test.go │ └── proxy.go └── middlewares │ ├── addheader.go │ ├── cache.go │ ├── cache_test.go │ ├── gzip.go │ ├── gzip_test.go │ ├── logging.go │ ├── logging_test.go │ ├── middleware.go │ ├── minify.go │ ├── minify_test.go │ ├── omit_headers.go │ ├── omit_headers_test.go │ ├── openapi.go │ ├── openapi_test.go │ ├── otel.go │ ├── ratelimit.go │ ├── ratelimit_test.go │ ├── responsecapture.go │ ├── security │ ├── routing_anomaly_score.go │ ├── routing_anomaly_score_test.go │ └── trackerhistory.go │ ├── sizelimiter.go │ ├── sizelimiter_test.go │ ├── timeout.go │ └── timeout_test.go ├── otel.go ├── pkg ├── cron │ ├── README.md │ ├── cron.go │ ├── cron_test.go │ ├── macros.go │ ├── schedule.go │ └── schedule_test.go ├── monitor │ ├── monitor.go │ └── monitor_test.go ├── multimux │ ├── multimux.go │ └── multimux_test.go ├── pathgraph │ └── pathgraph.go └── tracker │ ├── tracker.go │ └── tracker_test.go └── server.go /.github/workflows/go-tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.22' 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 yehoyada 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reverse Proxy Server 2 | 3 | [](https://github.com/hvuhsg/gatego/actions/workflows/go-tests.yml) 4 | 5 | ## Overview 6 | 7 | This reverse proxy server is designed to forward incoming requests to internal services, while offering advanced features such as SSL termination, rate limiting, content optimization, and OpenAPI-based request/response validation. 8 | 9 | ## Supported Features 10 | 11 | - 🔒 SSL Termination - HTTPS support with configurable SSL certificates 12 | 13 | - 🚀 Content Optimization 14 | - Minification for HTML, CSS, JS, XML, JSON, and SVG 15 | - GZIP compression support 16 | 17 | 18 | - ⚡ Performance Controls 19 | - Configurable request timeouts 20 | - Maximum request size limits 21 | - Response caching for cacheable content 22 | 23 | 24 | - 🛡️ Security & Protection 25 | 26 | - IP-based rate limiting (per minute/day) 27 | - Request/response validation via OpenAPI 28 | - Anomaly detection score (per session) 29 | 30 | - ⚖️ Load Balancing 31 | 32 | - Multiple backend server support 33 | - Round-robin, random, and least-latency policies 34 | - Weighted distribution options 35 | 36 | 37 | - 📁 File Serving - Static file serving with path stripping 38 | 39 | - 🏥 Health Monitoring 40 | 41 | - Automated health checks with cron scheduling 42 | Configurable failure notifications 43 | 44 | 45 | - 📊 Observability - OpenTelemetry integration for tracing and metrics 46 | 47 | ## More About The Features 48 | ### 1. SSL Termination 49 | 50 | The proxy supports secure connections through SSL, with configurable paths to the SSL key and certificate files. This allows for secure HTTPS communication between clients and the reverse proxy. 51 | 52 | ```yaml 53 | # Optional 54 | ssl: 55 | keyfile: /path/to/your/ssl/keyfile 56 | certfile: /path/to/your/ssl/certfile 57 | ``` 58 | 59 | ### 2. Content Optimization 60 | 61 | - Minification: The server can minify content (e.g., HTML, CSS, JavaScript, XML, JSON, SVG) before forwarding it to the client, reducing response sizes and improving load times. 62 | - Compression: GZIP compression is supported to further reduce the size of responses, optimizing bandwidth usage. 63 | 64 | ```yaml 65 | - path: / 66 | 67 | # Optional 68 | minify: [js, html, css, json, xml, svg] 69 | # You can use 'all' instaed to enable all content-types 70 | 71 | # Optional 72 | gzip: true # Enable GZIP compression 73 | ``` 74 | 75 | 76 | ### 3. Request Limits and Timeouts 77 | 78 | - Timeout: Custom timeouts can be set to avoid slow backend services from hanging client requests. 79 | - Maximum Request Size: Limits can be placed on the size of incoming requests to prevent excessively large payloads from overwhelming the server. 80 | 81 | ```yaml 82 | - path: / 83 | timeout: 5s # Custom timeout for backend responses (Default 30s) 84 | max_size: 2048 # Max request size in bytes (Default 10MB) 85 | ``` 86 | 87 | ### 4. Rate Limiting 88 | 89 | Rate limiting can be applied to prevent abuse, restricting the number of requests an individual client (based on IP) can make within a specific time window. Multiple rate limit policies can be configured, such as: 90 | - Requests per minute from the same IP 91 | - Requests per day from the same IP 92 | 93 | ```yaml 94 | - path: / 95 | 96 | # Optional 97 | ratelimits: 98 | - ip-10/m # Limit to 10 requests per minute per IP 99 | - ip-500/d # Limit to 500 requests per day per IP 100 | ``` 101 | 102 | ### 5. OpenAPI-based Request and Response Validation 103 | 104 | The server integrates OpenAPI for validating incoming requests and outgoing responses against an OpenAPI specification document. This ensures that: 105 | 106 | - Requests conform to the expected format, including parameters, headers, and body content. 107 | - Responses adhere to the defined API schema, ensuring consistent and reliable data exchange. 108 | 109 | You can specify the OpenAPI file path in the configuration, and the server will use it to validate the requests and responses automatically. 110 | 111 | ```yaml 112 | - path: / 113 | 114 | # Optional 115 | openapi: /path/to/openapi.yaml # OpenAPI file for request/response validation 116 | ``` 117 | 118 | 119 | ### 6. Routing Anomaly Detection 120 | 121 | The Server will calculate an anomaly score for the request based on global avg routing and session avg routing. 122 | The score is added as a header to the request `X-Anomaly-Score`. 123 | The score ranging between 0 (normal request) to 1 (a-normal request) 124 | 125 | ```yaml 126 | services: 127 | - domain: your-domain.com 128 | 129 | # Will add to downstream request an header with routing anomaly score between 0 (normal) and 1 (suspicuse) 130 | anomaly_detection: 131 | active: true 132 | header_name: "X-Anomaly-Score" # (Optional) [Default: X-Anomaly-Score] 133 | min_score: 100 # (Optional) Every internal score below this number is 0 [Default: 100] 134 | max_score: 100 # (Optional) Every internal score above this number is 1 [Default: 200] 135 | treshold_for_rating: 100 # (Optional) The amount of requests to collect stats on before starting to rate anomaly [Default: 100] 136 | ``` 137 | 138 | 139 | ### 7. Load Balancing and File Serving 140 | 141 | File serving is used when the `directory` field is set. 142 | > The endpoint path is removed from the request path before the file lookup. For example a path of /static and request path of /static/file.txt and a directory /var/www will search the file in /var/www/file.txt and not /var/www/static/file.txt 143 | 144 | ```yaml 145 | - path: /static 146 | directory: /var/www/ 147 | ``` 148 | 149 | The Server support load balancing between a number of backend servers and allow you to choose the balancing policy. 150 | 151 | 152 | ```yaml 153 | - path: /static 154 | backend: 155 | balance_policy: 'round-robin' 156 | servers: 157 | - url: http://backend-server-1/ 158 | weight: 1 159 | - url: http://backend-server-2/ 160 | weight: 2 161 | ``` 162 | 163 | #### Supported Policies: 164 | - `round-robin` (affected by weights) 165 | - `random` (affected by weights) 166 | - `least-latency` (**not** affected by weights) 167 | 168 | 169 | ### 8. Health Checks 170 | 171 | The server supports automated health checks for backend services. You can configure periodic checks to monitor the health of your backend servers under each endpoint's configuration. 172 | 173 | ```yaml 174 | - path: / 175 | checks: 176 | - name: "Health Check" # Descriptive name for the check 177 | cron: "* * * * *" # Cron expression for check frequency 178 | # Supported cron macros: 179 | # - @yearly (or @annually) - Run once a year 180 | # - @monthly - Run once a month 181 | # - @weekly - Run once a week 182 | # - @daily - Run once a day 183 | # - @hourly - Run once an hour 184 | # - @minutely - Run once a minute 185 | method: GET # HTTP method for the health check 186 | url: "http://backend-server-1/up" # Health check endpoint 187 | timeout: 5s # Timeout for health check requests 188 | headers: # Optional custom headers 189 | Host: domain.org 190 | Authorization: "Bearer abc123" 191 | ``` 192 | 193 | ### 9. OpenTelemetry Integration 194 | The server includes built-in support for OpenTelemetry, enabling comprehensive observability through distributed tracing, metrics, and logging. This integration helps monitor application performance, troubleshoot issues, and understand system behavior in distributed environments. 195 | 196 | ```yaml 197 | version: '...' 198 | 199 | open_telemetry: 200 | endpoint: "localhost:4317" 201 | sample_ratio: 0.01 # == 1% 202 | ``` 203 | 204 | ## Configuration Example 205 | 206 | Here’s a generic example of how you can configure the reverse proxy: 207 | 208 | ```yaml 209 | version: '0.0.1' 210 | host: your-host 211 | port: your-port 212 | 213 | ssl: 214 | keyfile: /path/to/your/ssl/keyfile 215 | certfile: /path/to/your/ssl/certfile 216 | 217 | open_telemetry: 218 | endpoint: "localhost:4317" 219 | sample_ratio: 0.01 # == 1% 220 | 221 | services: 222 | - domain: your-domain.com 223 | 224 | # Will add to downstream request an header with routing anomaly score between 0 (normal) and 1 (suspicuse) 225 | anomaly_detection: 226 | active: true 227 | header_name: "X-Anomaly-Score" # (Optional) [Default: X-Anomaly-Score] 228 | min_score: 100 # (Optional) Every internal score below this number is 0 [Default: 100] 229 | max_score: 100 # (Optional) Every internal score above this number is 1 [Default: 200] 230 | treshold_for_rating: 100 # (Optional) The amount of requests to collect stats on before starting to rate anomaly [Default: 100] 231 | 232 | endpoints: 233 | - path: /your-endpoint # will be served for every request with path that start with /your-endpoint (Example: /your-endpoint/1) 234 | 235 | # directory: /home/yoyo/ # For static files serving 236 | # destination: http://your-backend-service/ 237 | backend: 238 | balance_policy: 'round-robin' # Can be 'round-robin', 'random', or 'least-latency' 239 | servers: 240 | - url: http://backend-server-1/ 241 | weight: 1 242 | - url: http://backend-server-2/ 243 | weight: 2 244 | 245 | minify: [js, html, css, json, xml, svg] 246 | # You can use 'all' instaed to enable all content-types 247 | 248 | gzip: true # Enable GZIP compression 249 | 250 | timeout: 5s # Custom timeout for backend responses (Default 30s) 251 | max_size: 2048 # Max request size in bytes (Default 10MB) 252 | 253 | ratelimits: 254 | - ip-10/m # Limit to 10 requests per minute per IP 255 | - ip-500/d # Limit to 500 requests per day per IP 256 | 257 | openapi: /path/to/openapi.yaml # OpenAPI file for request/response validation 258 | 259 | omit_headers: [Server] # Omit response headers 260 | 261 | checks: 262 | - name: "Health Check" 263 | 264 | cron: "* * * * *" # == @minutely 265 | # Support cron format and macros. 266 | # Macros: 267 | # - @yearly 268 | # - @annually 269 | # - @monthly 270 | # - @weekly 271 | # - @daily 272 | # - @hourly 273 | # - @minutely 274 | 275 | method: GET # HTTP Method 276 | url: "http://backend-server-1/up" 277 | timeout: 5s 278 | headers: 279 | Host: domain.org 280 | Authorization: "Bearer abc123" 281 | 282 | # on_failure runs a shell command if the check fails. Expands $date, $error, $check_name. 283 | on_failure: | 284 | curl -d "Health check '$check_name' failed at $date due to: $error" ntfy.sh/gatego 285 | cache: true # Cache responses that has cache headers (Cache-Control and Expire) 286 | 287 | ``` 288 | 289 | ### Breakdown 290 | The configuration is organized into three main sections: 291 | 292 | - Global Settings: 293 | - Server configuration (host, port) 294 | - SSL settings 295 | - OpenTelemetry configuration 296 | 297 | 298 | - Services 299 | - Domain-based routing 300 | - Multiple endpoints per domain 301 | - Path-based matching with longest-prefix wins 302 | 303 | 304 | - Endpoints 305 | - Backend service configuration 306 | - Performance optimizations 307 | - Security controls 308 | - Monitoring settings 309 | 310 | Each endpoint can be independently configured with its own set of features, allowing for flexible and granular control over different parts of your application. 311 | 312 | ## License 313 | 314 | This project is licensed under the MIT License. 315 | -------------------------------------------------------------------------------- /checks.go: -------------------------------------------------------------------------------- 1 | package gatego 2 | 3 | import ( 4 | "github.com/hvuhsg/gatego/internal/config" 5 | "github.com/hvuhsg/gatego/pkg/monitor" 6 | ) 7 | 8 | func createMonitorChecks(services []config.Service) []monitor.Check { 9 | checks := make([]monitor.Check, 0) 10 | for _, service := range services { 11 | for _, path := range service.Paths { 12 | for _, checkConfig := range path.Checks { 13 | check := monitor.Check{ 14 | Name: checkConfig.Name, 15 | Cron: checkConfig.Cron, 16 | URL: checkConfig.URL, 17 | Method: checkConfig.Method, 18 | Timeout: checkConfig.Timeout, 19 | Headers: checkConfig.Headers, 20 | OnFailure: checkConfig.OnFailure, 21 | } 22 | 23 | checks = append(checks, check) 24 | } 25 | } 26 | } 27 | 28 | return checks 29 | } 30 | -------------------------------------------------------------------------------- /cmd/config.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/hvuhsg/gatego/refs/heads/main/config-schema.json 2 | 3 | version: '0.0.1' 4 | host: localhost 5 | port: 8004 6 | 7 | # open_telemetry: 8 | # endpoint: "localhost:4317" 9 | # sample_ratio: 1 10 | 11 | services: 12 | - domain: localhost 13 | 14 | anomaly_detection: 15 | active: true 16 | 17 | endpoints: 18 | - path: / 19 | # directory: /home/yoyo/ # Instead of destination 20 | destination: http://127.0.0.1:4007/ 21 | # backend: 22 | # balance_policy: 'least-latency' # Can be 'round-robin', 'random', or 'least-latency' 23 | # servers: 24 | # - url: http://127.0.0.1:4007/ 25 | # weight: 1 26 | # - url: http://127.0.0.1:4008/ 27 | # weight: 2 28 | 29 | minify: [js, html, css, json, xml, svg] 30 | 31 | gzip: true 32 | 33 | timeout: 3s # Default (30s) 34 | max_size: 1024 # Default (10MB) 35 | 36 | ratelimits: 37 | - ip-60/m # Limit requests from the same IP to 6 requests per minute. 38 | - ip-100/d 39 | 40 | openapi: openapi.yaml 41 | 42 | checks: 43 | - name: "DB Health" 44 | cron: "* * * * *" 45 | method: GET 46 | url: "http://127.0.0.1:4007/check_db" 47 | timeout: 5s 48 | headers: 49 | Host: domain.org 50 | Authorization: "Bearer abc123" 51 | on_failure: | 52 | echo Health check '$check_name' failed at $date with error: $error 53 | 54 | omit_headers: [Authorization, X-API-Key, X-Secret-Token] 55 | 56 | cache: true 57 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "os/signal" 8 | 9 | "github.com/hvuhsg/gatego" 10 | "github.com/hvuhsg/gatego/internal/config" 11 | ) 12 | 13 | const version = "0.0.1" 14 | 15 | func main() { 16 | // Handle SIGINT (CTRL+C) gracefully. 17 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 18 | defer stop() 19 | 20 | config, err := config.ParseConfig("config.yaml", version) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | log.Default().Println("Config loaded successfully") 26 | 27 | server := gatego.New(ctx, config, version) 28 | 29 | err = server.Run() 30 | if err != nil { 31 | log.Fatalln(err) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /cmd/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | 3 | info: 4 | title: Simple API 5 | version: 1.0.0 6 | description: A simple API with one root path and one query parameter 7 | 8 | paths: 9 | /: 10 | post: 11 | summary: Root endpoint 12 | description: Returns a greeting message 13 | parameters: 14 | - in: query 15 | name: name 16 | schema: 17 | type: string 18 | maxLength: 10 19 | required: true 20 | description: Name of the person to greet 21 | responses: 22 | '200': 23 | description: Successful response 24 | content: 25 | application/json: 26 | schema: 27 | type: object 28 | properties: 29 | message: 30 | type: string 31 | example: "Hello, World!" 32 | '400': 33 | description: Bad request 34 | content: 35 | application/json: 36 | schema: 37 | type: object 38 | properties: 39 | error: 40 | type: string 41 | example: "Invalid query parameter" -------------------------------------------------------------------------------- /config-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "version": { 6 | "type": "string", 7 | "description": "Version of the configuration." 8 | }, 9 | "host": { 10 | "type": "string", 11 | "description": "The host where the service will run." 12 | }, 13 | "port": { 14 | "type": "integer", 15 | "description": "The port for the service." 16 | }, 17 | "ssl": { 18 | "type": "object", 19 | "properties": { 20 | "keyfile": { 21 | "type": "string", 22 | "description": "Path to SSL key file." 23 | }, 24 | "certfile": { 25 | "type": "string", 26 | "description": "Path to SSL certificate file." 27 | } 28 | }, 29 | "required": [ 30 | "keyfile", 31 | "certfile" 32 | ], 33 | "description": "SSL configuration for the server." 34 | }, 35 | "open_telemetry": { 36 | "type": "object", 37 | "properties": { 38 | "endpoint": { 39 | "type": "string", 40 | "description": "GRPC connection string for open telemetry collection agent" 41 | }, 42 | "sample_ratio": { 43 | "type":"number", 44 | "exclusiveMinimum": 0, 45 | "maximum": 1 46 | } 47 | }, 48 | "required": ["sample_ratio", "endpoint"] 49 | }, 50 | "services": { 51 | "type": "array", 52 | "items": { 53 | "type": "object", 54 | "properties": { 55 | "domain": { 56 | "type": "string", 57 | "description": "Domain name for the service." 58 | }, 59 | "anomaly_detection": { 60 | "type": "object", 61 | "description": "Adds header to downstream request with routing anomaly score between 0 to 1", 62 | "properties": { 63 | "header_name": { 64 | "type":"string", 65 | "description": "The header name that will hold the anomaly score [Default X-Anomaly-Score]" 66 | }, 67 | "min_score": { 68 | "type":"integer", 69 | "default": 100, 70 | "description": "Below that score the anomaly score is 0", 71 | "minimum": 0 72 | }, 73 | "max_score": { 74 | "type":"integer", 75 | "default": 200, 76 | "description": "Above that score the anomaly score is 1", 77 | "minimum": 0 78 | }, 79 | "treshold_for_rating": { 80 | "type": "integer", 81 | "default": 100, 82 | "description": "How many requests to collect data from before starting to calculate anomaly score", 83 | "minimum": 0 84 | }, 85 | "active": { 86 | "type":"boolean", 87 | "description": "Activate the anomaly detector" 88 | } 89 | } 90 | }, 91 | "endpoints": { 92 | "type": "array", 93 | "items": { 94 | "type": "object", 95 | "properties": { 96 | "path": { 97 | "type": "string", 98 | "description": "Endpoint path that will be served." 99 | }, 100 | "directory": { 101 | "type": "string", 102 | "description": "Directory to serve files from." 103 | }, 104 | "destination": { 105 | "type": "string", 106 | "description": "Server URL to proxy the requests there." 107 | }, 108 | "backend": { 109 | "type": "object", 110 | "properties": { 111 | "balance_policy": { 112 | "type": "string", 113 | "enum": [ 114 | "round-robin", 115 | "random", 116 | "least-latency" 117 | ], 118 | "description": "Load balancing policy for backend servers." 119 | }, 120 | "servers": { 121 | "type": "array", 122 | "items": { 123 | "type": "object", 124 | "properties": { 125 | "url": { 126 | "type": "string", 127 | "description": "URL of the backend server." 128 | }, 129 | "weight": { 130 | "type": "integer", 131 | "description": "Weight of the backend server for load balancing." 132 | } 133 | }, 134 | "required": [ 135 | "url", 136 | "weight" 137 | ] 138 | } 139 | } 140 | }, 141 | "required": [ 142 | "balance_policy", 143 | "servers" 144 | ] 145 | }, 146 | "omit_headers": { 147 | "type": "array", 148 | "description": "List of headers to omit for secrets protection.", 149 | "items": { 150 | "type": "string" 151 | } 152 | }, 153 | "headers": { 154 | "type": "array", 155 | "description": "List of headers to add to request.", 156 | "items": { 157 | "type": "string" 158 | } 159 | }, 160 | "minify": { 161 | "type": "array", 162 | "items": { 163 | "type": "string" 164 | } 165 | }, 166 | "gzip": { 167 | "type": "boolean", 168 | "description": "Enable GZIP compression." 169 | }, 170 | "timeout": { 171 | "type": "string", 172 | "description": "Custom timeout for backend responses." 173 | }, 174 | "max_size": { 175 | "type": "integer", 176 | "description": "Max request size in bytes." 177 | }, 178 | "ratelimits": { 179 | "type": "array", 180 | "items": { 181 | "type": "string", 182 | "description": "Rate limits in the format of requests per time period (e.g., ip-10/m)." 183 | } 184 | }, 185 | "openapi": { 186 | "type": "string", 187 | "description": "Path to the OpenAPI specification for request/response validation." 188 | }, 189 | "checks": { 190 | "type": "array", 191 | "description": "List of health check configurations", 192 | "items": { 193 | "type": "object", 194 | "required": [ 195 | "name", 196 | "cron", 197 | "method", 198 | "url", 199 | "timeout" 200 | ], 201 | "properties": { 202 | "name": { 203 | "type": "string", 204 | "description": "Descriptive name for the health check", 205 | "minLength": 1 206 | }, 207 | "cron": { 208 | "type": "string", 209 | "description": "Cron expression or macro for check frequency", 210 | "pattern": "^(@yearly|@annually|@monthly|@weekly|@daily|@hourly|@minutely|([*\\d,-/]+\\s){4}[*\\d,-/]+)$", 211 | "examples": [ 212 | "* * * * *", 213 | "@hourly", 214 | "@daily", 215 | "0 0 * * *" 216 | ] 217 | }, 218 | "method": { 219 | "type": "string", 220 | "description": "HTTP method for the health check", 221 | "enum": [ 222 | "GET", 223 | "POST", 224 | "PUT", 225 | "DELETE", 226 | "HEAD", 227 | "OPTIONS", 228 | "PATCH", 229 | "CONNECT", 230 | "TRACE" 231 | ] 232 | }, 233 | "url": { 234 | "type": "string", 235 | "description": "Health check endpoint URL", 236 | "format": "uri", 237 | "pattern": "^https?://" 238 | }, 239 | "timeout": { 240 | "type": "string", 241 | "description": "Timeout duration for health check requests", 242 | "pattern": "^\\d+[smh]$", 243 | "default": "5s", 244 | "examples": [ 245 | "5s", 246 | "1m", 247 | "1h" 248 | ] 249 | }, 250 | "headers": { 251 | "type": "object", 252 | "description": "Custom headers to be sent with the health check request", 253 | "additionalProperties": { 254 | "type": "string" 255 | }, 256 | "examples": [ 257 | { 258 | "Host": "domain.org", 259 | "Authorization": "Bearer abc123" 260 | } 261 | ] 262 | }, 263 | "on_failure": { 264 | "type": "string", 265 | "description": "Shell command to execute if the health check fails. Supports variable expansion: $date, $error, and $check_name.", 266 | "examples": [ 267 | "echo Health check '$check_name' failed at $date with error: $error" 268 | ] 269 | } 270 | } 271 | } 272 | }, 273 | "cache": { 274 | "type": "boolean", 275 | "description": "Enable caching of response that has cache headers" 276 | } 277 | }, 278 | "required": [ 279 | "path" 280 | ], 281 | "oneOf": [ 282 | { 283 | "required": [ 284 | "directory" 285 | ] 286 | }, 287 | { 288 | "required": [ 289 | "destination" 290 | ] 291 | }, 292 | { 293 | "required": [ 294 | "backend" 295 | ] 296 | } 297 | ] 298 | } 299 | } 300 | }, 301 | "required": [ 302 | "domain", 303 | "endpoints" 304 | ] 305 | } 306 | } 307 | }, 308 | "required": [ 309 | "version", 310 | "host", 311 | "port", 312 | "services" 313 | ] 314 | } -------------------------------------------------------------------------------- /dev/client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | ) 11 | 12 | func sendRequest() *http.Response { 13 | // Sample data to send in JSON format 14 | data := map[string]interface{}{ 15 | "key1": "value1", 16 | "key2": "value2", 17 | "key3": 123, 18 | } 19 | 20 | // Convert the data to JSON 21 | jsonData, err := json.Marshal(data) 22 | if err != nil { 23 | log.Fatal("Error marshaling JSON:", err) 24 | } 25 | 26 | // Create a new POST request with the JSON payload 27 | req, err := http.NewRequest(http.MethodPost, "http://localhost:8004/?name=yoyo", bytes.NewBuffer(jsonData)) 28 | if err != nil { 29 | log.Fatal("Error creating request:", err) 30 | } 31 | 32 | // Set the appropriate Content-Type header for JSON 33 | req.Header.Set("Content-Type", "application/json") 34 | 35 | // Send the POST request 36 | client := http.DefaultClient 37 | response, err := client.Do(req) 38 | if err != nil { 39 | log.Fatal("Error sending request:", err) 40 | } 41 | 42 | return response 43 | } 44 | func main() { 45 | resp := sendRequest() 46 | defer resp.Body.Close() // Always defer closing the response body 47 | 48 | // Check the response status code 49 | if resp.StatusCode > 299 { 50 | log.Printf("Error: received status code %d", resp.StatusCode) 51 | } 52 | 53 | fmt.Println(resp) 54 | 55 | // Read the response body 56 | data, err := io.ReadAll(resp.Body) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | 61 | // Print the response body 62 | fmt.Println(string(data)) 63 | } 64 | -------------------------------------------------------------------------------- /dev/otel/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | # Jaeger 3 | jaeger: 4 | image: jaegertracing/all-in-one:latest 5 | ports: 6 | - "16686:16686" # Jaeger UI 7 | - "14250:14250" # Model used by collector 8 | environment: 9 | - COLLECTOR_OTLP_ENABLED=true 10 | 11 | # OpenTelemetry Collector 12 | otel-collector: 13 | image: otel/opentelemetry-collector-contrib:latest 14 | command: ["--config=/etc/otel-collector-config.yaml"] 15 | volumes: 16 | - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml 17 | ports: 18 | - "4317:4317" # OTLP gRPC receiver 19 | - "4318:4318" # OTLP http receiver 20 | - "8888:8888" # Prometheus metrics exposed by the collector 21 | - "8889:8889" # Prometheus exporter metrics 22 | - "13133:13133" # Health check extension 23 | depends_on: 24 | - jaeger -------------------------------------------------------------------------------- /dev/otel/otel-collector-config.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | endpoint: 0.0.0.0:4317 6 | http: 7 | endpoint: 0.0.0.0:4318 8 | 9 | processors: 10 | batch: 11 | timeout: 1s 12 | send_batch_size: 1024 13 | 14 | memory_limiter: 15 | check_interval: 1s 16 | limit_mib: 1000 17 | spike_limit_mib: 200 18 | 19 | exporters: 20 | otlp: 21 | endpoint: "jaeger:4317" 22 | tls: 23 | insecure: true 24 | 25 | debug: 26 | verbosity: detailed 27 | 28 | extensions: 29 | health_check: 30 | endpoint: 0.0.0.0:13133 31 | 32 | service: 33 | extensions: [health_check] 34 | pipelines: 35 | traces: 36 | receivers: [otlp] 37 | processors: [memory_limiter, batch] 38 | exporters: [otlp, debug] -------------------------------------------------------------------------------- /dev/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | server := http.NewServeMux() 11 | 12 | server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 13 | fmt.Printf("%s, %s, %s, %v\n", r.Proto, r.Host, r.URL, r.Header) 14 | w.Header().Set("Content-Type", "application/json") 15 | w.WriteHeader(200) 16 | w.Write([]byte(`{ "hello" : 1.5 , "good" : true }`)) 17 | }) 18 | 19 | fmt.Println("Running server at '127.0.0.1:4007'") 20 | 21 | err := http.ListenAndServe("127.0.0.1:4007", server) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /gatego.go: -------------------------------------------------------------------------------- 1 | package gatego 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/hvuhsg/gatego/internal/config" 9 | "github.com/hvuhsg/gatego/internal/contextvalues" 10 | "github.com/hvuhsg/gatego/pkg/monitor" 11 | ) 12 | 13 | const serviceName = "gatego" 14 | 15 | type GateGo struct { 16 | config config.Config 17 | monitor *monitor.Monitor 18 | ctx context.Context 19 | } 20 | 21 | func New(ctx context.Context, config config.Config, version string) *GateGo { 22 | ctx = contextvalues.AddVersionToContext(ctx, version) 23 | return &GateGo{config: config, ctx: ctx} 24 | } 25 | 26 | func (gg GateGo) Run() error { 27 | useOtel := gg.config.OTEL != nil 28 | if useOtel { 29 | otelConfig := otelConfig{ 30 | ServiceName: serviceName, 31 | SampleRatio: gg.config.OTEL.SampleRatio, 32 | CollectorTimeout: time.Second * 5, // TODO: Add to config 33 | TraceCollectorEndpoint: gg.config.OTEL.Endpoint, 34 | MetricCollectorEndpoint: gg.config.OTEL.Endpoint, 35 | LogsCollectorEndpoint: gg.config.OTEL.Endpoint, 36 | } 37 | shutdown, err := setupOTelSDK(gg.ctx, otelConfig) 38 | if err != nil { 39 | return err 40 | } 41 | defer shutdown(context.Background()) 42 | } 43 | 44 | // Create checks start monitoring 45 | healthChecks := createMonitorChecks(gg.config.Services) 46 | gg.monitor = monitor.New(time.Second*5, healthChecks...) 47 | gg.monitor.Start() 48 | 49 | server, err := newServer(gg.ctx, gg.config, useOtel) 50 | if err != nil { 51 | return err 52 | } 53 | defer server.Shutdown(gg.ctx) 54 | 55 | serveErrChan, err := server.serve(gg.config.TLS.CertFile, gg.config.TLS.KeyFile) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | // Wait for interruption. 61 | select { 62 | case err = <-serveErrChan: 63 | return err 64 | case <-gg.ctx.Done(): 65 | fmt.Println("\nShutting down...") 66 | return server.Shutdown(context.Background()) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hvuhsg/gatego 2 | 3 | go 1.22.0 4 | 5 | require gopkg.in/yaml.v3 v3.0.1 6 | 7 | require ( 8 | github.com/hashicorp/go-version v1.7.0 9 | github.com/tdewolff/minify/v2 v2.21.0 10 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 11 | go.opentelemetry.io/otel/log v0.7.0 12 | go.opentelemetry.io/otel/sdk/log v0.7.0 13 | go.opentelemetry.io/otel/trace v1.31.0 14 | ) 15 | 16 | require ( 17 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 18 | github.com/go-logr/logr v1.4.2 // indirect 19 | github.com/go-logr/stdr v1.2.2 // indirect 20 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect 21 | go.opentelemetry.io/otel/metric v1.31.0 // indirect 22 | go.opentelemetry.io/proto/otlp v1.3.1 // indirect 23 | golang.org/x/sys v0.26.0 // indirect 24 | golang.org/x/text v0.19.0 // indirect 25 | google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect 26 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect 27 | google.golang.org/grpc v1.67.1 // indirect 28 | google.golang.org/protobuf v1.35.1 // indirect 29 | ) 30 | 31 | require ( 32 | github.com/davecgh/go-spew v1.1.1 // indirect 33 | github.com/getkin/kin-openapi v0.128.0 34 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 35 | github.com/go-openapi/swag v0.23.0 // indirect 36 | github.com/google/uuid v1.6.0 37 | github.com/gorilla/mux v1.8.0 // indirect 38 | github.com/invopop/yaml v0.3.1 // indirect 39 | github.com/josharian/intern v1.0.0 // indirect 40 | github.com/mailru/easyjson v0.7.7 // indirect 41 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 42 | github.com/patrickmn/go-cache v2.1.0+incompatible 43 | github.com/perimeterx/marshmallow v1.1.5 // indirect 44 | github.com/pmezard/go-difflib v1.0.0 // indirect 45 | github.com/stretchr/testify v1.9.0 46 | github.com/tdewolff/parse/v2 v2.7.17 // indirect 47 | go.opentelemetry.io/otel v1.31.0 48 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.7.0 49 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 50 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 51 | go.opentelemetry.io/otel/sdk v1.31.0 52 | go.opentelemetry.io/otel/sdk/metric v1.31.0 53 | golang.org/x/net v0.30.0 54 | golang.org/x/time v0.7.0 55 | ) 56 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 2 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/getkin/kin-openapi v0.128.0 h1:jqq3D9vC9pPq1dGcOCv7yOp1DaEe7c/T1vzcLbITSp4= 6 | github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM= 7 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 8 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 9 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 10 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 11 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 12 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 13 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 14 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 15 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 16 | github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= 17 | github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 18 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 19 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 20 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 21 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 22 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 23 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 24 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= 25 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= 26 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 27 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 28 | github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= 29 | github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= 30 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 31 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 32 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 33 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 34 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 35 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 36 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 37 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 38 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= 39 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= 40 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 41 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 42 | github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= 43 | github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= 44 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 45 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 46 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 47 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 48 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 49 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 50 | github.com/tdewolff/minify/v2 v2.21.0 h1:nAPP1UVx0aK1xsQh/JiG3xyEnnqWw+agPstn+V6Pkto= 51 | github.com/tdewolff/minify/v2 v2.21.0/go.mod h1:hGcthJ6Vj51NG+9QRIfN/DpWj5loHnY3bfhThzWWq08= 52 | github.com/tdewolff/parse/v2 v2.7.17 h1:uC10p6DaQQORDy72eaIyD+AvAkaIUOouQ0nWp4uD0D0= 53 | github.com/tdewolff/parse/v2 v2.7.17/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= 54 | github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= 55 | github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo= 56 | github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= 57 | github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= 58 | github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= 59 | go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= 60 | go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= 61 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.7.0 h1:iNba3cIZTDPB2+IAbVY/3TUN+pCCLrNYo2GaGtsKBak= 62 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.7.0/go.mod h1:l5BDPiZ9FbeejzWTAX6BowMzQOM/GeaUQ6lr3sOcSkc= 63 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 h1:FZ6ei8GFW7kyPYdxJaV2rgI6M+4tvZzhYsQ2wgyVC08= 64 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0/go.mod h1:MdEu/mC6j3D+tTEfvI15b5Ci2Fn7NneJ71YMoiS3tpI= 65 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= 66 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= 67 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU= 68 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0/go.mod h1:TMu73/k1CP8nBUpDLc71Wj/Kf7ZS9FK5b53VapRsP9o= 69 | go.opentelemetry.io/otel/log v0.7.0 h1:d1abJc0b1QQZADKvfe9JqqrfmPYQCz2tUSO+0XZmuV4= 70 | go.opentelemetry.io/otel/log v0.7.0/go.mod h1:2jf2z7uVfnzDNknKTO9G+ahcOAyWcp1fJmk/wJjULRo= 71 | go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= 72 | go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= 73 | go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= 74 | go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= 75 | go.opentelemetry.io/otel/sdk/log v0.7.0 h1:dXkeI2S0MLc5g0/AwxTZv6EUEjctiH8aG14Am56NTmQ= 76 | go.opentelemetry.io/otel/sdk/log v0.7.0/go.mod h1:oIRXpW+WD6M8BuGj5rtS0aRu/86cbDV/dAfNaZBIjYM= 77 | go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= 78 | go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= 79 | go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= 80 | go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= 81 | go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= 82 | go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= 83 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 84 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 85 | golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= 86 | golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= 87 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 88 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 89 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= 90 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 91 | golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= 92 | golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 93 | google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= 94 | google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= 95 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= 96 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= 97 | google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= 98 | google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= 99 | google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= 100 | google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 101 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 102 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 103 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 104 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 105 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 106 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package gatego 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "os" 8 | "slices" 9 | 10 | "github.com/hvuhsg/gatego/internal/config" 11 | "github.com/hvuhsg/gatego/internal/handlers" 12 | "github.com/hvuhsg/gatego/internal/middlewares" 13 | "github.com/hvuhsg/gatego/internal/middlewares/security" 14 | ) 15 | 16 | var ErrUnsupportedBaseHandler = errors.New("base handler unsupported") 17 | 18 | func GetBaseHandler(service config.Service, path config.Path) (http.Handler, error) { 19 | if path.Destination != nil && *path.Destination != "" { 20 | return handlers.NewProxy(service, path) 21 | } else if path.Directory != nil && *path.Directory != "" { 22 | handler := handlers.NewFiles(*path.Directory, path.Path) 23 | return handler, nil 24 | } else if path.Backend != nil { 25 | return handlers.NewBalancer(service, path) 26 | } else { 27 | // Should not be reached (early validation should prevent it) 28 | return nil, ErrUnsupportedBaseHandler 29 | } 30 | } 31 | 32 | func NewHandler(ctx context.Context, useOtel bool, service config.Service, path config.Path) (http.Handler, error) { 33 | handler, err := GetBaseHandler(service, path) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | handlerWithMiddlewares := middlewares.NewHandlerWithMiddleware(handler) 39 | 40 | handlerWithMiddlewares.Add(middlewares.NewLoggingMiddleware(os.Stdout)) 41 | 42 | // Open Telemetry 43 | if useOtel { 44 | otelMiddleware, err := middlewares.NewOpenTelemetryMiddleware( 45 | ctx, 46 | middlewares.OTELConfig{ 47 | ServiceDomain: service.Domain, 48 | BasePath: path.Path, 49 | }, 50 | ) 51 | if err != nil { 52 | return nil, err 53 | } 54 | handlerWithMiddlewares.Add(otelMiddleware) 55 | } 56 | 57 | // Timeout 58 | if path.Timeout == 0 { 59 | path.Timeout = config.DefaultTimeout 60 | } 61 | handlerWithMiddlewares.Add(middlewares.NewTimeoutMiddleware(path.Timeout)) 62 | 63 | // Max request size 64 | if path.MaxSize == 0 { 65 | path.MaxSize = config.DefaultMaxRequestSize 66 | } 67 | handlerWithMiddlewares.Add(middlewares.NewRequestSizeLimitMiddleware(path.MaxSize)) 68 | 69 | // Rate limits 70 | if len(path.RateLimits) > 0 { 71 | ratelimiter, err := middlewares.NewRateLimitMiddleware(path.RateLimits) 72 | if err != nil { 73 | return nil, err 74 | } 75 | handlerWithMiddlewares.Add(ratelimiter) 76 | } 77 | 78 | // Add anomaly detector 79 | if service.AnomalyDetection != nil { 80 | handlerWithMiddlewares.Add( 81 | security.NewRoutingAnomalyDetector( 82 | service.AnomalyDetection.HeaderName, 83 | service.AnomalyDetection.TresholdForRating, 84 | service.AnomalyDetection.MinScore, 85 | service.AnomalyDetection.MaxScore).AddAnomalyScore, 86 | ) 87 | } 88 | 89 | // Add headers 90 | if path.Headers != nil { 91 | handlerWithMiddlewares.Add(middlewares.NewAddHeadersMiddleware(*path.Headers)) 92 | } 93 | 94 | // GZIP compression 95 | if path.Gzip != nil && *path.Gzip { 96 | handlerWithMiddlewares.Add(middlewares.GzipMiddleware) 97 | } 98 | 99 | // Remove response headers 100 | if len(path.OmitHeaders) > 0 { 101 | handlerWithMiddlewares.Add(middlewares.NewOmitHeadersMiddleware(path.OmitHeaders)) 102 | } 103 | 104 | // Minify files 105 | minifyConfig := middlewares.MinifyConfig{ 106 | ALL: slices.Contains(path.Minify, "all"), 107 | JS: slices.Contains(path.Minify, "js"), 108 | HTML: slices.Contains(path.Minify, "html"), 109 | CSS: slices.Contains(path.Minify, "css"), 110 | JSON: slices.Contains(path.Minify, "json"), 111 | SVG: slices.Contains(path.Minify, "svg"), 112 | XML: slices.Contains(path.Minify, "xml"), 113 | } 114 | handlerWithMiddlewares.Add(middlewares.NewMinifyMiddleware(minifyConfig)) 115 | 116 | // OpenAPI validation 117 | if path.OpenAPI != nil { 118 | openapiMiddleware, err := middlewares.NewOpenAPIValidationMiddleware(*path.OpenAPI) 119 | if err != nil { 120 | return nil, err 121 | } 122 | handlerWithMiddlewares.Add(openapiMiddleware) 123 | } 124 | 125 | // Response cache 126 | if path.Cache { 127 | handlerWithMiddlewares.Add(middlewares.NewCacheMiddleware()) 128 | } 129 | 130 | return handlerWithMiddlewares, nil 131 | } 132 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "regexp" 12 | "slices" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/hashicorp/go-version" 18 | "github.com/hvuhsg/gatego/internal/middlewares" 19 | "github.com/hvuhsg/gatego/pkg/cron" 20 | "gopkg.in/yaml.v3" 21 | ) 22 | 23 | const DefaultTimeout = time.Second * 30 24 | const DefaultMaxRequestSize = 1024 * 10 // 10 MB 25 | var SupportedBalancePolicies = []string{"round-robin", "random", "least-latency"} 26 | 27 | type Backend struct { 28 | BalancePolicy string `yaml:"balance_policy"` 29 | Servers []struct { 30 | URL string `yaml:"url"` 31 | Weight uint `yaml:"weight"` 32 | } 33 | } 34 | 35 | func (b Backend) validate() error { 36 | if !slices.Contains(SupportedBalancePolicies, b.BalancePolicy) { 37 | return fmt.Errorf("balance policy '%s' is not supported", b.BalancePolicy) 38 | } 39 | 40 | if len(b.Servers) == 0 { 41 | return errors.New("backend require at least one server") 42 | } 43 | 44 | for _, server := range b.Servers { 45 | if !isValidURL(server.URL) { 46 | return fmt.Errorf("invalid backend server url '%s'", server.URL) 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | 53 | type Check struct { 54 | Name string `yaml:"name"` 55 | Cron string `yaml:"cron"` 56 | URL string `yaml:"url"` 57 | Method string `yaml:"method"` 58 | Timeout time.Duration `yaml:"timeout"` 59 | Headers map[string]string `yaml:"headers"` 60 | OnFailure string `yaml:"on_failure"` 61 | } 62 | 63 | func (c Check) validate() error { 64 | if len(c.Name) == 0 { 65 | return errors.New("check requires a name") 66 | } 67 | 68 | if _, err := cron.NewSchedule(c.Cron); err != nil { 69 | return errors.New("invalid check cron expression") 70 | } 71 | 72 | if !isValidURL(c.URL) { 73 | return errors.New("invalid check url") 74 | } 75 | 76 | if !isValidMethod(c.Method) { 77 | return errors.New("invalid check method") 78 | } 79 | 80 | return nil 81 | } 82 | 83 | type Path struct { 84 | Path string `yaml:"path"` 85 | Destination *string `yaml:"destination"` // The domain / url of the service server 86 | Directory *string `yaml:"directory"` // path to dir you want to serve 87 | Backend *Backend `yaml:"backend"` // List of servers to load balance between 88 | Headers *map[string]string `yaml:"headers"` 89 | OmitHeaders []string `yaml:"omit_headers"` // Omit specified headers 90 | Minify []string `yaml:"minify"` 91 | Gzip *bool `yaml:"gzip"` 92 | Timeout time.Duration `yaml:"timeout"` 93 | MaxSize uint64 `yaml:"max_size"` 94 | OpenAPI *string `yaml:"openapi"` 95 | RateLimits []string `yaml:"ratelimits"` 96 | Checks []Check `yaml:"checks"` // Automated checks 97 | Cache bool `yaml:"cache"` // Cache responses that has cache headers 98 | } 99 | 100 | func (p Path) validate() error { 101 | if p.Path[0] != '/' { 102 | return errors.New("path must start with '/'") 103 | } 104 | 105 | if p.Destination != nil { 106 | if !isValidURL(*p.Destination) { 107 | return errors.New("invalid destination url") 108 | } 109 | 110 | if p.Directory != nil { 111 | return errors.New("can't have destination and directory for the same path") 112 | } 113 | } 114 | 115 | if p.Directory != nil { 116 | if !isValidDir(*p.Directory) { 117 | return errors.New("invalid directory path") 118 | } 119 | 120 | if p.Cache { 121 | log.Println("[WARNING] Using cache while serving static files is not recommanded") 122 | } 123 | } 124 | 125 | if p.Backend != nil { 126 | if err := p.Backend.validate(); err != nil { 127 | return err 128 | } 129 | } 130 | 131 | if p.Destination == nil && p.Directory == nil && p.Backend == nil { 132 | return errors.New("path must have destination or directory or backend") 133 | } 134 | 135 | if p.OpenAPI != nil { 136 | if *p.OpenAPI == "" { 137 | return errors.New("openapi can't be empty (remove or fill)") 138 | } 139 | 140 | if !isValidFile(*p.OpenAPI) { 141 | return errors.New("invalid openapi spec path") 142 | } 143 | } 144 | 145 | for _, ratelimit := range p.RateLimits { 146 | _, err := middlewares.ParseLimitConfig(ratelimit) 147 | if err != nil { 148 | return fmt.Errorf("invalid ratelimit: %s", err.Error()) 149 | } 150 | } 151 | 152 | for _, check := range p.Checks { 153 | if err := check.validate(); err != nil { 154 | return err 155 | } 156 | } 157 | 158 | return nil 159 | } 160 | 161 | type AnomalyDetection struct { 162 | HeaderName string `yaml:"header_name"` 163 | MinScore int `yaml:"min_score"` 164 | MaxScore int `yaml:"max_score"` 165 | TresholdForRating int `yaml:"treshold_for_rating"` 166 | Active bool `yaml:"active"` 167 | } 168 | 169 | func (a *AnomalyDetection) validate() error { 170 | if a.HeaderName == "" { 171 | a.HeaderName = "X-Anomaly-Score" 172 | } 173 | 174 | if a.MinScore == 0 { 175 | a.MinScore = 100 176 | } 177 | 178 | if a.MaxScore == 0 { 179 | a.MaxScore = 200 180 | } 181 | 182 | if a.TresholdForRating == 0 { 183 | a.TresholdForRating = 100 184 | } 185 | 186 | if a.MaxScore <= a.MinScore { 187 | return errors.New("anomaly detection maxScore MUST be grater the minScore") 188 | } 189 | 190 | return nil 191 | } 192 | 193 | type Service struct { 194 | Domain string `yaml:"domain"` // The domain / host the request was sent to 195 | Paths []Path `yaml:"endpoints"` 196 | AnomalyDetection *AnomalyDetection `yaml:"anomaly_detection"` 197 | } 198 | 199 | func (s Service) validate() error { 200 | if !isValidHostname(s.Domain) { 201 | return errors.New("invalid domain") 202 | } 203 | 204 | for _, path := range s.Paths { 205 | if err := path.validate(); err != nil { 206 | return err 207 | } 208 | } 209 | 210 | if s.AnomalyDetection != nil { 211 | if err := s.AnomalyDetection.validate(); err != nil { 212 | return err 213 | } 214 | } 215 | 216 | return nil 217 | } 218 | 219 | type TLS struct { 220 | Auto bool `yaml:"auto"` 221 | Domains []string `yaml:"domain"` 222 | Email *string `yaml:"email"` 223 | KeyFile *string `yaml:"keyfile"` 224 | CertFile *string `yaml:"certfile"` 225 | } 226 | 227 | func (tls TLS) validate() error { 228 | if tls.Auto { 229 | if len(tls.Domains) == 0 { 230 | return errors.New("when using the auto tls feature you MUST include a list of domains to issue certificates for") 231 | } 232 | if tls.Email == nil || len(*tls.Email) == 0 || !isValidEmail(*tls.Email) { 233 | return errors.New("when using the auto tls feature you MUST include a valid email for the lets-encrypt registration") 234 | } 235 | } 236 | 237 | if tls.CertFile != nil { 238 | if tls.KeyFile == nil { 239 | return errors.New("you MUST provide certfile AND keyfile") 240 | } 241 | } 242 | 243 | if tls.KeyFile != nil { 244 | if tls.CertFile == nil { 245 | return errors.New("you MUST provide certfile AND keyfile") 246 | } 247 | 248 | if !isValidFile(*tls.CertFile) { 249 | return errors.New("certfile path is invalid") 250 | } 251 | 252 | if !isValidFile(*tls.KeyFile) { 253 | return errors.New("keyfile path is invalid") 254 | } 255 | } 256 | 257 | return nil 258 | } 259 | 260 | type OTEL struct { 261 | Endpoint string `yaml:"endpoint"` 262 | SampleRatio float64 `yaml:"sample_ratio"` 263 | } 264 | 265 | func (otel OTEL) validate() error { 266 | if len(otel.Endpoint) > 0 { 267 | if err := isValidGRPCAddress(otel.Endpoint); err != nil { 268 | return err 269 | } 270 | } 271 | 272 | if otel.SampleRatio < 0 { 273 | return errors.New("OpenTelemetry sample ratio MUST be above 0") 274 | } 275 | 276 | if otel.SampleRatio == 0 { 277 | return errors.New("OpenTelemetry sample ratio is missing or equales to 0") 278 | } 279 | 280 | if otel.SampleRatio > 1 { 281 | return errors.New("OpenTelemetry sample ratio CAN NOT be above 1") 282 | } 283 | 284 | return nil 285 | } 286 | 287 | type Config struct { 288 | Version string `yaml:"version"` 289 | Host string `yaml:"host"` // listen host 290 | Port uint16 `yaml:"port"` // listen port 291 | 292 | OTEL *OTEL `yaml:"open_telemetry"` 293 | 294 | // TLS options 295 | TLS TLS `yaml:"ssl"` 296 | 297 | Services []Service `yaml:"services"` 298 | } 299 | 300 | func (c Config) Validate(currentVersion string) error { 301 | if c.Version == "" { 302 | return errors.New("version is required") 303 | } 304 | 305 | progVersion, _ := version.NewVersion(currentVersion) 306 | configVersion, err := version.NewVersion(c.Version) 307 | if err != nil { 308 | return errors.New("version is invalid") 309 | } 310 | 311 | if configVersion.Compare(progVersion) > 0 { 312 | return errors.New("config version is not supported (too advanced)") 313 | } 314 | 315 | if c.Host == "" { 316 | return errors.New("host is required") 317 | } 318 | 319 | if c.OTEL != nil { 320 | if err := (*c.OTEL).validate(); err != nil { 321 | return err 322 | } 323 | } 324 | 325 | if c.Port == 0 { 326 | return errors.New("port is required") 327 | } 328 | 329 | if err := c.TLS.validate(); err != nil { 330 | return err 331 | } 332 | 333 | if c.TLS.Auto && c.Port != 443 { 334 | return errors.New("the auto tls feature is only available if the server runs on port 443") 335 | } 336 | 337 | for _, service := range c.Services { 338 | if err := service.validate(); err != nil { 339 | return err 340 | } 341 | } 342 | 343 | return nil 344 | } 345 | 346 | func ParseConfig(filepath string, currentVersion string) (Config, error) { 347 | // Read the YAML file 348 | data, err := os.ReadFile(filepath) 349 | if err != nil { 350 | return Config{}, err 351 | } 352 | 353 | // Defaults 354 | c := Config{Port: 80} 355 | 356 | // Unmarshal the YAML data into the struct 357 | err = yaml.Unmarshal(data, &c) 358 | if err != nil { 359 | return Config{}, err 360 | } 361 | 362 | if err := c.Validate(currentVersion); err != nil { 363 | return Config{}, err 364 | } 365 | 366 | return c, nil 367 | } 368 | 369 | func isValidHostname(hostname string) bool { 370 | // Remove leading/trailing whitespace 371 | hostname = strings.TrimSpace(hostname) 372 | 373 | // Check if the hostname is empty 374 | if hostname == "" { 375 | return false 376 | } 377 | 378 | // Check if the hostname is too long (max 253 characters) 379 | if len(hostname) > 253 { 380 | return false 381 | } 382 | 383 | // Check for localhost 384 | if hostname == "localhost" { 385 | return true 386 | } 387 | 388 | // Check if it's an IP address (IPv4 or IPv6) 389 | if ip := net.ParseIP(hostname); ip != nil { 390 | return true 391 | } 392 | 393 | // Regular expression for domain validation 394 | // This regex allows for domains with multiple subdomains and supports IDNs 395 | domainRegex := regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`) 396 | 397 | return domainRegex.MatchString(hostname) 398 | } 399 | 400 | func isValidURL(str string) bool { 401 | u, err := url.Parse(str) 402 | return err == nil && u.Scheme != "" && u.Host != "" 403 | } 404 | 405 | func isValidDir(path string) bool { 406 | if path == "" { 407 | return false 408 | } 409 | 410 | fileInfo, err := os.Stat(path) 411 | if err != nil { 412 | return false 413 | } 414 | return fileInfo.IsDir() 415 | } 416 | 417 | func isValidFile(path string) bool { 418 | if path == "" { 419 | return false 420 | } 421 | 422 | fileInfo, err := os.Stat(path) 423 | if err != nil { 424 | return false 425 | } 426 | return !fileInfo.IsDir() 427 | } 428 | 429 | func isValidMethod(method string) bool { 430 | methods := []string{ 431 | http.MethodGet, 432 | http.MethodHead, 433 | http.MethodPost, 434 | http.MethodPut, 435 | http.MethodPatch, 436 | http.MethodDelete, 437 | http.MethodConnect, 438 | http.MethodOptions, 439 | http.MethodTrace, 440 | } 441 | 442 | return slices.Contains(methods, method) 443 | } 444 | 445 | func isValidGRPCAddress(address string) error { 446 | if address == "" { 447 | return fmt.Errorf("address cannot be empty") 448 | } 449 | 450 | // Split host and port 451 | host, portStr, err := net.SplitHostPort(address) 452 | if err != nil { 453 | return fmt.Errorf("invalid address format: %v", err) 454 | } 455 | 456 | // Validate port 457 | port, err := strconv.Atoi(portStr) 458 | if err != nil { 459 | return fmt.Errorf("invalid port number: %v", err) 460 | } 461 | if port < 1 || port > 65535 { 462 | return fmt.Errorf("port number must be between 1 and 65535") 463 | } 464 | 465 | // Empty host means localhost/0.0.0.0, which is valid 466 | if host == "" { 467 | return nil 468 | } 469 | 470 | // Check if host is IPv4 or IPv6 471 | if ip := net.ParseIP(host); ip != nil { 472 | return nil 473 | } 474 | 475 | // Validate hostname format 476 | hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$`) 477 | if !hostnameRegex.MatchString(host) { 478 | return fmt.Errorf("invalid hostname format") 479 | } 480 | 481 | // Check hostname length 482 | if len(host) > 253 { 483 | return fmt.Errorf("hostname too long") 484 | } 485 | 486 | // Validate hostname parts 487 | parts := strings.Split(host, ".") 488 | for _, part := range parts { 489 | if len(part) > 63 { 490 | return fmt.Errorf("hostname label too long") 491 | } 492 | } 493 | 494 | return nil 495 | } 496 | 497 | func isValidEmail(email string) bool { 498 | // Define a regular expression for valid email addresses 499 | var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) 500 | 501 | // Match the email string with the regular expression 502 | return emailRegex.MatchString(email) 503 | } 504 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestPathValidate(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | path Path 13 | wantErr bool 14 | }{ 15 | {"Valid path with destination", Path{Path: "/api", Destination: ptr("http://example.com")}, false}, 16 | {"Valid path with directory", Path{Path: "/static", Directory: ptr("/var")}, false}, 17 | {"Invalid path without leading slash", Path{Path: "api", Destination: ptr("http://example.com")}, true}, 18 | {"Invalid destination URL", Path{Path: "/api", Destination: ptr("not-a-url")}, true}, 19 | {"Invalid with both destination and directory", Path{Path: "/both", Destination: ptr("http://example.com"), Directory: ptr("/var/www")}, true}, 20 | {"Invalid with neither destination nor directory", Path{Path: "/empty"}, true}, 21 | } 22 | 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | err := tt.path.validate() 26 | if (err != nil) != tt.wantErr { 27 | t.Errorf("Path.validate() error = %v, wantErr %v", err, tt.wantErr) 28 | } 29 | }) 30 | } 31 | } 32 | 33 | func TestServiceValidate(t *testing.T) { 34 | tests := []struct { 35 | name string 36 | service Service 37 | wantErr bool 38 | }{ 39 | {"Valid service", Service{Domain: "example.com", Paths: []Path{{Path: "/api", Destination: ptr("http://api.example.com")}}}, false}, 40 | {"Invalid domain", Service{Domain: "not a domain", Paths: []Path{{Path: "/api", Destination: ptr("http://api.example.com")}}}, true}, 41 | {"Invalid path", Service{Domain: "example.com", Paths: []Path{{Path: "invalid", Destination: ptr("http://api.example.com")}}}, true}, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | err := tt.service.validate() 47 | if (err != nil) != tt.wantErr { 48 | t.Errorf("Service.validate() service = %v, error = %v, wantErr %v", err, tt.service, tt.wantErr) 49 | } 50 | }) 51 | } 52 | } 53 | 54 | func TestConfigValidate(t *testing.T) { 55 | tests := []struct { 56 | name string 57 | config Config 58 | currentVersion string 59 | wantErr bool 60 | }{ 61 | {"Valid config", Config{Version: "1.0.0", Host: "localhost", Port: 80, Services: []Service{{Domain: "example.com", Paths: []Path{{Path: "/api", Destination: ptr("http://api.example.com")}}}}}, "1.0.0", false}, 62 | {"AutoTLS with port != 443", Config{Version: "1.0.0", Host: "localhost", Port: 80, TLS: TLS{Auto: true, Domains: []string{"example.com"}}, Services: []Service{{Domain: "example.com", Paths: []Path{{Path: "/api", Destination: ptr("http://api.example.com")}}}}}, "1.0.0", true}, 63 | {"Missing version", Config{Host: "localhost"}, "1.0.0", true}, 64 | {"Invalid version", Config{Version: "invalid", Host: "localhost"}, "1.0.0", true}, 65 | {"Future version", Config{Version: "2.0.0", Host: "localhost"}, "1.0.0", true}, 66 | {"Missing host", Config{Version: "1.0.0"}, "1.0.0", true}, 67 | } 68 | 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | err := tt.config.Validate(tt.currentVersion) 72 | if (err != nil) != tt.wantErr { 73 | t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr) 74 | } 75 | }) 76 | } 77 | } 78 | 79 | func TestParseConfig(t *testing.T) { 80 | // Create a temporary directory for test files 81 | tempDir, err := os.MkdirTemp("", "config_test") 82 | if err != nil { 83 | t.Fatalf("Failed to create temp dir: %v", err) 84 | } 85 | defer os.RemoveAll(tempDir) 86 | 87 | // Create a valid config file 88 | validConfig := ` 89 | version: "1.0.0" 90 | host: "localhost" 91 | port: 8080 92 | services: 93 | - domain: "example.com" 94 | endpoints: 95 | - path: "/api" 96 | destination: "http://api.example.com" 97 | ` 98 | validConfigPath := filepath.Join(tempDir, "valid_config.yaml") 99 | err = os.WriteFile(validConfigPath, []byte(validConfig), 0644) 100 | if err != nil { 101 | t.Fatalf("Failed to write valid config file: %v", err) 102 | } 103 | 104 | // Create an invalid config file 105 | invalidConfig := ` 106 | version: "invalid" 107 | host: "localhost" 108 | ` 109 | invalidConfigPath := filepath.Join(tempDir, "invalid_config.yaml") 110 | err = os.WriteFile(invalidConfigPath, []byte(invalidConfig), 0644) 111 | if err != nil { 112 | t.Fatalf("Failed to write invalid config file: %v", err) 113 | } 114 | 115 | tests := []struct { 116 | name string 117 | filepath string 118 | currentVersion string 119 | wantErr bool 120 | }{ 121 | {"Valid config", validConfigPath, "1.0.0", false}, 122 | {"Invalid config", invalidConfigPath, "1.0.0", true}, 123 | {"Non-existent file", filepath.Join(tempDir, "non_existent.yaml"), "1.0.0", true}, 124 | } 125 | 126 | for _, tt := range tests { 127 | t.Run(tt.name, func(t *testing.T) { 128 | _, err := ParseConfig(tt.filepath, tt.currentVersion) 129 | if (err != nil) != tt.wantErr { 130 | t.Errorf("ParseConfig() error = %v, wantErr %v", err, tt.wantErr) 131 | } 132 | }) 133 | } 134 | } 135 | 136 | func TestIsValidURL(t *testing.T) { 137 | tests := []struct { 138 | name string 139 | url string 140 | want bool 141 | }{ 142 | {"Valid URL", "http://example.com", true}, 143 | {"Valid URL with path", "https://example.com/path", true}, 144 | {"Invalid URL", "not-a-url", false}, 145 | {"Invalid URL", "not a domain", false}, 146 | {"Missing scheme", "example.com", false}, 147 | } 148 | 149 | for _, tt := range tests { 150 | t.Run(tt.name, func(t *testing.T) { 151 | if got := isValidURL(tt.url); got != tt.want { 152 | t.Errorf("isValidURL() = %v, want %v", got, tt.want) 153 | } 154 | }) 155 | } 156 | } 157 | 158 | func TestIsValidDir(t *testing.T) { 159 | // Create a temporary directory for the test 160 | tempDir, err := os.MkdirTemp("", "dir_test") 161 | if err != nil { 162 | t.Fatalf("Failed to create temp dir: %v", err) 163 | } 164 | defer os.RemoveAll(tempDir) 165 | 166 | tests := []struct { 167 | name string 168 | path string 169 | want bool 170 | }{ 171 | {"Valid directory", tempDir, true}, 172 | {"Non-existent directory", filepath.Join(tempDir, "non_existent"), false}, 173 | {"Empty path", "", false}, 174 | } 175 | 176 | for _, tt := range tests { 177 | t.Run(tt.name, func(t *testing.T) { 178 | if got := isValidDir(tt.path); got != tt.want { 179 | t.Errorf("isValidDir() = %v, want %v", got, tt.want) 180 | } 181 | }) 182 | } 183 | } 184 | 185 | // Helper function to create string pointers 186 | func ptr(s string) *string { 187 | return &s 188 | } 189 | -------------------------------------------------------------------------------- /internal/contextvalues/tracer.go: -------------------------------------------------------------------------------- 1 | package contextvalues 2 | 3 | import ( 4 | "context" 5 | 6 | "go.opentelemetry.io/otel/trace" 7 | ) 8 | 9 | // Define a custom type for context keys to avoid collisions 10 | type tracerKeyType string 11 | 12 | var tracerKey = tracerKeyType("tracer") 13 | 14 | // Add tracer to context 15 | func AddTracerToContext(ctx context.Context, tracer trace.Tracer) context.Context { 16 | return context.WithValue(ctx, tracerKey, tracer) 17 | } 18 | 19 | // Retrieve tracer from context 20 | func TracerFromContext(ctx context.Context) trace.Tracer { 21 | var tracer trace.Tracer = nil 22 | if t, ok := ctx.Value(tracerKey).(trace.Tracer); ok { 23 | tracer = t 24 | } 25 | return tracer 26 | } 27 | -------------------------------------------------------------------------------- /internal/contextvalues/version.go: -------------------------------------------------------------------------------- 1 | package contextvalues 2 | 3 | import "context" 4 | 5 | // Define a custom type for context keys to avoid collisions 6 | type versionKeyType string 7 | 8 | var versionKey = versionKeyType("version") 9 | 10 | // Add version to context 11 | func AddVersionToContext(ctx context.Context, version string) context.Context { 12 | return context.WithValue(ctx, versionKey, version) 13 | } 14 | 15 | // Retrieve version from context 16 | func VersionFromContext(ctx context.Context) string { 17 | version := "" 18 | if v, ok := ctx.Value(versionKey).(string); ok { 19 | version = v 20 | } 21 | return version 22 | } 23 | -------------------------------------------------------------------------------- /internal/handlers/balancer.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "math" 5 | "math/rand" 6 | "net/http" 7 | "net/http/httputil" 8 | "net/url" 9 | "time" 10 | 11 | "github.com/hvuhsg/gatego/internal/config" 12 | "github.com/hvuhsg/gatego/internal/contextvalues" 13 | semconv "go.opentelemetry.io/otel/semconv/v1.4.0" 14 | ) 15 | 16 | type ServerAndWeight struct { 17 | server *httputil.ReverseProxy 18 | weight int 19 | url string 20 | } 21 | 22 | type BalancePolicy interface { 23 | GetNext() *httputil.ReverseProxy 24 | } 25 | 26 | type Balancer struct { 27 | policy BalancePolicy 28 | } 29 | 30 | func NewBalancer(service config.Service, path config.Path) (*Balancer, error) { 31 | serversConfig := path.Backend.Servers 32 | 33 | serversAndWeights := make([]ServerAndWeight, 0, len(serversConfig)) 34 | for _, serverConfig := range serversConfig { 35 | serverURL, err := url.Parse(serverConfig.URL) 36 | if err != nil { 37 | return &Balancer{}, err 38 | } 39 | 40 | server := httputil.NewSingleHostReverseProxy(serverURL) 41 | 42 | serverWeight := int(serverConfig.Weight) 43 | if serverWeight < 1 { 44 | serverWeight = 1 45 | } 46 | serversAndWeights = append(serversAndWeights, ServerAndWeight{server: server, weight: serverWeight, url: serverConfig.URL}) 47 | } 48 | 49 | var policy BalancePolicy 50 | switch path.Backend.BalancePolicy { 51 | case "round-robin": 52 | policy = NewRoundRobinPolicy(serversAndWeights) 53 | case "random": 54 | policy = NewRandomPolicy(serversAndWeights) 55 | case "least-latency": 56 | policy = NewLeastLatencyPolicy(serversAndWeights) 57 | } 58 | 59 | balancer := Balancer{policy: policy} 60 | 61 | return &balancer, nil 62 | } 63 | 64 | func (b *Balancer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 65 | proxy := b.policy.GetNext() 66 | 67 | tracer := contextvalues.TracerFromContext(r.Context()) 68 | if tracer != nil { 69 | ctx, span := tracer.Start(r.Context(), "request.upstream") 70 | span.SetAttributes(semconv.HTTPServerAttributesFromHTTPRequest(r.Host, r.URL.Path, r)...) 71 | r = r.WithContext(ctx) 72 | defer span.End() 73 | } 74 | 75 | proxy.ServeHTTP(w, r) 76 | } 77 | 78 | type RoundRobinPolicy struct { 79 | current int 80 | weightsSum int 81 | servers []ServerAndWeight 82 | } 83 | 84 | func NewRoundRobinPolicy(servers []ServerAndWeight) *RoundRobinPolicy { 85 | weightsSum := 0 86 | for _, server := range servers { 87 | weightsSum += server.weight 88 | } 89 | 90 | policy := &RoundRobinPolicy{current: 0, weightsSum: weightsSum, servers: servers} 91 | return policy 92 | } 93 | 94 | // The servers provided must be provided in the same order for accurate results 95 | func (rrp *RoundRobinPolicy) GetNext() *httputil.ReverseProxy { 96 | serverIndex := rrp.current 97 | 98 | for _, server := range rrp.servers { 99 | serverIndex -= server.weight 100 | if serverIndex < 0 { 101 | rrp.current += 1 102 | return server.server 103 | } 104 | } 105 | 106 | rrp.current = (rrp.current % rrp.weightsSum) + 1 107 | return rrp.servers[0].server 108 | } 109 | 110 | type RandomPolicy struct { 111 | weightsSum int 112 | servers []ServerAndWeight 113 | } 114 | 115 | func NewRandomPolicy(servers []ServerAndWeight) *RandomPolicy { 116 | weightsSum := 0 117 | for _, server := range servers { 118 | weightsSum += server.weight 119 | } 120 | 121 | return &RandomPolicy{weightsSum: weightsSum, servers: servers} 122 | } 123 | 124 | func (rp *RandomPolicy) GetNext() *httputil.ReverseProxy { 125 | randomServerIndex := rand.Intn(rp.weightsSum) 126 | 127 | for _, server := range rp.servers { 128 | randomServerIndex -= server.weight 129 | if randomServerIndex <= 0 { 130 | return server.server 131 | } 132 | } 133 | 134 | return rp.servers[0].server 135 | } 136 | 137 | type LeastLatencyPolicy struct { 138 | serversLatency map[string]int64 139 | servers []ServerAndWeight 140 | } 141 | 142 | func NewLeastLatencyPolicy(serversAndURLs []ServerAndWeight) *LeastLatencyPolicy { 143 | serversLatency := make(map[string]int64, len(serversAndURLs)) 144 | 145 | for _, serverAndWeight := range serversAndURLs { 146 | serversLatency[serverAndWeight.url] = 0 147 | } 148 | 149 | return &LeastLatencyPolicy{servers: serversAndURLs, serversLatency: serversLatency} 150 | } 151 | 152 | func (llp *LeastLatencyPolicy) GetNext() *httputil.ReverseProxy { 153 | 154 | bestServerURL := llp.servers[0].url 155 | var bestLatency int64 = math.MaxInt64 156 | 157 | for url, latency := range llp.serversLatency { 158 | if latency < bestLatency { 159 | bestServerURL = url 160 | bestLatency = latency 161 | } 162 | } 163 | 164 | var chosenServer ServerAndWeight 165 | for _, server := range llp.servers { 166 | if server.url == bestServerURL { 167 | chosenServer = server 168 | break 169 | } 170 | } 171 | 172 | // TODO: use decaing latency for extream latency conditions 173 | 174 | startTime := time.Now().UnixMicro() 175 | chosenServer.server.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { 176 | llp.serversLatency[chosenServer.url] = time.Now().UnixMicro() - startTime 177 | } 178 | chosenServer.server.ModifyResponse = func(r *http.Response) error { 179 | llp.serversLatency[chosenServer.url] = time.Now().UnixMicro() - startTime 180 | return nil 181 | } 182 | 183 | return chosenServer.server 184 | } 185 | -------------------------------------------------------------------------------- /internal/handlers/balancer_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/http/httputil" 7 | "net/url" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/hvuhsg/gatego/internal/config" 13 | ) 14 | 15 | func TestNewBalancer(t *testing.T) { 16 | service := config.Service{} 17 | path := config.Path{ 18 | Backend: &config.Backend{ 19 | BalancePolicy: "round-robin", 20 | Servers: []struct { 21 | URL string "yaml:\"url\"" 22 | Weight uint "yaml:\"weight\"" 23 | }{ 24 | {URL: "http://localhost:8001", Weight: 1}, 25 | {URL: "http://localhost:8002", Weight: 2}, 26 | }, 27 | }, 28 | } 29 | 30 | balancer, err := NewBalancer(service, path) 31 | if err != nil { 32 | t.Fatalf("Failed to create balancer: %v", err) 33 | } 34 | 35 | if balancer == nil { 36 | t.Fatal("Balancer is nil") 37 | } 38 | } 39 | 40 | func TestRoundRobinPolicy(t *testing.T) { 41 | servers := []ServerAndWeight{ 42 | {server: createDummyProxy("http://localhost:8001/"), weight: 1, url: "http://localhost:8001/"}, 43 | {server: createDummyProxy("http://localhost:8002/"), weight: 1, url: "http://localhost:8002/"}, 44 | } 45 | 46 | policy := NewRoundRobinPolicy(servers) 47 | 48 | // Test the round-robin behavior 49 | expectedOrder := []string{"http://localhost:8001/", "http://localhost:8002/", "http://localhost:8001/", "http://localhost:8002/"} 50 | for i, expected := range expectedOrder { 51 | server := policy.GetNext() 52 | if server.Director == nil { 53 | t.Fatalf("Server %d is nil", i) 54 | } 55 | serverURL := getProxyURL(server) 56 | if serverURL != expected { 57 | t.Errorf("index = %d Expected server %s, got %s", i, expected, serverURL) 58 | } 59 | } 60 | } 61 | 62 | func TestRandomPolicy(t *testing.T) { 63 | servers := []ServerAndWeight{ 64 | {server: createDummyProxy("http://localhost:8001"), weight: 1, url: "http://localhost:8001"}, 65 | {server: createDummyProxy("http://localhost:8002"), weight: 1, url: "http://localhost:8002"}, 66 | } 67 | 68 | policy := NewRandomPolicy(servers) 69 | 70 | // Test that we get a valid server (we can't test randomness easily) 71 | for i := 0; i < 10; i++ { 72 | server := policy.GetNext() 73 | if server == nil { 74 | t.Fatal("Got nil server from RandomPolicy") 75 | } 76 | } 77 | } 78 | 79 | func TestLeastLatencyPolicy(t *testing.T) { 80 | // Create mock servers 81 | server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 82 | time.Sleep(20 * time.Millisecond) 83 | w.WriteHeader(http.StatusOK) 84 | w.Write([]byte("Slow response from server 1")) 85 | })) 86 | defer server1.Close() 87 | 88 | server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 89 | w.WriteHeader(http.StatusOK) 90 | w.Write([]byte("Fast response from server 2")) 91 | })) 92 | defer server2.Close() 93 | 94 | servers := []ServerAndWeight{ 95 | {server: httputil.NewSingleHostReverseProxy(mustParseURL(server1.URL)), weight: 1, url: server1.URL}, 96 | {server: httputil.NewSingleHostReverseProxy(mustParseURL(server2.URL)), weight: 1, url: server2.URL}, 97 | } 98 | 99 | policy := NewLeastLatencyPolicy(servers) 100 | 101 | // Initially, all servers should have 0 latency 102 | server := policy.GetNext() 103 | if server == nil { 104 | t.Fatal("Got nil server from LeastLatencyPolicy") 105 | } 106 | 107 | // Simulate a request and update latency 108 | w := httptest.NewRecorder() 109 | r, _ := http.NewRequest("GET", server1.URL, nil) 110 | server.ServeHTTP(w, r) 111 | 112 | // The policy should now prefer the fast second server 113 | server = policy.GetNext() 114 | serverURL := strings.TrimSuffix(getProxyURL(server), "/") 115 | if serverURL != strings.TrimSuffix(server2.URL, "/") { 116 | t.Errorf("LeastLatencyPolicy did not choose the server with least latency Got %s Want %s", serverURL, server2.URL) 117 | } 118 | } 119 | 120 | func TestBalancerServeHTTP(t *testing.T) { 121 | // Create mock servers 122 | server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 123 | w.WriteHeader(http.StatusOK) 124 | w.Write([]byte("Response from server 1")) 125 | })) 126 | defer server1.Close() 127 | 128 | server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 129 | w.WriteHeader(http.StatusOK) 130 | w.Write([]byte("Response from server 2")) 131 | })) 132 | defer server2.Close() 133 | 134 | // Create ServerAndWeight structs using the mock servers 135 | servers := []ServerAndWeight{ 136 | {server: httputil.NewSingleHostReverseProxy(mustParseURL(server1.URL)), weight: 1, url: server1.URL}, 137 | {server: httputil.NewSingleHostReverseProxy(mustParseURL(server2.URL)), weight: 1, url: server2.URL}, 138 | } 139 | 140 | policy := NewRoundRobinPolicy(servers) 141 | balancer := &Balancer{policy: policy} 142 | 143 | w := httptest.NewRecorder() 144 | r, _ := http.NewRequest("GET", "http://example.com", nil) 145 | 146 | balancer.ServeHTTP(w, r) 147 | 148 | if w.Code != http.StatusOK { 149 | t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) 150 | } 151 | 152 | w = httptest.NewRecorder() 153 | r, _ = http.NewRequest("GET", "http://example.com", nil) 154 | 155 | balancer.ServeHTTP(w, r) 156 | 157 | if w.Code != http.StatusOK { 158 | t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) 159 | } 160 | } 161 | 162 | // Helper function to create a dummy reverse proxy 163 | func createDummyProxy(targetURL string) *httputil.ReverseProxy { 164 | url, _ := url.Parse(targetURL) 165 | return httputil.NewSingleHostReverseProxy(url) 166 | } 167 | 168 | // Helper function to get the target URL of a reverse proxy 169 | func getProxyURL(proxy *httputil.ReverseProxy) string { 170 | req, _ := http.NewRequest("GET", "http://example.com", nil) 171 | proxy.Director(req) 172 | return req.URL.String() 173 | } 174 | 175 | // Helper function to parse URL and panic on error 176 | func mustParseURL(rawURL string) *url.URL { 177 | u, err := url.Parse(rawURL) 178 | if err != nil { 179 | panic(err) 180 | } 181 | return u 182 | } 183 | -------------------------------------------------------------------------------- /internal/handlers/files.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "path" 7 | "strings" 8 | ) 9 | 10 | type Files struct { 11 | basePath string 12 | handler http.Handler 13 | } 14 | 15 | func NewFiles(dirPath string, basePath string) Files { 16 | return Files{handler: http.FileServer(http.Dir(dirPath)), basePath: basePath} 17 | } 18 | 19 | func (f Files) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 | cleanedPath, err := removeBaseURLPath(f.basePath, r.URL.Path) 21 | if err == nil { 22 | r.URL.Path = cleanedPath 23 | } 24 | 25 | f.handler.ServeHTTP(w, r) 26 | } 27 | 28 | func removeBaseURLPath(basePath, fullPath string) (string, error) { 29 | // Ensure paths start with "/" 30 | basePath = "/" + strings.Trim(basePath, "/") 31 | fullPath = "/" + strings.Trim(fullPath, "/") 32 | 33 | // Normalize paths 34 | basePath = path.Clean(basePath) 35 | fullPath = path.Clean(fullPath) 36 | 37 | // Check if the full path starts with the base path 38 | if !strings.HasPrefix(fullPath, basePath) { 39 | return "", fmt.Errorf("full path %s is not in base path %s", fullPath, basePath) 40 | } 41 | 42 | // Remove the base path 43 | relPath := strings.TrimPrefix(fullPath, basePath) 44 | 45 | // Ensure the relative path starts with "/" 46 | relPath = "/" + strings.TrimPrefix(relPath, "/") 47 | 48 | return relPath, nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/handlers/files_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestRemoveBaseURLPath(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | basePath string 15 | fullPath string 16 | want string 17 | wantErr bool 18 | }{ 19 | { 20 | name: "simple path", 21 | basePath: "/api", 22 | fullPath: "/api/file.txt", 23 | want: "/file.txt", 24 | wantErr: false, 25 | }, 26 | { 27 | name: "path with multiple segments", 28 | basePath: "/api/v1", 29 | fullPath: "/api/v1/docs/file.txt", 30 | want: "/docs/file.txt", 31 | wantErr: false, 32 | }, 33 | { 34 | name: "paths with trailing slashes", 35 | basePath: "/api/", 36 | fullPath: "/api/file.txt/", 37 | want: "/file.txt", 38 | wantErr: false, 39 | }, 40 | { 41 | name: "paths without leading slashes", 42 | basePath: "api", 43 | fullPath: "api/file.txt", 44 | want: "/file.txt", 45 | wantErr: false, 46 | }, 47 | { 48 | name: "path not in base path", 49 | basePath: "/api", 50 | fullPath: "/other/file.txt", 51 | want: "", 52 | wantErr: true, 53 | }, 54 | { 55 | name: "empty paths", 56 | basePath: "", 57 | fullPath: "/file.txt", 58 | want: "/file.txt", 59 | wantErr: false, 60 | }, 61 | { 62 | name: "identical paths", 63 | basePath: "/api", 64 | fullPath: "/api", 65 | want: "/", 66 | wantErr: false, 67 | }, 68 | } 69 | 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | got, err := removeBaseURLPath(tt.basePath, tt.fullPath) 73 | if (err != nil) != tt.wantErr { 74 | t.Errorf("removeBaseURLPath() error = %v, wantErr %v", err, tt.wantErr) 75 | return 76 | } 77 | if got != tt.want { 78 | t.Errorf("removeBaseURLPath() = %v, want %v", got, tt.want) 79 | } 80 | }) 81 | } 82 | } 83 | 84 | func TestFiles_ServeHTTP(t *testing.T) { 85 | // Create a temporary directory for test files 86 | tmpDir, err := os.MkdirTemp("", "files_test") 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | defer os.RemoveAll(tmpDir) 91 | 92 | // Create a test file 93 | testContent := []byte("test file content") 94 | testFilePath := filepath.Join(tmpDir, "test.txt") 95 | if err := os.WriteFile(testFilePath, testContent, 0644); err != nil { 96 | t.Fatal(err) 97 | } 98 | 99 | tests := []struct { 100 | name string 101 | basePath string 102 | requestPath string 103 | expectedStatus int 104 | expectedBody string 105 | }{ 106 | { 107 | name: "valid file request", 108 | basePath: "/files", 109 | requestPath: "/files/test.txt", 110 | expectedStatus: http.StatusOK, 111 | expectedBody: "test file content", 112 | }, 113 | { 114 | name: "file not found", 115 | basePath: "/files", 116 | requestPath: "/files/nonexistent.txt", 117 | expectedStatus: http.StatusNotFound, 118 | expectedBody: "404 page not found\n", 119 | }, 120 | { 121 | name: "path outside base path", 122 | basePath: "/files", 123 | requestPath: "/other/test.txt", 124 | expectedStatus: http.StatusNotFound, 125 | expectedBody: "404 page not found\n", 126 | }, 127 | } 128 | 129 | for _, tt := range tests { 130 | t.Run(tt.name, func(t *testing.T) { 131 | // Create a new Files handler 132 | files := NewFiles(tmpDir, tt.basePath) 133 | 134 | // Create a test request 135 | req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil) 136 | w := httptest.NewRecorder() 137 | 138 | // Serve the request 139 | files.ServeHTTP(w, req) 140 | 141 | // Check status code 142 | if w.Code != tt.expectedStatus { 143 | t.Errorf("ServeHTTP() status = %v, want %v", w.Code, tt.expectedStatus) 144 | } 145 | 146 | // Check response body 147 | if w.Body.String() != tt.expectedBody { 148 | t.Errorf("ServeHTTP() body = %v, want %v", w.Body.String(), tt.expectedBody) 149 | } 150 | }) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /internal/handlers/proxy.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "net/url" 7 | 8 | "github.com/hvuhsg/gatego/internal/config" 9 | "github.com/hvuhsg/gatego/internal/contextvalues" 10 | semconv "go.opentelemetry.io/otel/semconv/v1.4.0" 11 | ) 12 | 13 | type Proxy struct { 14 | proxy *httputil.ReverseProxy 15 | } 16 | 17 | func NewProxy(service config.Service, path config.Path) (Proxy, error) { 18 | serviceURL, err := url.Parse(*path.Destination) 19 | if err != nil { 20 | return Proxy{}, err 21 | } 22 | 23 | proxy := httputil.NewSingleHostReverseProxy(serviceURL) 24 | 25 | server := Proxy{proxy: proxy} 26 | return server, nil 27 | } 28 | 29 | func (p Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { 30 | tracer := contextvalues.TracerFromContext(r.Context()) 31 | if tracer != nil { 32 | ctx, span := tracer.Start(r.Context(), "request.upstream") 33 | span.SetAttributes(semconv.HTTPServerAttributesFromHTTPRequest(r.Host, r.URL.Path, r)...) 34 | r = r.WithContext(ctx) 35 | defer span.End() 36 | } 37 | p.proxy.ServeHTTP(w, r) 38 | } 39 | -------------------------------------------------------------------------------- /internal/middlewares/addheader.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "go.opentelemetry.io/otel/trace" 8 | ) 9 | 10 | func NewAddHeadersMiddleware(headers map[string]string) Middleware { 11 | return func(next http.Handler) http.Handler { 12 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | span := trace.SpanFromContext(r.Context()) 14 | 15 | for header, value := range headers { 16 | r.Header.Set(header, value) 17 | span.AddEvent(fmt.Sprintf("Added header %s to request", header)) 18 | } 19 | next.ServeHTTP(w, r) 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/middlewares/cache.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/patrickmn/go-cache" 10 | "go.opentelemetry.io/otel/trace" 11 | ) 12 | 13 | const DEFAULT_CACHE_TTL = time.Minute * 1 14 | const CLEANUP_CACHE_INTERVAL = time.Minute * 10 15 | 16 | var responseCache = cache.New(DEFAULT_CACHE_TTL, CLEANUP_CACHE_INTERVAL) // Default cache with a placeholder TTL 17 | 18 | type CachedResponse struct { 19 | statusCode int 20 | body []byte 21 | headers http.Header 22 | } 23 | 24 | func NewCacheMiddleware() Middleware { 25 | return func(next http.Handler) http.Handler { 26 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 | span := trace.SpanFromContext(r.Context()) 28 | 29 | // Check if response response is already cached 30 | cachedResponse, found := responseCache.Get(r.URL.String()) 31 | if found { 32 | span.AddEvent("Cache hit") 33 | response := cachedResponse.(CachedResponse) 34 | for header := range response.headers { 35 | w.Header().Set(header, response.headers.Get(header)) 36 | } 37 | w.WriteHeader(response.statusCode) 38 | w.Write(response.body) 39 | return 40 | } 41 | 42 | // Serve the next handler and capture the response 43 | rc := NewRecorder() 44 | next.ServeHTTP(rc, r) 45 | 46 | // Get cache control headers 47 | cacheControl := rc.Header().Get("Cache-Control") 48 | maxAge := getCacheMaxAge(cacheControl) 49 | expires := getCacheExpires(rc.Header().Get("Expires")) 50 | 51 | // Determine TTL based on cache headers 52 | ttl := time.Second * 0 53 | if maxAge > 0 { 54 | ttl = time.Duration(maxAge) * time.Second 55 | } else if !expires.IsZero() { 56 | ttl = time.Until(expires) 57 | } 58 | 59 | // Cache the response if it's cacheable 60 | if ttl > 0 { 61 | cachedResponse := CachedResponse{statusCode: rc.Result().StatusCode, body: rc.Body.Bytes(), headers: rc.Result().Header} 62 | responseCache.Set(r.URL.String(), cachedResponse, ttl) 63 | span.AddEvent("Response stored in cache") 64 | } 65 | 66 | // Write the captured response (original or cached) 67 | rc.WriteTo(w) 68 | }) 69 | } 70 | } 71 | 72 | func getCacheMaxAge(cacheControl string) int { 73 | for _, directive := range strings.Split(cacheControl, ",") { 74 | directive = strings.TrimSpace(directive) 75 | if strings.HasPrefix(directive, "max-age=") { 76 | maxAge, err := strconv.Atoi(strings.TrimPrefix(directive, "max-age=")) 77 | if err == nil { 78 | return maxAge 79 | } 80 | } 81 | } 82 | return 0 83 | } 84 | 85 | func getCacheExpires(expiresHeader string) time.Time { 86 | expires, err := time.Parse(time.RFC1123, expiresHeader) 87 | if err != nil { 88 | return time.Time{} 89 | } 90 | return expires 91 | } 92 | -------------------------------------------------------------------------------- /internal/middlewares/cache_test.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestCacheMiddleware(t *testing.T) { 11 | t.Parallel() 12 | // Reset cache before each test 13 | responseCache.Flush() 14 | 15 | t.Run("Should not cache response with no cache headers", func(t *testing.T) { 16 | responseText := "test response" 17 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | w.WriteHeader(200) 19 | w.Write([]byte(responseText)) 20 | }) 21 | 22 | middleware := NewCacheMiddleware()(handler) 23 | req := httptest.NewRequest("GET", "/test", nil) 24 | 25 | // First request 26 | w1 := httptest.NewRecorder() 27 | middleware.ServeHTTP(w1, req) 28 | 29 | if w1.Body.String() != "test response" { 30 | t.Errorf("Expected 'test response', got '%s'", w1.Body.String()) 31 | } 32 | 33 | responseText = "new response" 34 | 35 | // Second request - should be served from cache 36 | w2 := httptest.NewRecorder() 37 | middleware.ServeHTTP(w2, req) 38 | 39 | if w2.Body.String() != "new response" { 40 | t.Errorf("Expected not cached 'new response', got '%s'", w2.Body.String()) 41 | } 42 | }) 43 | 44 | t.Run("Should respect max-age Cache-Control header", func(t *testing.T) { 45 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | w.Header().Set("Cache-Control", "max-age=1") 47 | w.WriteHeader(200) 48 | w.Write([]byte("cache-control test")) 49 | }) 50 | 51 | middleware := NewCacheMiddleware()(handler) 52 | req := httptest.NewRequest("GET", "/cache-control", nil) 53 | 54 | // First request 55 | w1 := httptest.NewRecorder() 56 | middleware.ServeHTTP(w1, req) 57 | 58 | // Wait for less than max-age 59 | time.Sleep(time.Millisecond * 500) 60 | 61 | // Should still be cached 62 | w2 := httptest.NewRecorder() 63 | middleware.ServeHTTP(w2, req) 64 | 65 | if w2.Body.String() != "cache-control test" { 66 | t.Errorf("Expected cached response before max-age expiration") 67 | } 68 | 69 | // Wait for cache to expire 70 | time.Sleep(time.Millisecond * 1500) 71 | 72 | if _, found := responseCache.Get("/cache-control"); found { 73 | t.Error("Cache should have expired") 74 | } 75 | }) 76 | 77 | t.Run("Should respect Expires header", func(t *testing.T) { 78 | responseText := "expires test" 79 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 80 | expiresTime := time.Now().Add(2 * time.Second) 81 | w.Header().Set("Expires", expiresTime.Format(time.RFC1123)) 82 | w.WriteHeader(200) 83 | w.Write([]byte(responseText)) 84 | }) 85 | 86 | middleware := NewCacheMiddleware()(handler) 87 | req := httptest.NewRequest("GET", "/expires", nil) 88 | 89 | // First request 90 | w1 := httptest.NewRecorder() 91 | middleware.ServeHTTP(w1, req) 92 | 93 | // Wait for less than expiration 94 | time.Sleep(time.Second * 1) 95 | 96 | responseText = "something else" 97 | 98 | // Should still be cached 99 | w2 := httptest.NewRecorder() 100 | middleware.ServeHTTP(w2, req) 101 | 102 | if w2.Body.String() != "expires test" { 103 | t.Errorf("Expected cached response before expiration") 104 | } 105 | 106 | // Wait for cache to expire 107 | time.Sleep(time.Second * 2) 108 | 109 | if _, found := responseCache.Get("/expires"); found { 110 | t.Error("Cache should have expired") 111 | } 112 | }) 113 | 114 | t.Run("Should preserve response headers", func(t *testing.T) { 115 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 116 | // Add expiration header 117 | expiresTime := time.Now().Add(50 * time.Second) 118 | w.Header().Set("Expires", expiresTime.Format(time.RFC1123)) 119 | 120 | w.Header().Set("Content-Type", "application/json") 121 | w.Header().Set("X-Custom-Header", "test-value") 122 | w.WriteHeader(200) 123 | w.Write([]byte(`{"message":"test"}`)) 124 | }) 125 | 126 | middleware := NewCacheMiddleware()(handler) 127 | req := httptest.NewRequest("GET", "/headers", nil) 128 | 129 | // First request 130 | w1 := httptest.NewRecorder() 131 | middleware.ServeHTTP(w1, req) 132 | 133 | // Second request - should preserve headers 134 | w2 := httptest.NewRecorder() 135 | middleware.ServeHTTP(w2, req) 136 | 137 | expectedHeaders := map[string]string{ 138 | "Content-Type": "application/json", 139 | "X-Custom-Header": "test-value", 140 | } 141 | 142 | for header, expectedValue := range expectedHeaders { 143 | if value := w2.Header().Get(header); value != expectedValue { 144 | t.Errorf("Expected header %s to be %s, got %s", header, expectedValue, value) 145 | } 146 | } 147 | }) 148 | 149 | t.Run("Should handle invalid cache headers gracefully", func(t *testing.T) { 150 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 151 | w.Header().Set("Cache-Control", "max-age=invalid") 152 | w.Header().Set("Expires", "invalid-date") 153 | w.WriteHeader(200) 154 | w.Write([]byte("invalid headers test")) 155 | }) 156 | 157 | middleware := NewCacheMiddleware()(handler) 158 | req := httptest.NewRequest("GET", "/invalid-headers", nil) 159 | 160 | w := httptest.NewRecorder() 161 | middleware.ServeHTTP(w, req) 162 | 163 | if w.Body.String() != "invalid headers test" { 164 | t.Errorf("Expected normal response despite invalid headers") 165 | } 166 | }) 167 | } 168 | 169 | func TestGetCacheMaxAge(t *testing.T) { 170 | t.Parallel() 171 | tests := []struct { 172 | name string 173 | cacheControl string 174 | expected int 175 | }{ 176 | {"Valid max-age", "max-age=60", 60}, 177 | {"Multiple directives", "public, max-age=30", 30}, 178 | {"Invalid max-age", "max-age=invalid", 0}, 179 | {"No max-age", "public, private", 0}, 180 | {"Empty string", "", 0}, 181 | } 182 | 183 | for _, tt := range tests { 184 | t.Run(tt.name, func(t *testing.T) { 185 | result := getCacheMaxAge(tt.cacheControl) 186 | if result != tt.expected { 187 | t.Errorf("getCacheMaxAge(%s) = %d; want %d", tt.cacheControl, result, tt.expected) 188 | } 189 | }) 190 | } 191 | } 192 | 193 | func TestGetCacheExpires(t *testing.T) { 194 | t.Parallel() 195 | 196 | now := time.Now() 197 | tests := []struct { 198 | name string 199 | expiresHeader string 200 | wantZero bool 201 | }{ 202 | {"Valid date", now.Format(time.RFC1123), false}, 203 | {"Invalid date", "invalid-date", true}, 204 | {"Empty string", "", true}, 205 | } 206 | 207 | for _, tt := range tests { 208 | t.Run(tt.name, func(t *testing.T) { 209 | result := getCacheExpires(tt.expiresHeader) 210 | if tt.wantZero && !result.IsZero() { 211 | t.Errorf("getCacheExpires(%s) expected zero time, got %v", tt.expiresHeader, result) 212 | } 213 | if !tt.wantZero && result.IsZero() { 214 | t.Errorf("getCacheExpires(%s) expected non-zero time, got zero time", tt.expiresHeader) 215 | } 216 | }) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /internal/middlewares/gzip.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "compress/gzip" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | // GzipMiddleware compresses the response using gzip if the client supports it 10 | func GzipMiddleware(next http.Handler) http.Handler { 11 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | // Check if the client accepts gzip encoding 13 | if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { 14 | // Client doesn't support gzip, serve the next handler 15 | next.ServeHTTP(w, r) 16 | return 17 | } 18 | 19 | // Create a gzip.Writer 20 | gzipWriter := gzip.NewWriter(w) 21 | defer gzipWriter.Close() 22 | 23 | // Serve the next handler, writing the response into the ResponseCapture 24 | rc := NewRecorder() 25 | next.ServeHTTP(rc, r) 26 | 27 | rc.WriteHeadersTo(w) 28 | 29 | w.Header().Del("Content-Length") 30 | w.Header().Set("Content-Encoding", "gzip") // Set Content-Encoding header 31 | 32 | w.WriteHeader(rc.Result().StatusCode) 33 | 34 | gzipWriter.Write(rc.Body.Bytes()) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /internal/middlewares/gzip_test.go: -------------------------------------------------------------------------------- 1 | package middlewares_test 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/hvuhsg/gatego/internal/middlewares" 12 | ) 13 | 14 | // Helper to decode gzip data 15 | func decodeGzip(t *testing.T, gzippedBody []byte) string { 16 | gzipReader, err := gzip.NewReader(bytes.NewReader(gzippedBody)) 17 | if err != nil { 18 | t.Fatalf("failed to create gzip reader: %v", err) 19 | } 20 | defer gzipReader.Close() 21 | 22 | var decodedBody bytes.Buffer 23 | if _, err := io.Copy(&decodedBody, gzipReader); err != nil { 24 | t.Fatalf("failed to decode gzip body: %v", err) 25 | } 26 | 27 | return decodedBody.String() 28 | } 29 | 30 | // TestGzipMiddleware_NoGzipSupport tests the middleware when the client does not support gzip 31 | func TestGzipMiddleware_NoGzipSupport(t *testing.T) { 32 | // Create a test handler to be wrapped by the middleware 33 | nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | w.Write([]byte("Hello, World")) 35 | }) 36 | 37 | // Wrap the handler with GzipMiddleware 38 | handler := middlewares.GzipMiddleware(nextHandler) 39 | 40 | // Create a new HTTP request without gzip support 41 | req := httptest.NewRequest(http.MethodGet, "/", nil) 42 | req.Header.Set("Accept-Encoding", "identity") 43 | 44 | // Record the response 45 | rr := httptest.NewRecorder() 46 | handler.ServeHTTP(rr, req) 47 | 48 | // Check that the response is not gzipped 49 | if encoding := rr.Header().Get("Content-Encoding"); encoding != "" { 50 | t.Errorf("expected no gzip encoding, got %s", encoding) 51 | } 52 | 53 | // Check the body content 54 | if rr.Body.String() != "Hello, World" { 55 | t.Errorf("expected 'Hello, World', got %s", rr.Body.String()) 56 | } 57 | } 58 | 59 | // TestGzipMiddleware_WithGzipSupport tests the middleware when the client supports gzip 60 | func TestGzipMiddleware_WithGzipSupport(t *testing.T) { 61 | // Create a test handler to be wrapped by the middleware 62 | nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 | w.WriteHeader(http.StatusOK) 64 | w.Write([]byte("Hello, World")) 65 | }) 66 | 67 | // Wrap the handler with GzipMiddleware 68 | handler := middlewares.GzipMiddleware(nextHandler) 69 | 70 | // Create a new HTTP request with gzip support 71 | req := httptest.NewRequest(http.MethodGet, "/", nil) 72 | req.Header.Set("Accept-Encoding", "gzip") 73 | 74 | // Record the response 75 | rr := httptest.NewRecorder() 76 | handler.ServeHTTP(rr, req) 77 | 78 | // Check that the response is gzipped 79 | if encoding := rr.Header().Get("Content-Encoding"); encoding != "gzip" { 80 | t.Errorf("expected gzip encoding, got %s", encoding) 81 | } 82 | 83 | // Decode the gzipped response body 84 | gzippedBody := rr.Body.Bytes() 85 | decodedBody := decodeGzip(t, gzippedBody) 86 | 87 | // Check the body content 88 | if decodedBody != "Hello, World" { 89 | t.Errorf("expected 'Hello, World', got %s", decodedBody) 90 | } 91 | } 92 | 93 | // TestGzipMiddleware_StatusCode tests that the middleware preserves status codes 94 | func TestGzipMiddleware_StatusCode(t *testing.T) { 95 | // Create a test handler to be wrapped by the middleware 96 | nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 97 | w.WriteHeader(http.StatusCreated) 98 | w.Write([]byte("Created")) 99 | }) 100 | 101 | // Wrap the handler with GzipMiddleware 102 | handler := middlewares.GzipMiddleware(nextHandler) 103 | 104 | // Create a new HTTP request with gzip support 105 | req := httptest.NewRequest(http.MethodGet, "/", nil) 106 | req.Header.Set("Accept-Encoding", "gzip") 107 | 108 | // Record the response 109 | rr := httptest.NewRecorder() 110 | handler.ServeHTTP(rr, req) 111 | 112 | // Check that the response is gzipped 113 | if encoding := rr.Header().Get("Content-Encoding"); encoding != "gzip" { 114 | t.Errorf("expected gzip encoding, got %s", encoding) 115 | } 116 | 117 | // Check that the status code is preserved 118 | if status := rr.Result().StatusCode; status != http.StatusCreated { 119 | t.Errorf("expected status code %d, got %d", http.StatusCreated, status) 120 | } 121 | 122 | // Decode the gzipped response body 123 | gzippedBody := rr.Body.Bytes() 124 | decodedBody := decodeGzip(t, gzippedBody) 125 | 126 | // Check the body content 127 | if decodedBody != "Created" { 128 | t.Errorf("expected 'Created', got %s", decodedBody) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /internal/middlewares/logging.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | func formatDuration(ms int64) string { 11 | if ms < 1000 { 12 | return fmt.Sprintf("%dms", ms) 13 | } 14 | return fmt.Sprintf("%.1fs", float64(ms)/1000) 15 | } 16 | 17 | // Logging middleware log the request / response with the log style of nginx 18 | func NewLoggingMiddleware(out io.Writer) func(http.Handler) http.Handler { 19 | return func(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | start := time.Now().UnixMilli() 22 | 23 | rh := &responseHook{ResponseWriter: w, respSize: 0} 24 | next.ServeHTTP(rh, r) 25 | 26 | end := time.Now().UnixMilli() 27 | 28 | scheme := "http" 29 | if r.TLS != nil { 30 | scheme = "https" 31 | } 32 | fullURL := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.String()) 33 | 34 | method := r.Method 35 | path := r.URL.Path 36 | responseSize := rh.respSize 37 | remoteAddr := r.RemoteAddr 38 | date := time.Now().Format("2006-01-02 15:04:05") 39 | userAgent := r.UserAgent() 40 | statusCode := rh.statusCode 41 | duration := formatDuration(end - start) 42 | 43 | fmt.Fprintf(out, "%s - - [%s] \"%s %s %s\" %d %d %s \"%s\" \"%s\"\n", remoteAddr, date, method, path, r.Proto, statusCode, responseSize, duration, fullURL, userAgent) 44 | }) 45 | } 46 | } 47 | 48 | type responseHook struct { 49 | http.ResponseWriter 50 | respSize int 51 | statusCode int 52 | } 53 | 54 | func (rh *responseHook) Write(b []byte) (int, error) { 55 | // Save the length of the response 56 | rh.respSize += len(b) 57 | 58 | return rh.ResponseWriter.Write(b) 59 | } 60 | 61 | func (rh *responseHook) WriteHeader(statusCode int) { 62 | // Save status code 63 | rh.statusCode = statusCode 64 | 65 | rh.ResponseWriter.WriteHeader(statusCode) 66 | } 67 | -------------------------------------------------------------------------------- /internal/middlewares/logging_test.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "regexp" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestLoggingMiddleware(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | method string 16 | path string 17 | statusCode int 18 | responseBody string 19 | expectedLogParts []string 20 | }{ 21 | { 22 | name: "GET request", 23 | method: "GET", 24 | path: "/test", 25 | statusCode: 200, 26 | responseBody: "OK", 27 | expectedLogParts: []string{ 28 | "GET", 29 | "/test", 30 | "HTTP/1.1", 31 | "200", 32 | "2", 33 | "http://example.com/test", 34 | }, 35 | }, 36 | { 37 | name: "POST request with 404", 38 | method: "POST", 39 | path: "/notfound", 40 | statusCode: 404, 41 | responseBody: "Not Found", 42 | expectedLogParts: []string{ 43 | "POST", 44 | "/notfound", 45 | "HTTP/1.1", 46 | "404", 47 | "9", 48 | "http://example.com/notfound", 49 | }, 50 | }, 51 | } 52 | 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | // Create a buffer to capture the log output 56 | buf := &bytes.Buffer{} 57 | 58 | // Create a test handler that returns the specified status code and body 59 | testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 60 | w.WriteHeader(tt.statusCode) 61 | w.Write([]byte(tt.responseBody)) 62 | }) 63 | 64 | // Create the logging middleware 65 | loggingMiddleware := NewLoggingMiddleware(buf) 66 | 67 | // Create a test server with the logging middleware 68 | ts := httptest.NewServer(loggingMiddleware(testHandler)) 69 | defer ts.Close() 70 | 71 | // Create and send the request 72 | req, _ := http.NewRequest(tt.method, ts.URL+tt.path, nil) 73 | req.Host = "example.com" // Set a consistent host for testing 74 | resp, err := http.DefaultClient.Do(req) 75 | if err != nil { 76 | t.Fatalf("Error making request: %v", err) 77 | } 78 | defer resp.Body.Close() 79 | 80 | // Check the response 81 | if resp.StatusCode != tt.statusCode { 82 | t.Errorf("Expected status code %d, got %d", tt.statusCode, resp.StatusCode) 83 | } 84 | 85 | // Check the log output 86 | logOutput := buf.String() 87 | for _, expectedPart := range tt.expectedLogParts { 88 | if !strings.Contains(logOutput, expectedPart) { 89 | t.Errorf("Expected log to contain '%s', but it didn't. Log: %s", expectedPart, logOutput) 90 | } 91 | } 92 | 93 | // Check for the presence of a timestamp in the expected format 94 | timeStampFormat := "[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}" 95 | if matched, _ := regexp.MatchString(timeStampFormat, logOutput); !matched { 96 | t.Errorf("Expected log to contain a timestamp in format YYYY-MM-DD HH:MM:SS, but it didn't. Log: %s", logOutput) 97 | } 98 | 99 | // Check for the presence of a duration 100 | if !strings.Contains(logOutput, "ms") && !strings.Contains(logOutput, "s") { 101 | t.Errorf("Expected log to contain a duration, but it didn't. Log: %s", logOutput) 102 | } 103 | }) 104 | } 105 | } 106 | 107 | func TestFormatDuration(t *testing.T) { 108 | tests := []struct { 109 | name string 110 | duration int64 111 | expected string 112 | }{ 113 | {"Less than a second", 500, "500ms"}, 114 | {"Exactly one second", 1000, "1.0s"}, 115 | {"More than a second", 1500, "1.5s"}, 116 | {"Multiple seconds", 3750, "3.8s"}, 117 | } 118 | 119 | for _, tt := range tests { 120 | t.Run(tt.name, func(t *testing.T) { 121 | result := formatDuration(tt.duration) 122 | if result != tt.expected { 123 | t.Errorf("formatDuration(%d) = %s; want %s", tt.duration, result, tt.expected) 124 | } 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /internal/middlewares/middleware.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import "net/http" 4 | 5 | type Middleware func(http.Handler) http.Handler 6 | 7 | type HandlerWithMiddleware struct { 8 | finalHandler http.Handler 9 | middlewares []Middleware 10 | } 11 | 12 | func NewHandlerWithMiddleware(handler http.Handler) *HandlerWithMiddleware { 13 | return &HandlerWithMiddleware{ 14 | finalHandler: handler, 15 | middlewares: []Middleware{}, 16 | } 17 | } 18 | 19 | func (h *HandlerWithMiddleware) Add(middleware Middleware) { 20 | h.middlewares = append(h.middlewares, middleware) 21 | } 22 | 23 | func (h *HandlerWithMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { 24 | // Chain the middlewares around the final handler 25 | handler := h.finalHandler 26 | for i := len(h.middlewares) - 1; i >= 0; i-- { 27 | handler = h.middlewares[i](handler) 28 | } 29 | handler.ServeHTTP(w, r) 30 | } 31 | -------------------------------------------------------------------------------- /internal/middlewares/minify.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/tdewolff/minify/v2" 9 | "github.com/tdewolff/minify/v2/css" 10 | "github.com/tdewolff/minify/v2/html" 11 | "github.com/tdewolff/minify/v2/js" 12 | "github.com/tdewolff/minify/v2/json" 13 | "github.com/tdewolff/minify/v2/svg" 14 | "github.com/tdewolff/minify/v2/xml" 15 | "go.opentelemetry.io/otel/trace" 16 | ) 17 | 18 | type MinifyConfig struct { 19 | ALL bool 20 | JS bool 21 | CSS bool 22 | HTML bool 23 | JSON bool 24 | SVG bool 25 | XML bool 26 | } 27 | 28 | func NewMinifyMiddleware(config MinifyConfig) Middleware { 29 | m := minify.New() 30 | 31 | // Add minifiers for the different content types 32 | if config.HTML || config.ALL { 33 | m.AddFunc("text/html", html.Minify) 34 | } 35 | if config.CSS || config.ALL { 36 | m.AddFunc("text/css", css.Minify) 37 | } 38 | if config.JS || config.ALL { 39 | m.AddFunc("application/javascript", js.Minify) 40 | } 41 | if config.JSON || config.ALL { 42 | m.AddFunc("application/json", json.Minify) 43 | } 44 | if config.SVG || config.ALL { 45 | m.AddFunc("image/svg+xml", svg.Minify) 46 | } 47 | if config.XML || config.ALL { 48 | m.AddFunc("application/xml", xml.Minify) 49 | } 50 | 51 | return func(next http.Handler) http.Handler { 52 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | span := trace.SpanFromContext(r.Context()) 54 | 55 | // Create a custom ResponseWriter to capture the response 56 | rc := NewRecorder() 57 | 58 | // Serve the next handler 59 | next.ServeHTTP(rc, r) 60 | 61 | // Get the content type of the response 62 | contentType := rc.Header().Get("Content-Type") 63 | 64 | minifiedContent, err := m.Bytes(contentType, rc.Body.Bytes()) 65 | if err != nil { 66 | rc.WriteTo(w) // Return the original response 67 | return 68 | } 69 | 70 | span.AddEvent(fmt.Sprintf("Minified response content, content-type = %s", contentType)) 71 | 72 | // Write the minified content to the response 73 | w.Header().Set("Content-Length", strconv.Itoa(len(minifiedContent))) 74 | rc.WriteHeadersTo(w) 75 | w.WriteHeader(rc.Result().StatusCode) 76 | 77 | w.Write(minifiedContent) 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /internal/middlewares/minify_test.go: -------------------------------------------------------------------------------- 1 | package middlewares_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/hvuhsg/gatego/internal/middlewares" 9 | ) 10 | 11 | // Helper function to create a basic next handler that returns content with a specific content type 12 | func createHandler(contentType, content string) http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | w.Header().Set("Content-Type", contentType) 15 | w.WriteHeader(http.StatusOK) 16 | w.Write([]byte(content)) 17 | }) 18 | } 19 | 20 | // TestMinifyMiddleware_HTML tests HTML minification 21 | func TestMinifyMiddleware_HTML(t *testing.T) { 22 | handler := createHandler("text/html", "