feat: Add proxy logging configuration and IP detection improvements
- Add `GORDON_PROXY_ENABLE_LOGS` environment variable to control HTTP request logging - Implement default `true` value for proxy log enabling - Enhance container IP detection with multiple fallback mechanisms - Improve route update logic for recreated containers - Add more robust IP verification during proxy route creation - Update documentation to reflect new logging configuration options - Enhance logging and error handling for container IP retrieval
- commit
- d205b1802c213cdb79d2568e6174489023990668
- author
- bamen · · 2025-03-10 08:17 UTC
- parent
- 6e11c22
- signature
- unsigned
16 files · +3380 -1774
M docker-compose.yml.example +1 -0
| @@ -46,6 +46,7 @@ services: | |||||
| 46 | - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | 46 | - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | ||
| 47 | - GORDON_PROXY_CERT_DIR=/certs | 47 | - GORDON_PROXY_CERT_DIR=/certs | ||
| 48 | - GORDON_PROXY_EMAIL=admin@example.com | 48 | - GORDON_PROXY_EMAIL=admin@example.com | ||
| 49 | + | - GORDON_PROXY_ENABLE_LOGS=true # Enable HTTP request logging (set to false to disable) | |||
| 49 | 50 | ||||
| 50 | # Session Secret for security | 51 | # Session Secret for security | ||
| 51 | - SESSION_SECRET=replace_with_secure_random_string | 52 | - SESSION_SECRET=replace_with_secure_random_string | ||
M docs/CONTAINER_CONFIG.md +2 -0
| @@ -183,6 +183,7 @@ Gordon supports the following environment variables for configuration: | |||||
| 183 | | `GORDON_PROXY_EMAIL` | Email for Let's Encrypt | Empty | | 183 | | `GORDON_PROXY_EMAIL` | Email for Let's Encrypt | Empty | | ||
| 184 | | `GORDON_PROXY_CACHE_SIZE` | Certificate cache size | `1000` | | 184 | | `GORDON_PROXY_CACHE_SIZE` | Certificate cache size | `1000` | | ||
| 185 | | `GORDON_PROXY_GRACE_PERIOD` | Shutdown grace period in seconds | `30` | | 185 | | `GORDON_PROXY_GRACE_PERIOD` | Shutdown grace period in seconds | `30` | | ||
| 186 | + | | `GORDON_PROXY_ENABLE_LOGS` | Controls whether HTTP request logs are enabled for the proxy | `true` | | |||
| 186 | 187 | ||||
| 187 | ### Other Configuration | 188 | ### Other Configuration | ||
| 188 | 189 | ||||
| @@ -205,6 +206,7 @@ If not specified in your config.yml, Gordon will automatically apply these recom | |||||
| 205 | | `ReverseProxy.CacheSize` | 1000 entries | Optimizes performance when serving multiple domains | | 206 | | `ReverseProxy.CacheSize` | 1000 entries | Optimizes performance when serving multiple domains | | ||
| 206 | | `ReverseProxy.GracePeriod` | 30 seconds | Allows for graceful server shutdowns | | 207 | | `ReverseProxy.GracePeriod` | 30 seconds | Allows for graceful server shutdowns | | ||
| 207 | | `ReverseProxy.AutoRenew` | true | Prevents certificate expiration issues | | 208 | | `ReverseProxy.AutoRenew` | true | Prevents certificate expiration issues | | ||
| 209 | + | | `ReverseProxy.EnableLogs` | true | Controls whether HTTP request logs are enabled for the proxy | | |||
| 208 | | `ReverseProxy.LetsEncryptMode` | staging | Safe default for testing; change to "production" for real certificates | | 210 | | `ReverseProxy.LetsEncryptMode` | staging | Safe default for testing; change to "production" for real certificates | | ||
| 209 | 211 | ||||
| 210 | This behavior prevents issues that could occur with zero values like: | 212 | This behavior prevents issues that could occur with zero values like: | ||
M internal/cli/cmd/proxy.go +75 -7
| @@ -1,8 +1,12 @@ | |||||
| 1 | package cmd | 1 | package cmd | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 4 | "github.com/bnema/gordon/internal/proxy" | 7 | "github.com/bnema/gordon/internal/proxy" | ||
| 5 | "github.com/bnema/gordon/internal/server" | 8 | "github.com/bnema/gordon/internal/server" | ||
| 9 | + | "github.com/bnema/gordon/pkg/docker" | |||
| 6 | "github.com/charmbracelet/log" | 10 | "github.com/charmbracelet/log" | ||
| 7 | "github.com/spf13/cobra" | 11 | "github.com/spf13/cobra" | ||
| 8 | ) | 12 | ) | ||
| @@ -17,6 +21,8 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||||
| 17 | protocol string | 21 | protocol string | ||
| 18 | path string | 22 | path string | ||
| 19 | remove bool | 23 | remove bool | ||
| 24 | + | update bool | |||
| 25 | + | force bool | |||
| 20 | ) | 26 | ) | ||
| 21 | 27 | ||||
| 22 | proxyCmd := &cobra.Command{ | 28 | proxyCmd := &cobra.Command{ | ||
| @@ -36,6 +42,53 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||||
| 36 | log.Fatal("Failed to create proxy:", err) | 42 | log.Fatal("Failed to create proxy:", err) | ||
| 37 | } | 43 | } | ||
| 38 | 44 | ||||
| 45 | + | // If update flag is set, update a container's IP address | |||
| 46 | + | if update { | |||
| 47 | + | if domain == "" { | |||
| 48 | + | log.Fatal("Domain is required for updating a route IP") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | if containerIP == "" { | |||
| 52 | + | log.Fatal("Container IP is required for updating a route IP") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // If force flag is not set, try to get the IP from the container ID | |||
| 56 | + | if !force && containerID != "" { | |||
| 57 | + | // Get container info to verify the IP | |||
| 58 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |||
| 59 | + | if err != nil { | |||
| 60 | + | log.Warn("Failed to get container info, using provided IP", "error", err) | |||
| 61 | + | } else { | |||
| 62 | + | // Look for the network specified in the config | |||
| 63 | + | networkName := a.Config.ContainerEngine.Network | |||
| 64 | + | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | |||
| 65 | + | if networkSettings.IPAddress != containerIP { | |||
| 66 | + | log.Warn("Provided IP doesn't match container's actual IP", | |||
| 67 | + | "provided", containerIP, | |||
| 68 | + | "actual", networkSettings.IPAddress) | |||
| 69 | + | if !force { | |||
| 70 | + | log.Info("Using actual container IP instead of provided IP", | |||
| 71 | + | "container_id", containerID, | |||
| 72 | + | "new_ip", networkSettings.IPAddress) | |||
| 73 | + | containerIP = networkSettings.IPAddress | |||
| 74 | + | } else { | |||
| 75 | + | log.Warn("Force flag is set, using provided IP despite mismatch") | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Update the route | |||
| 83 | + | err := p.ForceUpdateRouteIP(domain, containerIP) | |||
| 84 | + | if err != nil { | |||
| 85 | + | log.Fatal("Failed to update route IP:", err) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | log.Info("Route IP updated successfully", "domain", domain, "ip", containerIP) | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | ||||
| 39 | // If remove flag is set, remove the route | 92 | // If remove flag is set, remove the route | ||
| 40 | if remove { | 93 | if remove { | ||
| 41 | if domain == "" { | 94 | if domain == "" { | ||
| @@ -95,6 +148,10 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||||
| 95 | } | 148 | } | ||
| 96 | defer rows.Close() | 149 | defer rows.Close() | ||
| 97 | 150 | ||||
| 151 | + | fmt.Println("--- Proxy Routes ---") | |||
| 152 | + | fmt.Printf("%-30s %-12s %-15s %-6s %-7s\n", "DOMAIN", "CONTAINER ID", "IP", "PORT", "ACTIVE") | |||
| 153 | + | fmt.Println(strings.Repeat("-", 75)) | |||
| 154 | + | ||||
| 98 | var count int | 155 | var count int | ||
| 99 | for rows.Next() { | 156 | for rows.Next() { | ||
| 100 | var domain, containerID, containerIP, containerPort string | 157 | var domain, containerID, containerIP, containerPort string | ||
| @@ -103,16 +160,25 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||||
| 103 | log.Fatal("Failed to scan row:", err) | 160 | log.Fatal("Failed to scan row:", err) | ||
| 104 | } | 161 | } | ||
| 105 | 162 | ||||
| 106 | - | log.Info("Route", | 163 | + | // Format container ID to show just first 12 chars |
| 107 | - | "domain", domain, | 164 | + | shortID := containerID |
| 108 | - | "containerIP", containerIP, | 165 | + | if len(containerID) > 12 { |
| 109 | - | "containerPort", containerPort, | 166 | + | shortID = containerID[:12] |
| 110 | - | "active", active, | 167 | + | } |
| 111 | - | ) | 168 | + | |
| 169 | + | // Format active status | |||
| 170 | + | activeStr := "✓" | |||
| 171 | + | if !active { | |||
| 172 | + | activeStr = "✗" | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | fmt.Printf("%-30s %-12s %-15s %-6s %-7s\n", | |||
| 176 | + | domain, shortID, containerIP, containerPort, activeStr) | |||
| 112 | count++ | 177 | count++ | ||
| 113 | } | 178 | } | ||
| 114 | 179 | ||||
| 115 | - | log.Info("Proxy routes loaded", "count", count) | 180 | + | fmt.Println(strings.Repeat("-", 75)) |
| 181 | + | fmt.Printf("Total routes: %d\n", count) | |||
| 116 | }, | 182 | }, | ||
| 117 | } | 183 | } | ||
| 118 | 184 | ||||
| @@ -124,6 +190,8 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||||
| 124 | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "http", "Protocol (http or https)") | 190 | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "http", "Protocol (http or https)") | ||
| 125 | proxyCmd.Flags().StringVarP(&path, "path", "a", "/", "Path prefix to route") | 191 | proxyCmd.Flags().StringVarP(&path, "path", "a", "/", "Path prefix to route") | ||
| 126 | proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove the route for the specified domain") | 192 | proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove the route for the specified domain") | ||
| 193 | + | proxyCmd.Flags().BoolVarP(&update, "update", "u", false, "Update the IP address for an existing route") | |||
| 194 | + | proxyCmd.Flags().BoolVarP(&force, "force", "f", false, "Force update with provided IP even if it doesn't match container's actual IP") | |||
| 127 | 195 | ||||
| 128 | return proxyCmd | 196 | return proxyCmd | ||
| 129 | } | 197 | } | ||
M internal/common/init_config.go +21 -4
| @@ -112,6 +112,7 @@ type ReverseProxyConfig struct { | |||||
| 112 | Email string `yaml:"email"` // Email for Let's Encrypt | 112 | Email string `yaml:"email"` // Email for Let's Encrypt | ||
| 113 | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | 113 | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | ||
| 114 | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | 114 | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | ||
| 115 | + | EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true) | |||
| 115 | } | 116 | } | ||
| 116 | 117 | ||||
| 117 | // Default values | 118 | // Default values | ||
| @@ -177,6 +178,12 @@ func applyDefaultsToConfig(config *Config) bool { | |||||
| 177 | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | 178 | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | ||
| 178 | defaultsApplied = true | 179 | defaultsApplied = true | ||
| 179 | } | 180 | } | ||
| 181 | + | // Set EnableLogs to true by default if not specified | |||
| 182 | + | if !config.ReverseProxy.EnableLogs { | |||
| 183 | + | config.ReverseProxy.EnableLogs = true | |||
| 184 | + | logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true) | |||
| 185 | + | defaultsApplied = true | |||
| 186 | + | } | |||
| 180 | 187 | ||||
| 181 | // Handle autoRenew | 188 | // Handle autoRenew | ||
| 182 | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | 189 | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | ||
| @@ -532,12 +539,20 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 532 | } | 539 | } | ||
| 533 | } | 540 | } | ||
| 534 | if val := os.Getenv("GORDON_PROXY_GRACE_PERIOD"); val != "" { | 541 | if val := os.Getenv("GORDON_PROXY_GRACE_PERIOD"); val != "" { | ||
| 535 | - | if i, err := strconv.Atoi(val); err == nil { | 542 | + | i, err := strconv.Atoi(val) |
| 543 | + | if err == nil { | |||
| 536 | config.ReverseProxy.GracePeriod = i | 544 | config.ReverseProxy.GracePeriod = i | ||
| 537 | - | if printLogs { | |||
| 538 | - | logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i) | |||
| 539 | - | } | |||
| 540 | } | 545 | } | ||
| 546 | + | logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i) | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | // Handle GORDON_PROXY_ENABLE_LOGS environment variable | |||
| 550 | + | if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" { | |||
| 551 | + | enableLogs, err := strconv.ParseBool(val) | |||
| 552 | + | if err == nil { | |||
| 553 | + | config.ReverseProxy.EnableLogs = enableLogs | |||
| 554 | + | } | |||
| 555 | + | logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS", "value", config.ReverseProxy.EnableLogs) | |||
| 541 | } | 556 | } | ||
| 542 | } | 557 | } | ||
| 543 | 558 | ||||
| @@ -593,6 +608,7 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 593 | LetsEncryptMode: letsEncryptMode, | 608 | LetsEncryptMode: letsEncryptMode, | ||
| 594 | CacheSize: cacheSize, | 609 | CacheSize: cacheSize, | ||
| 595 | GracePeriod: gracePeriod, | 610 | GracePeriod: gracePeriod, | ||
| 611 | + | EnableLogs: true, | |||
| 596 | }, | 612 | }, | ||
| 597 | } | 613 | } | ||
| 598 | 614 | ||||
| @@ -730,6 +746,7 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 730 | LetsEncryptMode: letsEncryptMode, | 746 | LetsEncryptMode: letsEncryptMode, | ||
| 731 | CacheSize: cacheSize, | 747 | CacheSize: cacheSize, | ||
| 732 | GracePeriod: gracePeriod, | 748 | GracePeriod: gracePeriod, | ||
| 749 | + | EnableLogs: true, | |||
| 733 | }, | 750 | }, | ||
| 734 | } | 751 | } | ||
| 735 | 752 | ||||
M internal/httpserve/handlers/endpoint_deploy.go +159 -2
| @@ -2,6 +2,7 @@ | |||||
| 2 | package handlers | 2 | package handlers | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "database/sql" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "io" | 8 | "io" | ||
| @@ -9,6 +10,7 @@ import ( | |||||
| 9 | "os" | 10 | "os" | ||
| 10 | "path/filepath" | 11 | "path/filepath" | ||
| 11 | "strings" | 12 | "strings" | ||
| 13 | + | "time" | |||
| 12 | 14 | ||||
| 13 | authToken "github.com/bnema/gordon/internal/cli/auth" | 15 | authToken "github.com/bnema/gordon/internal/cli/auth" | ||
| 14 | "github.com/bnema/gordon/internal/common" | 16 | "github.com/bnema/gordon/internal/common" | ||
| @@ -397,6 +399,47 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 397 | return fmt.Errorf("failed to create proxy: %w", err) | 399 | return fmt.Errorf("failed to create proxy: %w", err) | ||
| 398 | } | 400 | } | ||
| 399 | 401 | ||||
| 402 | + | // Double check that we have a valid container IP - critical for recreated containers | |||
| 403 | + | if containerIP == "" || containerIP == containerID || containerIP == "localhost" { | |||
| 404 | + | log.Warn("Invalid container IP detected, attempting to get correct IP", | |||
| 405 | + | "containerID", containerID, | |||
| 406 | + | "invalid_ip", containerIP) | |||
| 407 | + | ||||
| 408 | + | // Try to get the container name for better logging | |||
| 409 | + | containerName, err := docker.GetContainerName(containerID) | |||
| 410 | + | if err != nil { | |||
| 411 | + | containerName = containerID | |||
| 412 | + | } else { | |||
| 413 | + | containerName = strings.TrimPrefix(containerName, "/") | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | // Get the correct container IP with retry logic | |||
| 417 | + | newContainerIP := GetContainerIP(a, containerID, containerName) | |||
| 418 | + | if newContainerIP != containerIP && newContainerIP != containerName { | |||
| 419 | + | log.Info("Updated container IP for proxy route", | |||
| 420 | + | "containerID", containerID, | |||
| 421 | + | "old_ip", containerIP, | |||
| 422 | + | "new_ip", newContainerIP) | |||
| 423 | + | containerIP = newContainerIP | |||
| 424 | + | } | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | // Get container info to check labels | |||
| 428 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |||
| 429 | + | if err != nil { | |||
| 430 | + | log.Warn("Failed to get container info", "error", err) | |||
| 431 | + | } else { | |||
| 432 | + | // Check if the container has a gordon.proxy.port label that might override containerPort | |||
| 433 | + | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 434 | + | if portLabel != containerPort { | |||
| 435 | + | log.Info("Using container label port instead of provided port", | |||
| 436 | + | "label_port", portLabel, | |||
| 437 | + | "provided_port", containerPort) | |||
| 438 | + | containerPort = portLabel | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | } | |||
| 442 | + | ||||
| 400 | // Extract the protocol from the target domain | 443 | // Extract the protocol from the target domain | ||
| 401 | protocol := "http" | 444 | protocol := "http" | ||
| 402 | 445 | ||||
| @@ -408,8 +451,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 408 | "protocol", protocol) | 451 | "protocol", protocol) | ||
| 409 | } else { | 452 | } else { | ||
| 410 | // If target domain doesn't specify protocol, check the container labels | 453 | // If target domain doesn't specify protocol, check the container labels | ||
| 411 | - | containerInfo, err := docker.GetContainerInfo(containerID) | 454 | + | if err == nil { // Only check if we have containerInfo |
| 412 | - | if err == nil { | |||
| 413 | // Check if gordon.proxy.ssl label is set to true | 455 | // Check if gordon.proxy.ssl label is set to true | ||
| 414 | if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | 456 | if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | ||
| 415 | (sslValue == "true" || sslValue == "1" || sslValue == "yes") { | 457 | (sslValue == "true" || sslValue == "1" || sslValue == "yes") { | ||
| @@ -433,6 +475,121 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 433 | log.Debug("Adding domain for Let's Encrypt verification", | 475 | log.Debug("Adding domain for Let's Encrypt verification", | ||
| 434 | "domain", cleanDomain) | 476 | "domain", cleanDomain) | ||
| 435 | 477 | ||||
| 478 | + | // Get container name for better logging | |||
| 479 | + | containerName, err := docker.GetContainerName(containerID) | |||
| 480 | + | if err != nil { | |||
| 481 | + | log.Warn("Unable to get container name", "containerID", containerID, "error", err) | |||
| 482 | + | } else { | |||
| 483 | + | containerName = strings.TrimPrefix(containerName, "/") | |||
| 484 | + | log.Debug("Container name for proxy route", "name", containerName) | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | // Check the database for any existing routes with this domain, regardless of container ID | |||
| 488 | + | // This handles the case where a container is recreated with the same name but a different ID | |||
| 489 | + | tx, err := a.GetDB().Begin() | |||
| 490 | + | if err != nil { | |||
| 491 | + | return fmt.Errorf("failed to begin transaction: %w", err) | |||
| 492 | + | } | |||
| 493 | + | defer tx.Rollback() | |||
| 494 | + | ||||
| 495 | + | var existingRouteID string | |||
| 496 | + | var existingContainerID string | |||
| 497 | + | err = tx.QueryRow(` | |||
| 498 | + | SELECT pr.id, pr.container_id | |||
| 499 | + | FROM proxy_route pr | |||
| 500 | + | JOIN domain d ON pr.domain_id = d.id | |||
| 501 | + | WHERE d.name = ?`, cleanDomain).Scan(&existingRouteID, &existingContainerID) | |||
| 502 | + | ||||
| 503 | + | if err == nil { | |||
| 504 | + | // Domain exists in database, check if this is a recreated container | |||
| 505 | + | log.Info("Found existing proxy route for domain", | |||
| 506 | + | "domain", cleanDomain, | |||
| 507 | + | "old_container_id", existingContainerID, | |||
| 508 | + | "new_container_id", containerID) | |||
| 509 | + | ||||
| 510 | + | // Verify container IP one last time before updating the route | |||
| 511 | + | containerNetworkInfo, err := docker.GetContainerInfo(containerID) | |||
| 512 | + | if err == nil && containerNetworkInfo.NetworkSettings != nil { | |||
| 513 | + | // Check the container's network settings for the configured network | |||
| 514 | + | networkName := a.Config.ContainerEngine.Network | |||
| 515 | + | if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists && | |||
| 516 | + | networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP { | |||
| 517 | + | log.Info("Updating container IP before database update", | |||
| 518 | + | "domain", cleanDomain, | |||
| 519 | + | "old_ip", containerIP, | |||
| 520 | + | "new_ip", networkSettings.IPAddress) | |||
| 521 | + | containerIP = networkSettings.IPAddress | |||
| 522 | + | } | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | // If the container IDs are different, this is likely a recreated container | |||
| 526 | + | // Update the route directly in the database | |||
| 527 | + | now := time.Now().Format(time.RFC3339) | |||
| 528 | + | _, err = tx.Exec(` | |||
| 529 | + | UPDATE proxy_route | |||
| 530 | + | SET container_id = ?, container_ip = ?, container_port = ?, updated_at = ? | |||
| 531 | + | WHERE id = ?`, | |||
| 532 | + | containerID, containerIP, containerPort, now, existingRouteID) | |||
| 533 | + | ||||
| 534 | + | if err != nil { | |||
| 535 | + | return fmt.Errorf("failed to update proxy route: %w", err) | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | log.Info("Updated proxy route for recreated container", | |||
| 539 | + | "domain", cleanDomain, | |||
| 540 | + | "old_container_id", existingContainerID, | |||
| 541 | + | "new_container_id", containerID, | |||
| 542 | + | "container_ip", containerIP, | |||
| 543 | + | "container_port", containerPort) | |||
| 544 | + | ||||
| 545 | + | // Commit transaction | |||
| 546 | + | if err := tx.Commit(); err != nil { | |||
| 547 | + | return fmt.Errorf("failed to commit transaction: %w", err) | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | // Verify the database update with a direct query | |||
| 551 | + | var updatedContainerIP string | |||
| 552 | + | err = a.GetDB().QueryRow(` | |||
| 553 | + | SELECT container_ip | |||
| 554 | + | FROM proxy_route | |||
| 555 | + | WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP) | |||
| 556 | + | ||||
| 557 | + | if err == nil && updatedContainerIP != containerIP { | |||
| 558 | + | log.Warn("Database verification failed: container IP mismatch", | |||
| 559 | + | "expected", containerIP, | |||
| 560 | + | "actual", updatedContainerIP) | |||
| 561 | + | } else { | |||
| 562 | + | log.Debug("Database verification passed: container IP updated correctly", | |||
| 563 | + | "container_ip", containerIP) | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | // Reload the routes | |||
| 567 | + | if err := p.Reload(); err != nil { | |||
| 568 | + | return fmt.Errorf("failed to reload routes: %w", err) | |||
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | // Final verification: check that the route was loaded with the correct IP | |||
| 572 | + | routes := p.GetRoutes() | |||
| 573 | + | if route, exists := routes[cleanDomain]; exists { | |||
| 574 | + | if route.ContainerIP != containerIP { | |||
| 575 | + | log.Warn("Route reload verification failed: container IP mismatch", | |||
| 576 | + | "domain", cleanDomain, | |||
| 577 | + | "expected", containerIP, | |||
| 578 | + | "actual", route.ContainerIP) | |||
| 579 | + | } else { | |||
| 580 | + | log.Debug("Route reload verification passed: container IP loaded correctly", | |||
| 581 | + | "domain", cleanDomain, | |||
| 582 | + | "container_ip", containerIP) | |||
| 583 | + | } | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | return nil | |||
| 587 | + | } else if err != sql.ErrNoRows { | |||
| 588 | + | // A database error other than "not found" | |||
| 589 | + | return fmt.Errorf("failed to query database for existing route: %w", err) | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | // Route doesn't exist or we had ErrNoRows, continue with normal AddRoute | |||
| 436 | // Add the route - this will also add to the domain database for Let's Encrypt | 593 | // Add the route - this will also add to the domain database for Let's Encrypt | ||
| 437 | if err := p.AddRoute(cleanDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil { | 594 | if err := p.AddRoute(cleanDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil { | ||
| 438 | return fmt.Errorf("failed to add proxy route: %w", err) | 595 | return fmt.Errorf("failed to add proxy route: %w", err) | ||
M internal/httpserve/handlers/endpoint_helpers.go +93 -23
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "net/http" | 9 | "net/http" | ||
| 10 | "os" | 10 | "os" | ||
| 11 | "regexp" | 11 | "regexp" | ||
| 12 | + | "strings" | |||
| 12 | "sync" | 13 | "sync" | ||
| 13 | "time" | 14 | "time" | ||
| 14 | 15 | ||||
| @@ -446,37 +447,106 @@ func getTransferData(transferID string) (*ChunkMetadata, map[int][]byte, error) | |||||
| 446 | 447 | ||||
| 447 | // GetContainerIP gets the IP address of a container within its Docker network | 448 | // GetContainerIP gets the IP address of a container within its Docker network | ||
| 448 | func GetContainerIP(a *server.App, containerID, containerName string) string { | 449 | func GetContainerIP(a *server.App, containerID, containerName string) string { | ||
| 449 | - | // Try to get container info | 450 | + | // Add retry logic with exponential backoff to ensure container network is ready |
| 450 | - | containerInfo, err := docker.GetContainerInfo(containerID) | 451 | + | maxRetries := 3 |
| 451 | - | if err != nil { | 452 | + | var containerIP string |
| 452 | - | log.Warn("Failed to get container info, using container name as IP", "error", err) | 453 | + | var lastErr error |
| 453 | - | return containerName // Use container name as fallback | 454 | + | |
| 454 | - | } | 455 | + | for retry := 0; retry < maxRetries; retry++ { |
| 456 | + | // If this is a retry, add an increasing delay | |||
| 457 | + | if retry > 0 { | |||
| 458 | + | sleepTime := time.Duration(500*retry) * time.Millisecond | |||
| 459 | + | log.Debug("Retrying to get container IP", | |||
| 460 | + | "attempt", retry+1, | |||
| 461 | + | "containerID", containerID, | |||
| 462 | + | "delay", sleepTime) | |||
| 463 | + | time.Sleep(sleepTime) | |||
| 464 | + | } | |||
| 455 | 465 | ||||
| 456 | - | // Extract IP address from container info | 466 | + | // Try to get container info |
| 457 | - | if containerInfo.NetworkSettings != nil { | 467 | + | containerInfo, err := docker.GetContainerInfo(containerID) |
| 458 | - | // Look for the network specified in the config | 468 | + | if err != nil { |
| 459 | - | networkName := a.Config.ContainerEngine.Network | 469 | + | lastErr = err |
| 460 | - | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | 470 | + | continue // Try again |
| 461 | - | log.Debug("Found container IP", | 471 | + | } |
| 462 | - | "containerName", containerName, | 472 | + | |
| 463 | - | "containerIP", networkSettings.IPAddress, | 473 | + | // Make sure we have the most up-to-date container info by refreshing if needed |
| 464 | - | "network", networkName) | 474 | + | // This is crucial for recreated containers |
| 465 | - | return networkSettings.IPAddress | 475 | + | if containerInfo.State == nil || !containerInfo.State.Running { |
| 476 | + | // Container might still be starting, wait a bit longer | |||
| 477 | + | time.Sleep(500 * time.Millisecond) | |||
| 478 | + | refreshedInfo, err := docker.GetContainerInfo(containerID) | |||
| 479 | + | if err == nil { | |||
| 480 | + | containerInfo = refreshedInfo | |||
| 481 | + | log.Debug("Refreshed container info for IP retrieval", | |||
| 482 | + | "containerName", containerName, | |||
| 483 | + | "containerID", containerID) | |||
| 484 | + | } else { | |||
| 485 | + | lastErr = err | |||
| 486 | + | continue // Try again | |||
| 487 | + | } | |||
| 466 | } | 488 | } | ||
| 467 | 489 | ||||
| 468 | - | // Look for any network if the specified one isn't found | 490 | + | // Extract IP address from container info |
| 469 | - | for networkName, networkSettings := range containerInfo.NetworkSettings.Networks { | 491 | + | if containerInfo.NetworkSettings != nil { |
| 470 | - | if networkSettings.IPAddress != "" { | 492 | + | // Look for the network specified in the config |
| 471 | - | log.Debug("Found container IP in alternative network", | 493 | + | networkName := a.Config.ContainerEngine.Network |
| 494 | + | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | |||
| 495 | + | containerIP = networkSettings.IPAddress | |||
| 496 | + | log.Debug("Found container IP", | |||
| 472 | "containerName", containerName, | 497 | "containerName", containerName, | ||
| 473 | - | "containerIP", networkSettings.IPAddress, | 498 | + | "containerID", containerID, |
| 499 | + | "containerIP", containerIP, | |||
| 474 | "network", networkName) | 500 | "network", networkName) | ||
| 475 | - | return networkSettings.IPAddress | 501 | + | return containerIP |
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | // Look for any network if the specified one isn't found | |||
| 505 | + | for networkName, networkSettings := range containerInfo.NetworkSettings.Networks { | |||
| 506 | + | if networkSettings.IPAddress != "" { | |||
| 507 | + | containerIP = networkSettings.IPAddress | |||
| 508 | + | log.Debug("Found container IP in alternative network", | |||
| 509 | + | "containerName", containerName, | |||
| 510 | + | "containerID", containerID, | |||
| 511 | + | "containerIP", containerIP, | |||
| 512 | + | "network", networkName) | |||
| 513 | + | return containerIP | |||
| 514 | + | } | |||
| 476 | } | 515 | } | ||
| 477 | } | 516 | } | ||
| 517 | + | ||||
| 518 | + | // If we've gotten this far, let's try inspecting the network directly | |||
| 519 | + | networkName := a.Config.ContainerEngine.Network | |||
| 520 | + | networkInfo, err := docker.GetNetworkInfo(networkName) | |||
| 521 | + | if err == nil { | |||
| 522 | + | // Search for this container in the network | |||
| 523 | + | for contID, endpoint := range networkInfo.Containers { | |||
| 524 | + | if contID == containerID && endpoint.IPv4Address != "" { | |||
| 525 | + | // Extract just the IP address from the CIDR format | |||
| 526 | + | ipCIDR := endpoint.IPv4Address | |||
| 527 | + | containerIP = strings.Split(ipCIDR, "/")[0] | |||
| 528 | + | log.Debug("Found container IP via network inspection", | |||
| 529 | + | "containerName", containerName, | |||
| 530 | + | "containerID", containerID, | |||
| 531 | + | "containerIP", containerIP, | |||
| 532 | + | "network", networkName) | |||
| 533 | + | return containerIP | |||
| 534 | + | } | |||
| 535 | + | } | |||
| 536 | + | } | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | // If we've tried multiple times and couldn't get an IP | |||
| 540 | + | if lastErr != nil { | |||
| 541 | + | log.Warn("Failed to get container info after multiple attempts", | |||
| 542 | + | "error", lastErr, | |||
| 543 | + | "containerID", containerID) | |||
| 478 | } | 544 | } | ||
| 479 | 545 | ||||
| 480 | - | // Fallback to container name if no IP found | 546 | + | log.Warn("Could not find container IP, using container name as fallback", |
| 547 | + | "containerName", containerName, | |||
| 548 | + | "containerID", containerID) | |||
| 549 | + | ||||
| 550 | + | // Use container name as a fallback | |||
| 481 | return containerName | 551 | return containerName | ||
| 482 | } | 552 | } | ||
M internal/proxy/blacklist.go +56 -0
| @@ -406,3 +406,59 @@ func (b *BlacklistConfig) RemoveRange(cidr string) error { | |||||
| 406 | // Force a reload to rebuild Networks and the cache | 406 | // Force a reload to rebuild Networks and the cache | ||
| 407 | return b.Load() | 407 | return b.Load() | ||
| 408 | } | 408 | } | ||
| 409 | + | ||||
| 410 | + | // logBlockedIP is a helper method to log blocked IPs with rate limiting | |||
| 411 | + | func (p *Proxy) logBlockedIP(clientIP, path, userAgent string) { | |||
| 412 | + | p.blockedIPCountMu.Lock() | |||
| 413 | + | defer p.blockedIPCountMu.Unlock() | |||
| 414 | + | ||||
| 415 | + | now := time.Now() | |||
| 416 | + | ||||
| 417 | + | // If this is the first block or it's been more than 5 minutes since the last summary, | |||
| 418 | + | // or if this is a new IP we haven't seen recently | |||
| 419 | + | if p.lastBlockedLog.IsZero() || | |||
| 420 | + | now.Sub(p.lastBlockedLog) > 5*time.Minute || | |||
| 421 | + | p.blockedIPCounter[clientIP] == 0 { | |||
| 422 | + | ||||
| 423 | + | // Log this block and update counters | |||
| 424 | + | log.Info("Blocked request from blacklisted IP", | |||
| 425 | + | "ip", clientIP, | |||
| 426 | + | "path", path, | |||
| 427 | + | "user_agent", userAgent) | |||
| 428 | + | ||||
| 429 | + | // If we're resetting due to time, clear the counters | |||
| 430 | + | if p.lastBlockedLog.IsZero() || now.Sub(p.lastBlockedLog) > 5*time.Minute { | |||
| 431 | + | p.blockedIPCounter = make(map[string]int) | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | p.blockedIPCounter[clientIP] = 1 | |||
| 435 | + | p.lastBlockedLog = now | |||
| 436 | + | return | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | // Increment counter for this IP | |||
| 440 | + | p.blockedIPCounter[clientIP]++ | |||
| 441 | + | ||||
| 442 | + | // Only log summaries every 5 minutes by default | |||
| 443 | + | if now.Sub(p.lastBlockedLog) >= 5*time.Minute { | |||
| 444 | + | // Log summary of blocked requests | |||
| 445 | + | totalBlocked := 0 | |||
| 446 | + | for _, count := range p.blockedIPCounter { | |||
| 447 | + | totalBlocked += count | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | log.Info("Blocked IP summary", | |||
| 451 | + | "unique_ips", len(p.blockedIPCounter), | |||
| 452 | + | "total_requests", totalBlocked, | |||
| 453 | + | "since", p.lastBlockedLog.Format(time.RFC3339)) | |||
| 454 | + | ||||
| 455 | + | // Reset counters | |||
| 456 | + | p.blockedIPCounter = make(map[string]int) | |||
| 457 | + | p.lastBlockedLog = now | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | // Debug logging only if needed | |||
| 461 | + | if false { | |||
| 462 | + | log.Debug("Request marked as blacklisted for logging skip", "ip", clientIP) | |||
| 463 | + | } | |||
| 464 | + | } | |||
A internal/proxy/certificates.go +681 -0
| @@ -0,0 +1,681 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/ecdsa" | |||
| 6 | + | "crypto/elliptic" | |||
| 7 | + | "crypto/rand" | |||
| 8 | + | "crypto/tls" | |||
| 9 | + | "crypto/x509" | |||
| 10 | + | "crypto/x509/pkix" | |||
| 11 | + | "encoding/pem" | |||
| 12 | + | "fmt" | |||
| 13 | + | "math/big" | |||
| 14 | + | "net" | |||
| 15 | + | "net/http" | |||
| 16 | + | "os" | |||
| 17 | + | "regexp" | |||
| 18 | + | "strings" | |||
| 19 | + | "time" | |||
| 20 | + | ||||
| 21 | + | "github.com/charmbracelet/log" | |||
| 22 | + | "golang.org/x/crypto/acme" | |||
| 23 | + | "golang.org/x/crypto/acme/autocert" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // setupCertManager configures the autocert manager for Let's Encrypt | |||
| 27 | + | func (p *Proxy) setupCertManager() { | |||
| 28 | + | // Create a cache directory if it doesn't exist | |||
| 29 | + | dir := p.config.CertDir | |||
| 30 | + | if dir == "" { | |||
| 31 | + | dir = p.app.GetConfig().General.StorageDir + "/certs" | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // Ensure directory exists | |||
| 35 | + | if err := os.MkdirAll(dir, 0755); err != nil { | |||
| 36 | + | log.Warn("Failed to create certificate cache directory", | |||
| 37 | + | "dir", dir, | |||
| 38 | + | "error", err) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Set up the certificate manager | |||
| 42 | + | certManager := &autocert.Manager{ | |||
| 43 | + | Prompt: autocert.AcceptTOS, | |||
| 44 | + | Cache: autocert.DirCache(dir), | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Configure the email if provided | |||
| 48 | + | if p.config.Email != "" { | |||
| 49 | + | certManager.Email = p.config.Email | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Configure the Let's Encrypt client | |||
| 53 | + | if p.config.LetsEncryptMode == "staging" { | |||
| 54 | + | certManager.Client = &acme.Client{ | |||
| 55 | + | DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory", | |||
| 56 | + | } | |||
| 57 | + | log.Debug("Using Let's Encrypt staging environment", | |||
| 58 | + | "url", "https://acme-staging-v02.api.letsencrypt.org/directory") | |||
| 59 | + | } else { | |||
| 60 | + | // Explicitly set production URL when not in staging mode | |||
| 61 | + | certManager.Client = &acme.Client{ | |||
| 62 | + | DirectoryURL: acme.LetsEncryptURL, // "https://acme-v02.api.letsencrypt.org/directory" | |||
| 63 | + | } | |||
| 64 | + | log.Debug("Using Let's Encrypt production environment", | |||
| 65 | + | "url", acme.LetsEncryptURL) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Set HostPolicy to allow the admin domain | |||
| 69 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 70 | + | rootDomain := p.app.GetConfig().Http.Domain | |||
| 71 | + | ||||
| 72 | + | // Use a more permissive hostpolicy that logs unknown domains rather than rejecting them | |||
| 73 | + | // This helps debug certificate acquisition issues | |||
| 74 | + | certManager.HostPolicy = func(_ context.Context, host string) error { | |||
| 75 | + | // Always allow the admin domain and root domain | |||
| 76 | + | if host == adminDomain || host == rootDomain { | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // For other domains, check if they are in our routes | |||
| 81 | + | p.mu.RLock() | |||
| 82 | + | defer p.mu.RUnlock() | |||
| 83 | + | ||||
| 84 | + | if _, ok := p.routes[host]; ok { | |||
| 85 | + | return nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Log the attempt but still allow it - this helps debug | |||
| 89 | + | // certificate acquisition issues during development | |||
| 90 | + | log.Warn("Unknown host in certificate request", | |||
| 91 | + | "host", host, | |||
| 92 | + | "adminDomain", adminDomain, | |||
| 93 | + | "allowed", "yes") | |||
| 94 | + | ||||
| 95 | + | // Allow unknown domains temporarily to help diagnose issues | |||
| 96 | + | // Change this to return an error in production for stricter security | |||
| 97 | + | return nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Generate a fallback self-signed certificate for the admin domain | |||
| 101 | + | var fallbackDomains []string | |||
| 102 | + | fallbackDomains = append(fallbackDomains, adminDomain) | |||
| 103 | + | ||||
| 104 | + | // Include root domain in fallback certificate if it's different from admin domain | |||
| 105 | + | if rootDomain != "" && rootDomain != adminDomain { | |||
| 106 | + | fallbackDomains = append(fallbackDomains, rootDomain) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | fallbackCert, err := generateFallbackCertificates(fallbackDomains) | |||
| 110 | + | if err != nil { | |||
| 111 | + | log.Warn("Failed to generate fallback certificate", "error", err) | |||
| 112 | + | } else { | |||
| 113 | + | log.Info("Generated fallback self-signed certificate for admin domain", | |||
| 114 | + | "domain", adminDomain, | |||
| 115 | + | "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05")) | |||
| 116 | + | // Store the fallback certificate | |||
| 117 | + | p.fallbackCert = fallbackCert | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | p.certManager = certManager | |||
| 121 | + | log.Debug("Certificate manager setup completed", | |||
| 122 | + | "directory", dir, | |||
| 123 | + | "mode", p.config.LetsEncryptMode) | |||
| 124 | + | ||||
| 125 | + | // Only request admin certificate during server startup, not during specific domain processing | |||
| 126 | + | if !p.processingSpecificDomain { | |||
| 127 | + | // Request the certificate for the admin domain | |||
| 128 | + | go p.requestAdminCertificate() | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // checkCertificateInCache checks if a valid certificate for the given domain exists in the cache | |||
| 133 | + | // Returns true if a valid certificate exists, false otherwise | |||
| 134 | + | func (p *Proxy) checkCertificateInCache(domain string) bool { | |||
| 135 | + | if p.certManager == nil || p.certManager.Cache == nil { | |||
| 136 | + | return false | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // The cache key format used by autocert is "cert-" + domain | |||
| 140 | + | cacheKey := "cert-" + domain | |||
| 141 | + | ||||
| 142 | + | // Create a context with a short timeout for the cache check | |||
| 143 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 144 | + | defer cancel() | |||
| 145 | + | ||||
| 146 | + | // Try to get the certificate from the cache | |||
| 147 | + | certData, err := p.certManager.Cache.Get(ctx, cacheKey) | |||
| 148 | + | if err != nil { | |||
| 149 | + | // ErrCacheMiss or other error means the certificate is not in the cache | |||
| 150 | + | log.Debug("Certificate not found in cache", | |||
| 151 | + | "domain", domain, | |||
| 152 | + | "error", err) | |||
| 153 | + | return false | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // Parse the certificate to check its validity | |||
| 157 | + | block, _ := pem.Decode(certData) | |||
| 158 | + | if block == nil || block.Type != "CERTIFICATE" { | |||
| 159 | + | log.Warn("Invalid certificate data in cache", | |||
| 160 | + | "domain", domain) | |||
| 161 | + | return false | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | cert, err := x509.ParseCertificate(block.Bytes) | |||
| 165 | + | if err != nil { | |||
| 166 | + | log.Warn("Failed to parse certificate from cache", | |||
| 167 | + | "domain", domain, | |||
| 168 | + | "error", err) | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // Check if the certificate is still valid | |||
| 173 | + | now := time.Now() | |||
| 174 | + | if now.After(cert.NotAfter) || now.Before(cert.NotBefore) { | |||
| 175 | + | log.Info("Certificate in cache has expired or is not yet valid", | |||
| 176 | + | "domain", domain, | |||
| 177 | + | "not_before", cert.NotBefore, | |||
| 178 | + | "not_after", cert.NotAfter) | |||
| 179 | + | return false | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // Check if the certificate is about to expire (within 30 days) | |||
| 183 | + | if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) { | |||
| 184 | + | log.Info("Certificate in cache is valid but will expire soon", | |||
| 185 | + | "domain", domain, | |||
| 186 | + | "expires_in", cert.NotAfter.Sub(now).Hours()/24, | |||
| 187 | + | "days") | |||
| 188 | + | // Return false to trigger renewal if it's about to expire | |||
| 189 | + | return false | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | log.Info("Valid certificate found in cache", | |||
| 193 | + | "domain", domain, | |||
| 194 | + | "expires_in", cert.NotAfter.Sub(now).Hours()/24, | |||
| 195 | + | "days") | |||
| 196 | + | return true | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | |||
| 200 | + | // for the Gordon admin interface | |||
| 201 | + | func (p *Proxy) requestAdminCertificate() { | |||
| 202 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 203 | + | if adminDomain == "" { | |||
| 204 | + | return | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Only proceed if HTTPS is enabled | |||
| 208 | + | if !p.app.GetConfig().Http.Https { | |||
| 209 | + | log.Debug("HTTPS is disabled, skipping admin certificate request") | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // Extract hostname from admin domain, resolving it to see if it's publicly accessible | |||
| 214 | + | host := adminDomain | |||
| 215 | + | ips, err := net.LookupIP(host) | |||
| 216 | + | if err != nil { | |||
| 217 | + | log.Error("Could not resolve admin domain, Let's Encrypt will likely fail", | |||
| 218 | + | "domain", adminDomain, | |||
| 219 | + | "error", err.Error(), | |||
| 220 | + | "solution", "Check DNS settings and ensure domain points to this server") | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | var ipStrings []string | |||
| 225 | + | for _, ip := range ips { | |||
| 226 | + | ipStrings = append(ipStrings, ip.String()) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | log.Info("Successfully resolved admin domain", | |||
| 230 | + | "domain", adminDomain, | |||
| 231 | + | "ips", ipStrings) | |||
| 232 | + | ||||
| 233 | + | // Check if we already have a valid certificate in the cache | |||
| 234 | + | if p.checkCertificateInCache(adminDomain) { | |||
| 235 | + | log.Info("Using existing certificate from cache", | |||
| 236 | + | "domain", adminDomain, | |||
| 237 | + | "action", "skipping Let's Encrypt request to avoid rate limits") | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // Check if environment is production | |||
| 242 | + | mode := "staging" | |||
| 243 | + | email := p.config.Email | |||
| 244 | + | ||||
| 245 | + | if !p.app.IsDevEnvironment() && email != "" { | |||
| 246 | + | mode = "production" | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // Log the certificate request intent | |||
| 250 | + | log.Info("Initiating Let's Encrypt certificate request for admin domain", | |||
| 251 | + | "domain", adminDomain, | |||
| 252 | + | "email", email, | |||
| 253 | + | "mode", mode) | |||
| 254 | + | ||||
| 255 | + | log.Info("⏳ Waiting for Let's Encrypt to validate domain ownership", | |||
| 256 | + | "domain", adminDomain, | |||
| 257 | + | "validation_method", "HTTP-01 challenge", | |||
| 258 | + | "requirements", "Domain must be publicly accessible on port 80", | |||
| 259 | + | "timeout", "1 minute") | |||
| 260 | + | ||||
| 261 | + | // Create a context with timeout for the initial certificate request | |||
| 262 | + | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) | |||
| 263 | + | defer cancel() | |||
| 264 | + | ||||
| 265 | + | // Try to obtain the certificate with timeout | |||
| 266 | + | var certResult *tls.Certificate | |||
| 267 | + | var certErr error | |||
| 268 | + | ||||
| 269 | + | // Use a channel to handle the timeout | |||
| 270 | + | certChan := make(chan struct { | |||
| 271 | + | cert *tls.Certificate | |||
| 272 | + | err error | |||
| 273 | + | }) | |||
| 274 | + | ||||
| 275 | + | go func() { | |||
| 276 | + | c, e := p.certManager.GetCertificate(&tls.ClientHelloInfo{ | |||
| 277 | + | ServerName: adminDomain, | |||
| 278 | + | }) | |||
| 279 | + | certChan <- struct { | |||
| 280 | + | cert *tls.Certificate | |||
| 281 | + | err error | |||
| 282 | + | }{c, e} | |||
| 283 | + | }() | |||
| 284 | + | ||||
| 285 | + | // Wait for either the certificate request to complete or the timeout | |||
| 286 | + | select { | |||
| 287 | + | case result := <-certChan: | |||
| 288 | + | certResult = result.cert | |||
| 289 | + | certErr = result.err | |||
| 290 | + | case <-ctx.Done(): | |||
| 291 | + | certErr = fmt.Errorf("certificate request timed out after 2 minutes: %w", ctx.Err()) | |||
| 292 | + | log.Error("Let's Encrypt certificate request timed out", | |||
| 293 | + | "domain", adminDomain, | |||
| 294 | + | "timeout", "2 minutes", | |||
| 295 | + | "error", certErr) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | if certErr != nil { | |||
| 299 | + | // Check for rate limit errors | |||
| 300 | + | if strings.Contains(strings.ToLower(certErr.Error()), "ratelimited") { | |||
| 301 | + | // Extract the retry-after time if available in the error message | |||
| 302 | + | retryAfterStr := "" | |||
| 303 | + | re := regexp.MustCompile(`retry after ([^:]+)`) | |||
| 304 | + | matches := re.FindStringSubmatch(certErr.Error()) | |||
| 305 | + | if len(matches) > 1 { | |||
| 306 | + | retryAfterStr = matches[1] | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | log.Error("Let's Encrypt rate limit reached", | |||
| 310 | + | "domain", adminDomain, | |||
| 311 | + | "error", certErr, | |||
| 312 | + | "retry_after", retryAfterStr, | |||
| 313 | + | "solution", "Wait until the rate limit expires, then restart the server") | |||
| 314 | + | ||||
| 315 | + | // Don't retry on rate limits | |||
| 316 | + | log.Info("Skipping certificate request retries due to rate limiting", | |||
| 317 | + | "domain", adminDomain) | |||
| 318 | + | } else { | |||
| 319 | + | log.Error("Failed to obtain Let's Encrypt certificate", | |||
| 320 | + | "domain", adminDomain, | |||
| 321 | + | "error", certErr) | |||
| 322 | + | ||||
| 323 | + | log.Info("⏳ Retrying certificate request with exponential backoff", | |||
| 324 | + | "domain", adminDomain, | |||
| 325 | + | "timeout", "2 minutes") | |||
| 326 | + | // Implement retry logic with backoff | |||
| 327 | + | go p.retryCertificateRequest(adminDomain, 3, 10*time.Second) | |||
| 328 | + | } | |||
| 329 | + | } else if certResult != nil { | |||
| 330 | + | log.Info("Successfully obtained Let's Encrypt certificate", | |||
| 331 | + | "domain", adminDomain) | |||
| 332 | + | } else { | |||
| 333 | + | log.Error("Unexpected error: received nil certificate but no error", | |||
| 334 | + | "domain", adminDomain, | |||
| 335 | + | "timeout", "2 minutes") | |||
| 336 | + | // Implement retry logic with backoff | |||
| 337 | + | go p.retryCertificateRequest(adminDomain, 3, 10*time.Second) | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // requestDomainCertificate proactively requests a Let's Encrypt certificate | |||
| 342 | + | // for a container domain | |||
| 343 | + | func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 344 | + | if domain == "" { | |||
| 345 | + | log.Warn("Empty domain provided to requestDomainCertificate") | |||
| 346 | + | return | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // Extract hostname from domain, resolving it to see if it's publicly accessible | |||
| 350 | + | ips, err := net.LookupIP(domain) | |||
| 351 | + | if err != nil { | |||
| 352 | + | log.Error("Could not resolve domain, Let's Encrypt will likely fail", | |||
| 353 | + | "domain", domain, | |||
| 354 | + | "error", err.Error(), | |||
| 355 | + | "solution", "Check DNS settings and ensure domain points to this server") | |||
| 356 | + | return | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | var ipStrings []string | |||
| 360 | + | for _, ip := range ips { | |||
| 361 | + | ipStrings = append(ipStrings, ip.String()) | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | log.Info("Successfully resolved domain for certificate request", | |||
| 365 | + | "domain", domain, | |||
| 366 | + | "ips", ipStrings) | |||
| 367 | + | ||||
| 368 | + | // Check if we already have a valid certificate in the cache | |||
| 369 | + | if p.checkCertificateInCache(domain) { | |||
| 370 | + | log.Info("Using existing certificate from cache", | |||
| 371 | + | "domain", domain, | |||
| 372 | + | "action", "skipping Let's Encrypt request to avoid rate limits") | |||
| 373 | + | return | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // Check if environment is production | |||
| 377 | + | mode := "staging" | |||
| 378 | + | email := p.config.Email | |||
| 379 | + | ||||
| 380 | + | if !p.app.IsDevEnvironment() && email != "" { | |||
| 381 | + | mode = "production" | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // Log the certificate request intent | |||
| 385 | + | log.Info("Initiating Let's Encrypt certificate request for container domain", | |||
| 386 | + | "domain", domain, | |||
| 387 | + | "email", email, | |||
| 388 | + | "mode", mode) | |||
| 389 | + | ||||
| 390 | + | log.Info("⏳ Waiting for Let's Encrypt to validate domain ownership", | |||
| 391 | + | "domain", domain, | |||
| 392 | + | "validation_method", "HTTP-01 challenge", | |||
| 393 | + | "requirements", "Domain must be publicly accessible on port 80", | |||
| 394 | + | "timeout", "1 minute") | |||
| 395 | + | ||||
| 396 | + | // Create a context with timeout for the initial certificate request | |||
| 397 | + | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) | |||
| 398 | + | defer cancel() | |||
| 399 | + | ||||
| 400 | + | // Try to obtain the certificate with timeout | |||
| 401 | + | var certResult *tls.Certificate | |||
| 402 | + | var certErr error | |||
| 403 | + | ||||
| 404 | + | // Use a channel to handle the timeout | |||
| 405 | + | certChan := make(chan struct { | |||
| 406 | + | cert *tls.Certificate | |||
| 407 | + | err error | |||
| 408 | + | }) | |||
| 409 | + | ||||
| 410 | + | go func() { | |||
| 411 | + | c, e := p.certManager.GetCertificate(&tls.ClientHelloInfo{ | |||
| 412 | + | ServerName: domain, | |||
| 413 | + | }) | |||
| 414 | + | certChan <- struct { | |||
| 415 | + | cert *tls.Certificate | |||
| 416 | + | err error | |||
| 417 | + | }{c, e} | |||
| 418 | + | }() | |||
| 419 | + | ||||
| 420 | + | // Wait for either the certificate request to complete or the timeout | |||
| 421 | + | select { | |||
| 422 | + | case result := <-certChan: | |||
| 423 | + | certResult = result.cert | |||
| 424 | + | certErr = result.err | |||
| 425 | + | case <-ctx.Done(): | |||
| 426 | + | certErr = fmt.Errorf("certificate request timed out after 1 minute: %w", ctx.Err()) | |||
| 427 | + | log.Error("Let's Encrypt certificate request timed out", | |||
| 428 | + | "domain", domain, | |||
| 429 | + | "timeout", "1 minute", | |||
| 430 | + | "error", certErr) | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | if certErr != nil { | |||
| 434 | + | // Check for rate limit errors | |||
| 435 | + | if strings.Contains(strings.ToLower(certErr.Error()), "ratelimited") { | |||
| 436 | + | // Extract the retry-after time if available in the error message | |||
| 437 | + | retryAfterStr := "" | |||
| 438 | + | re := regexp.MustCompile(`retry after ([^:]+)`) | |||
| 439 | + | matches := re.FindStringSubmatch(certErr.Error()) | |||
| 440 | + | if len(matches) > 1 { | |||
| 441 | + | retryAfterStr = matches[1] | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | log.Error("Let's Encrypt rate limit reached", | |||
| 445 | + | "domain", domain, | |||
| 446 | + | "error", certErr, | |||
| 447 | + | "retry_after", retryAfterStr, | |||
| 448 | + | "solution", "Wait until the rate limit expires, then restart the server") | |||
| 449 | + | ||||
| 450 | + | // Don't retry on rate limits | |||
| 451 | + | log.Info("Skipping certificate request retries due to rate limiting", | |||
| 452 | + | "domain", domain) | |||
| 453 | + | } else { | |||
| 454 | + | log.Error("Failed to obtain Let's Encrypt certificate", | |||
| 455 | + | "domain", domain, | |||
| 456 | + | "error", certErr) | |||
| 457 | + | ||||
| 458 | + | log.Info("⏳ Retrying certificate request with exponential backoff", | |||
| 459 | + | "domain", domain, | |||
| 460 | + | "timeout", "2 minutes") | |||
| 461 | + | // Implement retry logic with backoff | |||
| 462 | + | go p.retryCertificateRequest(domain, 3, 10*time.Second) | |||
| 463 | + | } | |||
| 464 | + | } else if certResult != nil { | |||
| 465 | + | log.Info("Successfully obtained Let's Encrypt certificate", | |||
| 466 | + | "domain", domain) | |||
| 467 | + | } else { | |||
| 468 | + | log.Error("Unexpected error: received nil certificate but no error", | |||
| 469 | + | "domain", domain, | |||
| 470 | + | "timeout", "1 minute") | |||
| 471 | + | // Implement retry logic with backoff | |||
| 472 | + | go p.retryCertificateRequest(domain, 3, 10*time.Second) | |||
| 473 | + | } | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | // retryCertificateRequest attempts to request a certificate with exponential backoff | |||
| 477 | + | func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) { | |||
| 478 | + | backoff := initialBackoff | |||
| 479 | + | ||||
| 480 | + | for attempt := 1; attempt <= maxRetries; attempt++ { | |||
| 481 | + | // Wait for backoff period | |||
| 482 | + | log.Info("Retrying Let's Encrypt certificate request", | |||
| 483 | + | "domain", domain, | |||
| 484 | + | "attempt", attempt, | |||
| 485 | + | "max_retries", maxRetries, | |||
| 486 | + | "wait_time", backoff) | |||
| 487 | + | time.Sleep(backoff) | |||
| 488 | + | ||||
| 489 | + | // Before each retry, check if the HTTP challenge endpoint is accessible | |||
| 490 | + | if attempt > 1 { | |||
| 491 | + | // Try connecting to our own HTTP server on port 80 | |||
| 492 | + | // to verify its availability for Let's Encrypt validation | |||
| 493 | + | client := &http.Client{ | |||
| 494 | + | Timeout: 5 * time.Second, | |||
| 495 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 496 | + | // Don't follow redirects for this test | |||
| 497 | + | return http.ErrUseLastResponse | |||
| 498 | + | }, | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | // Test the ACME challenge path with a fake token | |||
| 502 | + | testURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test-token", domain) | |||
| 503 | + | resp, err := client.Get(testURL) | |||
| 504 | + | ||||
| 505 | + | if err != nil { | |||
| 506 | + | log.Warn("HTTP challenge endpoint might not be accessible", | |||
| 507 | + | "domain", domain, | |||
| 508 | + | "url", testURL, | |||
| 509 | + | "error", err, | |||
| 510 | + | "solution", "Ensure port 80 is accessible and not blocked by firewall") | |||
| 511 | + | } else { | |||
| 512 | + | resp.Body.Close() | |||
| 513 | + | log.Info("HTTP challenge endpoint is accessible", | |||
| 514 | + | "domain", domain, | |||
| 515 | + | "url", testURL, | |||
| 516 | + | "status", resp.StatusCode) | |||
| 517 | + | } | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | // Create context with timeout for this attempt | |||
| 521 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) | |||
| 522 | + | ||||
| 523 | + | // Request certificate | |||
| 524 | + | err := func(ctx context.Context) error { | |||
| 525 | + | defer cancel() | |||
| 526 | + | cert, certErr := p.certManager.GetCertificate(&tls.ClientHelloInfo{ | |||
| 527 | + | ServerName: domain, | |||
| 528 | + | }) | |||
| 529 | + | if cert == nil && certErr == nil { | |||
| 530 | + | return fmt.Errorf("certificate is nil but no error returned") | |||
| 531 | + | } | |||
| 532 | + | return certErr | |||
| 533 | + | }(ctx) | |||
| 534 | + | ||||
| 535 | + | // Check result | |||
| 536 | + | if err == nil { | |||
| 537 | + | log.Info("Successfully obtained Let's Encrypt certificate on retry", | |||
| 538 | + | "domain", domain, | |||
| 539 | + | "attempt", attempt) | |||
| 540 | + | return | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | log.Error("Let's Encrypt certificate request retry failed", | |||
| 544 | + | "domain", domain, | |||
| 545 | + | "attempt", attempt, | |||
| 546 | + | "error", err) | |||
| 547 | + | ||||
| 548 | + | // Provide more detailed diagnostics based on the error | |||
| 549 | + | if strings.Contains(strings.ToLower(err.Error()), "connection refused") || | |||
| 550 | + | strings.Contains(strings.ToLower(err.Error()), "timeout") { | |||
| 551 | + | log.Error("Let's Encrypt connection failed - this typically indicates:", | |||
| 552 | + | "issue_1", "Port 80 is not accessible from the internet", | |||
| 553 | + | "issue_2", "Firewall is blocking inbound connections", | |||
| 554 | + | "issue_3", "DNS records not properly propagated", | |||
| 555 | + | "solution", "Check firewall settings and DNS configuration") | |||
| 556 | + | } else if strings.Contains(strings.ToLower(err.Error()), "unauthorized") { | |||
| 557 | + | log.Error("Let's Encrypt authorization failed - this typically indicates:", | |||
| 558 | + | "issue", "Domain ownership validation failed", | |||
| 559 | + | "solution", "Ensure the server is publicly accessible on port 80") | |||
| 560 | + | } else if strings.Contains(strings.ToLower(err.Error()), "ratelimited") { | |||
| 561 | + | // Extract the retry-after date if present | |||
| 562 | + | retryAfterStr := "see error message for details" | |||
| 563 | + | if strings.Contains(err.Error(), "retry after") { | |||
| 564 | + | parts := strings.Split(err.Error(), "retry after") | |||
| 565 | + | if len(parts) > 1 { | |||
| 566 | + | retryAfterStr = strings.TrimSpace(parts[1]) | |||
| 567 | + | if idx := strings.Index(retryAfterStr, ":"); idx > 0 { | |||
| 568 | + | retryAfterStr = retryAfterStr[:idx] | |||
| 569 | + | } | |||
| 570 | + | } | |||
| 571 | + | } | |||
| 572 | + | ||||
| 573 | + | log.Error("Let's Encrypt rate limit reached - cannot issue more certificates for this domain yet", | |||
| 574 | + | "domain", domain, | |||
| 575 | + | "retry_after", retryAfterStr, | |||
| 576 | + | "solution", "Wait until the rate limit expires, then restart the server") | |||
| 577 | + | ||||
| 578 | + | // No point in retrying on rate limit errors | |||
| 579 | + | return | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | // Increase backoff for next attempt (exponential backoff) | |||
| 583 | + | backoff *= 2 | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | log.Error("All Let's Encrypt certificate request retries failed", | |||
| 587 | + | "domain", domain, | |||
| 588 | + | "max_retries", maxRetries, | |||
| 589 | + | "fallback", "Using self-signed certificate") | |||
| 590 | + | ||||
| 591 | + | // At this point, all retries have failed, so we'll rely on the fallback self-signed certificate | |||
| 592 | + | // But let's log this prominently for debugging | |||
| 593 | + | log.Error("⚠️ HTTPS is using a self-signed certificate which browsers will warn about", | |||
| 594 | + | "domain", domain, | |||
| 595 | + | "reason", "Let's Encrypt certificate issuance failed", | |||
| 596 | + | "solution", "Check network settings and Let's Encrypt status") | |||
| 597 | + | ||||
| 598 | + | // Suggest checking the common issues | |||
| 599 | + | log.Error("Common Let's Encrypt issues to check:", | |||
| 600 | + | "check_1", "Ensure ports 80 and 443 are open on your firewall", | |||
| 601 | + | "check_2", "Verify DNS records point to your server IP", | |||
| 602 | + | "check_3", "Make sure no other services are running on ports 80/443", | |||
| 603 | + | "check_4", "Check if Let's Encrypt service is having issues (https://letsencrypt.status.io/)") | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | // generateFallbackCertificates generates a self-signed fallback certificate | |||
| 607 | + | func generateFallbackCertificates(domains []string) (*tls.Certificate, error) { | |||
| 608 | + | if len(domains) == 0 { | |||
| 609 | + | return nil, fmt.Errorf("no domains provided for fallback certificate") | |||
| 610 | + | } | |||
| 611 | + | ||||
| 612 | + | // Create a new private key | |||
| 613 | + | privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 614 | + | if err != nil { | |||
| 615 | + | return nil, fmt.Errorf("failed to generate private key: %w", err) | |||
| 616 | + | } | |||
| 617 | + | ||||
| 618 | + | // Create a template for the certificate | |||
| 619 | + | notBefore := time.Now() | |||
| 620 | + | notAfter := notBefore.Add(24 * time.Hour) // Valid for 24 hours | |||
| 621 | + | ||||
| 622 | + | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) | |||
| 623 | + | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) | |||
| 624 | + | if err != nil { | |||
| 625 | + | return nil, fmt.Errorf("failed to generate serial number: %w", err) | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | template := x509.Certificate{ | |||
| 629 | + | SerialNumber: serialNumber, | |||
| 630 | + | Subject: pkix.Name{ | |||
| 631 | + | Organization: []string{"Gordon Self-Signed Certificate"}, | |||
| 632 | + | CommonName: domains[0], // Use the first domain as the common name | |||
| 633 | + | }, | |||
| 634 | + | NotBefore: notBefore, | |||
| 635 | + | NotAfter: notAfter, | |||
| 636 | + | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, | |||
| 637 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |||
| 638 | + | BasicConstraintsValid: true, | |||
| 639 | + | // Add all domains as SANs | |||
| 640 | + | DNSNames: domains, | |||
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | // Add IP addresses to the certificate if domains contain IP addresses | |||
| 644 | + | for _, domain := range domains { | |||
| 645 | + | ip := net.ParseIP(domain) | |||
| 646 | + | if ip != nil { | |||
| 647 | + | template.IPAddresses = append(template.IPAddresses, ip) | |||
| 648 | + | } | |||
| 649 | + | } | |||
| 650 | + | ||||
| 651 | + | // Self-sign the certificate | |||
| 652 | + | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) | |||
| 653 | + | if err != nil { | |||
| 654 | + | return nil, fmt.Errorf("failed to create certificate: %w", err) | |||
| 655 | + | } | |||
| 656 | + | ||||
| 657 | + | // Encode certificate to PEM | |||
| 658 | + | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) | |||
| 659 | + | if certPEM == nil { | |||
| 660 | + | return nil, fmt.Errorf("failed to encode certificate to PEM") | |||
| 661 | + | } | |||
| 662 | + | ||||
| 663 | + | // Encode private key to PEM | |||
| 664 | + | privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) | |||
| 665 | + | if err != nil { | |||
| 666 | + | return nil, fmt.Errorf("failed to marshal private key: %w", err) | |||
| 667 | + | } | |||
| 668 | + | ||||
| 669 | + | privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyBytes}) | |||
| 670 | + | if privateKeyPEM == nil { | |||
| 671 | + | return nil, fmt.Errorf("failed to encode private key to PEM") | |||
| 672 | + | } | |||
| 673 | + | ||||
| 674 | + | // Load the certificate | |||
| 675 | + | cert, err := tls.X509KeyPair(certPEM, privateKeyPEM) | |||
| 676 | + | if err != nil { | |||
| 677 | + | return nil, fmt.Errorf("failed to load certificate: %w", err) | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | return &cert, nil | |||
| 681 | + | } | |||
A internal/proxy/containers.go +157 -0
| @@ -0,0 +1,157 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/pkg/docker" | |||
| 9 | + | "github.com/charmbracelet/log" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // scanContainersAndCheckCertificates scans all containers from the Gordon network | |||
| 13 | + | // and checks if we have certificates for their domains and subdomains | |||
| 14 | + | func (p *Proxy) scanContainersAndCheckCertificates() error { | |||
| 15 | + | // Get the network name from app config | |||
| 16 | + | networkName := p.app.GetConfig().ContainerEngine.Network | |||
| 17 | + | ||||
| 18 | + | if networkName == "" { | |||
| 19 | + | return fmt.Errorf("container network name is not configured") | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | log.Info("Scanning containers in network for certificate checks", | |||
| 23 | + | "network", networkName) | |||
| 24 | + | ||||
| 25 | + | // Try to get network info using the docker client | |||
| 26 | + | networkInfo, err := docker.GetNetworkInfo(networkName) | |||
| 27 | + | if err != nil { | |||
| 28 | + | return fmt.Errorf("failed to get network info: %w", err) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Skip admin cert check if environment variable is set | |||
| 32 | + | skipAdminCertCheck := os.Getenv("GORDON_SKIP_ADMIN_CERT_CHECK") == "true" | |||
| 33 | + | if skipAdminCertCheck { | |||
| 34 | + | log.Info("Skipping admin certificate check as GORDON_SKIP_ADMIN_CERT_CHECK=true") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Get all containers in the network | |||
| 38 | + | for containerID, containerEndpoint := range networkInfo.Containers { | |||
| 39 | + | containerName := strings.TrimPrefix(containerEndpoint.Name, "/") | |||
| 40 | + | ||||
| 41 | + | // Get container info to check for relevant domains | |||
| 42 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |||
| 43 | + | if err != nil { | |||
| 44 | + | log.Warn("Failed to get container info", | |||
| 45 | + | "containerID", containerID, | |||
| 46 | + | "containerName", containerName, | |||
| 47 | + | "error", err) | |||
| 48 | + | continue | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Extract domains from container | |||
| 52 | + | // First check if there are labels with domain information | |||
| 53 | + | domains := []string{} | |||
| 54 | + | ||||
| 55 | + | // Check common domain-related labels | |||
| 56 | + | if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" { | |||
| 57 | + | domains = append(domains, domainLabel) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Check environment variables for domain information | |||
| 61 | + | for _, env := range containerInfo.Config.Env { | |||
| 62 | + | if strings.HasPrefix(env, "DOMAIN=") || strings.HasPrefix(env, "VIRTUAL_HOST=") { | |||
| 63 | + | parts := strings.SplitN(env, "=", 2) | |||
| 64 | + | if len(parts) == 2 && parts[1] != "" { | |||
| 65 | + | // Multiple domains might be comma-separated | |||
| 66 | + | for _, domain := range strings.Split(parts[1], ",") { | |||
| 67 | + | domains = append(domains, strings.TrimSpace(domain)) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // If no domains were found, use the container name | |||
| 74 | + | if len(domains) == 0 { | |||
| 75 | + | // Check if there is a proxy route for this container | |||
| 76 | + | var found bool | |||
| 77 | + | for domain, routeInfo := range p.routes { | |||
| 78 | + | if routeInfo.ContainerID == containerID { | |||
| 79 | + | domains = append(domains, domain) | |||
| 80 | + | found = true | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // If no route was found, use container name as fallback | |||
| 85 | + | if !found { | |||
| 86 | + | // Use container name as subdomain of the default domain | |||
| 87 | + | if defaultDomain := p.app.GetConfig().Http.Domain; defaultDomain != "" { | |||
| 88 | + | domains = append(domains, containerName+"."+defaultDomain) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Check certificates for each domain | |||
| 94 | + | for _, domain := range domains { | |||
| 95 | + | // Check certificate for the full domain | |||
| 96 | + | hasCert := p.checkCertificateInCache(domain) | |||
| 97 | + | ||||
| 98 | + | if hasCert { | |||
| 99 | + | log.Debug("Certificate exists for domain", | |||
| 100 | + | "domain", domain, | |||
| 101 | + | "container", containerName) | |||
| 102 | + | } else { | |||
| 103 | + | log.Info("Certificate not found in cache, requesting", | |||
| 104 | + | "domain", domain, | |||
| 105 | + | "container", containerName) | |||
| 106 | + | ||||
| 107 | + | // Request certificate (non-blocking) | |||
| 108 | + | go func(domain string) { | |||
| 109 | + | // Set flag to indicate we're processing a specific domain | |||
| 110 | + | p.processingSpecificDomain = true | |||
| 111 | + | // Try to request certificate for domain | |||
| 112 | + | p.requestDomainCertificate(domain) | |||
| 113 | + | // Reset flag when done | |||
| 114 | + | p.processingSpecificDomain = false | |||
| 115 | + | }(domain) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // If this is a subdomain, check the parent domain too | |||
| 119 | + | parts := strings.Split(domain, ".") | |||
| 120 | + | if len(parts) >= 3 { | |||
| 121 | + | // This is a subdomain (e.g., sub.domain.com) | |||
| 122 | + | parentDomain := strings.Join(parts[1:], ".") | |||
| 123 | + | ||||
| 124 | + | // Only check parent domain if it's not an ordinary TLD | |||
| 125 | + | if len(parts) > 2 && !isCommonTLD(parts[len(parts)-1]) { | |||
| 126 | + | hasCert = p.checkCertificateInCache(parentDomain) | |||
| 127 | + | if !hasCert { | |||
| 128 | + | log.Info("Certificate not found for parent domain", | |||
| 129 | + | "domain", parentDomain, | |||
| 130 | + | "container", containerName) | |||
| 131 | + | ||||
| 132 | + | // Request certificate for parent domain also | |||
| 133 | + | go func(domain string) { | |||
| 134 | + | // Set flag to indicate we're processing a specific domain | |||
| 135 | + | p.processingSpecificDomain = true | |||
| 136 | + | p.requestDomainCertificate(domain) | |||
| 137 | + | // Reset flag when done | |||
| 138 | + | p.processingSpecificDomain = false | |||
| 139 | + | }(parentDomain) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | return nil | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // isCommonTLD checks if a string is a common top-level domain | |||
| 150 | + | func isCommonTLD(tld string) bool { | |||
| 151 | + | commonTLDs := map[string]bool{ | |||
| 152 | + | "com": true, "org": true, "net": true, "io": true, "co": true, | |||
| 153 | + | "ai": true, "app": true, "dev": true, "edu": true, "gov": true, | |||
| 154 | + | "info": true, "name": true, "biz": true, "me": true, | |||
| 155 | + | } | |||
| 156 | + | return commonTLDs[tld] | |||
| 157 | + | } | |||
A internal/proxy/core.go +97 -0
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "crypto/tls" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/common" | |||
| 10 | + | "github.com/bnema/gordon/internal/interfaces" | |||
| 11 | + | "github.com/charmbracelet/log" | |||
| 12 | + | "github.com/labstack/echo/v4" | |||
| 13 | + | "golang.org/x/crypto/acme/autocert" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // - core.go - Core proxy structure and constructor | |||
| 17 | + | // - certificates.go - Certificate management functions | |||
| 18 | + | // - routes.go - Route management functions | |||
| 19 | + | // - middleware.go - HTTP middleware functions | |||
| 20 | + | // - blacklist.go - IP blacklisting functionality | |||
| 21 | + | // - server.go - Server startup and shutdown | |||
| 22 | + | // - utils.go - Utility functions | |||
| 23 | + | ||||
| 24 | + | // Proxy represents the reverse proxy server | |||
| 25 | + | type Proxy struct { | |||
| 26 | + | config common.ReverseProxyConfig | |||
| 27 | + | app interfaces.AppInterface | |||
| 28 | + | httpsServer *echo.Echo | |||
| 29 | + | httpServer *echo.Echo | |||
| 30 | + | routes map[string]*ProxyRouteInfo | |||
| 31 | + | mu sync.RWMutex | |||
| 32 | + | certManager *autocert.Manager | |||
| 33 | + | fallbackCert *tls.Certificate // Fallback self-signed certificate | |||
| 34 | + | serverStarted bool | |||
| 35 | + | blacklist *BlacklistConfig | |||
| 36 | + | ||||
| 37 | + | // Fields for throttling blacklist logs | |||
| 38 | + | lastBlockedLog time.Time | |||
| 39 | + | blockedIPCounter map[string]int | |||
| 40 | + | blockedIPCountMu sync.Mutex | |||
| 41 | + | ||||
| 42 | + | // Firewall-like memory of recently blocked IPs for quick rejection | |||
| 43 | + | recentlyBlocked map[string]time.Time | |||
| 44 | + | recentlyBlockedMu sync.RWMutex | |||
| 45 | + | ||||
| 46 | + | // Flag to track specific domain certificate operations | |||
| 47 | + | processingSpecificDomain bool | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // NewProxy creates a new instance of the reverse proxy | |||
| 51 | + | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||
| 52 | + | log.Debug("Initializing reverse proxy") | |||
| 53 | + | ||||
| 54 | + | // Set up the echo server for HTTPS traffic | |||
| 55 | + | httpsServer := echo.New() | |||
| 56 | + | httpsServer.HideBanner = true | |||
| 57 | + | ||||
| 58 | + | // Set up the echo server for HTTP traffic (redirects to HTTPS) | |||
| 59 | + | httpServer := echo.New() | |||
| 60 | + | httpServer.HideBanner = true | |||
| 61 | + | ||||
| 62 | + | // Set up the config | |||
| 63 | + | config := app.GetConfig().ReverseProxy | |||
| 64 | + | ||||
| 65 | + | // Initialize the blacklist | |||
| 66 | + | blacklistPath := app.GetConfig().General.StorageDir + "/blacklist.json" | |||
| 67 | + | blacklist, err := NewBlacklist(blacklistPath) | |||
| 68 | + | if err != nil { | |||
| 69 | + | log.Warn("Failed to initialize blacklist, continuing without it", "error", err) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Initialize routes map | |||
| 73 | + | routes := make(map[string]*ProxyRouteInfo) | |||
| 74 | + | ||||
| 75 | + | // Create the proxy | |||
| 76 | + | p := &Proxy{ | |||
| 77 | + | config: config, | |||
| 78 | + | app: app, | |||
| 79 | + | httpsServer: httpsServer, | |||
| 80 | + | httpServer: httpServer, | |||
| 81 | + | routes: routes, | |||
| 82 | + | serverStarted: false, | |||
| 83 | + | blacklist: blacklist, | |||
| 84 | + | blockedIPCounter: make(map[string]int), | |||
| 85 | + | lastBlockedLog: time.Time{}, // Zero time | |||
| 86 | + | recentlyBlocked: make(map[string]time.Time), | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Now add the middleware with the proxy reference | |||
| 90 | + | p.setupMiddleware() | |||
| 91 | + | ||||
| 92 | + | // Set up the certificate manager | |||
| 93 | + | p.setupCertManager() | |||
| 94 | + | ||||
| 95 | + | log.Debug("Reverse proxy initialized") | |||
| 96 | + | return p, nil | |||
| 97 | + | } | |||
A internal/proxy/middleware.go +123 -0
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/http" | |||
| 6 | + | "strings" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/charmbracelet/log" | |||
| 10 | + | "github.com/labstack/echo/v4" | |||
| 11 | + | "github.com/labstack/echo/v4/middleware" | |||
| 12 | + | "golang.org/x/time/rate" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Create blacklistedIPs set to track IPs for middleware skipping | |||
| 16 | + | type requestContext struct { | |||
| 17 | + | blacklisted bool | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // setupMiddleware configures middleware for both HTTP and HTTPS servers | |||
| 21 | + | func (p *Proxy) setupMiddleware() { | |||
| 22 | + | // Create blacklist middleware for HTTPS | |||
| 23 | + | p.httpsServer.Use(p.createBlacklistMiddleware()) | |||
| 24 | + | ||||
| 25 | + | // Add rate limiter middleware to prevent spam attacks | |||
| 26 | + | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |||
| 27 | + | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | |||
| 28 | + | middleware.RateLimiterMemoryStoreConfig{ | |||
| 29 | + | Rate: rate.Limit(10), // 10 requests per second | |||
| 30 | + | Burst: 30, // Burst of 30 requests | |||
| 31 | + | ExpiresIn: 3 * time.Minute, // Store expiration | |||
| 32 | + | }, | |||
| 33 | + | ), | |||
| 34 | + | DenyHandler: func(context echo.Context, identifier string, err error) error { | |||
| 35 | + | log.Warn("Rate limit exceeded", | |||
| 36 | + | "ip", identifier, | |||
| 37 | + | "path", context.Request().URL.Path, | |||
| 38 | + | "method", context.Request().Method) | |||
| 39 | + | return context.String(http.StatusTooManyRequests, "Too many requests") | |||
| 40 | + | }, | |||
| 41 | + | })) | |||
| 42 | + | ||||
| 43 | + | // Add default Echo logger middleware only if enabled in config | |||
| 44 | + | if p.config.EnableLogs { | |||
| 45 | + | p.httpsServer.Use(middleware.Logger()) | |||
| 46 | + | log.Debug("HTTP request logging enabled for HTTPS server") | |||
| 47 | + | } else { | |||
| 48 | + | log.Debug("HTTP request logging disabled for HTTPS server") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Create blacklist middleware for HTTP | |||
| 52 | + | p.httpServer.Use(p.createBlacklistMiddleware()) | |||
| 53 | + | ||||
| 54 | + | // Add rate limiter middleware to prevent spam attacks for HTTP server | |||
| 55 | + | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |||
| 56 | + | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | |||
| 57 | + | middleware.RateLimiterMemoryStoreConfig{ | |||
| 58 | + | Rate: rate.Limit(10), // 10 requests per second | |||
| 59 | + | Burst: 30, // Burst of 30 requests | |||
| 60 | + | ExpiresIn: 3 * time.Minute, // Store expiration | |||
| 61 | + | }, | |||
| 62 | + | ), | |||
| 63 | + | DenyHandler: func(context echo.Context, identifier string, err error) error { | |||
| 64 | + | log.Warn("Rate limit exceeded", | |||
| 65 | + | "ip", identifier, | |||
| 66 | + | "path", context.Request().URL.Path, | |||
| 67 | + | "method", context.Request().Method) | |||
| 68 | + | return context.String(http.StatusTooManyRequests, "Too many requests") | |||
| 69 | + | }, | |||
| 70 | + | })) | |||
| 71 | + | ||||
| 72 | + | // Add default Echo logger middleware for HTTP server only if enabled in config | |||
| 73 | + | if p.config.EnableLogs { | |||
| 74 | + | p.httpServer.Use(middleware.Logger()) | |||
| 75 | + | log.Debug("HTTP request logging enabled for HTTP server") | |||
| 76 | + | } else { | |||
| 77 | + | log.Debug("HTTP request logging disabled for HTTP server") | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // createBlacklistMiddleware returns a middleware function that checks IPs against the blacklist | |||
| 82 | + | func (p *Proxy) createBlacklistMiddleware() echo.MiddlewareFunc { | |||
| 83 | + | return func(next echo.HandlerFunc) echo.HandlerFunc { | |||
| 84 | + | return func(c echo.Context) error { | |||
| 85 | + | // Get client IP | |||
| 86 | + | clientIP := c.RealIP() | |||
| 87 | + | ||||
| 88 | + | // Get host and strip port if present | |||
| 89 | + | host := c.Request().Host | |||
| 90 | + | if strings.Contains(host, ":") { | |||
| 91 | + | host = strings.Split(host, ":")[0] | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // Check if the host is an IP address | |||
| 95 | + | if net.ParseIP(host) != nil { | |||
| 96 | + | // This is an IP address being used as a hostname - silently reject | |||
| 97 | + | // This prevents log spam without adding IPs to the blacklist | |||
| 98 | + | // Set context for other middleware | |||
| 99 | + | c.Set("reqContext", &requestContext{blacklisted: true}) | |||
| 100 | + | return c.String(http.StatusForbidden, "Forbidden") | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Debug log for all requests to check if the IP is being properly recognized | |||
| 104 | + | log.Debug("Received request", "ip", clientIP, "path", c.Request().URL.Path) | |||
| 105 | + | ||||
| 106 | + | // Check if IP is blacklisted (if blacklist exists) | |||
| 107 | + | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | |||
| 108 | + | // Set context for other middleware | |||
| 109 | + | c.Set("reqContext", &requestContext{blacklisted: true}) | |||
| 110 | + | ||||
| 111 | + | // Rate-limited logging of blocked IPs | |||
| 112 | + | p.logBlockedIP(clientIP, c.Request().URL.Path, c.Request().UserAgent()) | |||
| 113 | + | ||||
| 114 | + | // Return forbidden without calling next handlers at all | |||
| 115 | + | return c.String(http.StatusForbidden, "Forbidden") | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // Store the context in the request context for non-blacklisted IPs | |||
| 119 | + | c.Set("reqContext", &requestContext{blacklisted: false}) | |||
| 120 | + | return next(c) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
D internal/proxy/proxy.go +0 -1737
| @@ -1,1737 +0,0 @@ | |||||
| 1 | - | package proxy | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "crypto/ecdsa" | |||
| 6 | - | "crypto/elliptic" | |||
| 7 | - | "crypto/rand" | |||
| 8 | - | "crypto/tls" | |||
| 9 | - | "crypto/x509" | |||
| 10 | - | "crypto/x509/pkix" | |||
| 11 | - | "database/sql" | |||
| 12 | - | "encoding/pem" | |||
| 13 | - | "fmt" | |||
| 14 | - | "math/big" | |||
| 15 | - | "net" | |||
| 16 | - | "net/http" | |||
| 17 | - | "net/http/httputil" | |||
| 18 | - | "net/url" | |||
| 19 | - | "os" | |||
| 20 | - | "regexp" | |||
| 21 | - | "strings" | |||
| 22 | - | "sync" | |||
| 23 | - | "time" | |||
| 24 | - | ||||
| 25 | - | "github.com/bnema/gordon/internal/common" | |||
| 26 | - | "github.com/bnema/gordon/internal/interfaces" | |||
| 27 | - | "github.com/charmbracelet/log" | |||
| 28 | - | "github.com/labstack/echo/v4" | |||
| 29 | - | "github.com/labstack/echo/v4/middleware" | |||
| 30 | - | "golang.org/x/crypto/acme" | |||
| 31 | - | "golang.org/x/crypto/acme/autocert" | |||
| 32 | - | "golang.org/x/time/rate" | |||
| 33 | - | ) | |||
| 34 | - | ||||
| 35 | - | // Proxy represents the reverse proxy server | |||
| 36 | - | type Proxy struct { | |||
| 37 | - | config common.ReverseProxyConfig | |||
| 38 | - | app interfaces.AppInterface | |||
| 39 | - | httpsServer *echo.Echo | |||
| 40 | - | httpServer *echo.Echo | |||
| 41 | - | routes map[string]*ProxyRouteInfo | |||
| 42 | - | mu sync.RWMutex | |||
| 43 | - | certManager *autocert.Manager | |||
| 44 | - | fallbackCert *tls.Certificate // Fallback self-signed certificate | |||
| 45 | - | serverStarted bool | |||
| 46 | - | blacklist *BlacklistConfig | |||
| 47 | - | ||||
| 48 | - | // Fields for throttling blacklist logs | |||
| 49 | - | lastBlockedLog time.Time | |||
| 50 | - | blockedIPCounter map[string]int | |||
| 51 | - | blockedIPCountMu sync.Mutex | |||
| 52 | - | ||||
| 53 | - | // Firewall-like memory of recently blocked IPs for quick rejection | |||
| 54 | - | recentlyBlocked map[string]time.Time | |||
| 55 | - | recentlyBlockedMu sync.RWMutex | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | // ProxyRouteInfo contains the information needed to route traffic to a container | |||
| 59 | - | type ProxyRouteInfo struct { | |||
| 60 | - | Domain string | |||
| 61 | - | ContainerIP string | |||
| 62 | - | ContainerPort string | |||
| 63 | - | ContainerID string | |||
| 64 | - | Protocol string | |||
| 65 | - | Path string | |||
| 66 | - | Active bool | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | // Create blacklistedIPs set to track IPs for middleware skipping | |||
| 70 | - | type requestContext struct { | |||
| 71 | - | blacklisted bool | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | // NewProxy creates a new reverse proxy | |||
| 75 | - | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||
| 76 | - | // Get the configuration from the app interface | |||
| 77 | - | // This will use the already loaded global configuration | |||
| 78 | - | config := app.GetConfig().ReverseProxy | |||
| 79 | - | ||||
| 80 | - | // Create the routes map | |||
| 81 | - | routes := make(map[string]*ProxyRouteInfo) | |||
| 82 | - | ||||
| 83 | - | // Initialize the blacklist | |||
| 84 | - | // Try both .yml and .yaml extensions, preferring .yml | |||
| 85 | - | var blacklist *BlacklistConfig | |||
| 86 | - | var err error | |||
| 87 | - | ||||
| 88 | - | storageDir := app.GetConfig().General.StorageDir | |||
| 89 | - | // Prefer .yml over .yaml for consistency with config.yml | |||
| 90 | - | blacklistPath := storageDir + "/blacklist.yml" | |||
| 91 | - | blacklistLegacy := storageDir + "/blacklist.yaml" | |||
| 92 | - | ||||
| 93 | - | // Check if legacy blacklist.yaml exists but not .yml | |||
| 94 | - | if _, statErrLegacy := os.Stat(blacklistLegacy); statErrLegacy == nil { | |||
| 95 | - | if _, statErrYml := os.Stat(blacklistPath); statErrYml != nil && os.IsNotExist(statErrYml) { | |||
| 96 | - | log.Info("Found legacy blacklist.yaml file, using it", "path", blacklistLegacy) | |||
| 97 | - | blacklist, err = NewBlacklist(blacklistLegacy) | |||
| 98 | - | } else { | |||
| 99 | - | // Both exist or only .yml exists, prefer .yml | |||
| 100 | - | log.Debug("Using blacklist.yml file", "path", blacklistPath) | |||
| 101 | - | blacklist, err = NewBlacklist(blacklistPath) | |||
| 102 | - | } | |||
| 103 | - | } else { | |||
| 104 | - | // No legacy file, use .yml | |||
| 105 | - | log.Debug("Using blacklist.yml file", "path", blacklistPath) | |||
| 106 | - | blacklist, err = NewBlacklist(blacklistPath) | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | if err != nil { | |||
| 110 | - | log.Error("Failed to initialize blacklist", "error", err) | |||
| 111 | - | // Continue anyway with nil blacklist | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | // Create the HTTPS echo instance | |||
| 115 | - | httpsServer := echo.New() | |||
| 116 | - | httpsServer.HideBanner = true | |||
| 117 | - | httpsServer.Use(middleware.Recover()) | |||
| 118 | - | ||||
| 119 | - | // Create the HTTP echo instance (for redirects to HTTPS) | |||
| 120 | - | httpServer := echo.New() | |||
| 121 | - | httpServer.HideBanner = true | |||
| 122 | - | httpServer.Use(middleware.Recover()) | |||
| 123 | - | ||||
| 124 | - | // Create the proxy | |||
| 125 | - | p := &Proxy{ | |||
| 126 | - | config: config, | |||
| 127 | - | app: app, | |||
| 128 | - | httpsServer: httpsServer, | |||
| 129 | - | httpServer: httpServer, | |||
| 130 | - | routes: routes, | |||
| 131 | - | serverStarted: false, | |||
| 132 | - | blacklist: blacklist, | |||
| 133 | - | blockedIPCounter: make(map[string]int), | |||
| 134 | - | lastBlockedLog: time.Time{}, // Zero time | |||
| 135 | - | recentlyBlocked: make(map[string]time.Time), | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | // Now add the middleware with the proxy reference | |||
| 139 | - | p.setupMiddleware() | |||
| 140 | - | ||||
| 141 | - | // Set up the certificate manager | |||
| 142 | - | p.setupCertManager() | |||
| 143 | - | ||||
| 144 | - | log.Debug("Reverse proxy initialized") | |||
| 145 | - | return p, nil | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | // setupMiddleware configures middleware for both HTTP and HTTPS servers | |||
| 149 | - | func (p *Proxy) setupMiddleware() { | |||
| 150 | - | // Create blacklist middleware for HTTPS | |||
| 151 | - | p.httpsServer.Use(p.createBlacklistMiddleware()) | |||
| 152 | - | ||||
| 153 | - | // Add rate limiter middleware to prevent spam attacks | |||
| 154 | - | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |||
| 155 | - | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | |||
| 156 | - | middleware.RateLimiterMemoryStoreConfig{ | |||
| 157 | - | Rate: rate.Limit(10), // 10 requests per second | |||
| 158 | - | Burst: 30, // Burst of 30 requests | |||
| 159 | - | ExpiresIn: 3 * time.Minute, // Store expiration | |||
| 160 | - | }, | |||
| 161 | - | ), | |||
| 162 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | |||
| 163 | - | log.Warn("Rate limit exceeded", | |||
| 164 | - | "ip", identifier, | |||
| 165 | - | "path", context.Request().URL.Path, | |||
| 166 | - | "method", context.Request().Method) | |||
| 167 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | |||
| 168 | - | }, | |||
| 169 | - | })) | |||
| 170 | - | ||||
| 171 | - | // Add default Echo logger middleware | |||
| 172 | - | p.httpsServer.Use(middleware.Logger()) | |||
| 173 | - | ||||
| 174 | - | // Create blacklist middleware for HTTP | |||
| 175 | - | p.httpServer.Use(p.createBlacklistMiddleware()) | |||
| 176 | - | ||||
| 177 | - | // Add rate limiter middleware to prevent spam attacks for HTTP server | |||
| 178 | - | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |||
| 179 | - | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | |||
| 180 | - | middleware.RateLimiterMemoryStoreConfig{ | |||
| 181 | - | Rate: rate.Limit(10), // 10 requests per second | |||
| 182 | - | Burst: 30, // Burst of 30 requests | |||
| 183 | - | ExpiresIn: 3 * time.Minute, // Store expiration | |||
| 184 | - | }, | |||
| 185 | - | ), | |||
| 186 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | |||
| 187 | - | log.Warn("Rate limit exceeded", | |||
| 188 | - | "ip", identifier, | |||
| 189 | - | "path", context.Request().URL.Path, | |||
| 190 | - | "method", context.Request().Method) | |||
| 191 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | |||
| 192 | - | }, | |||
| 193 | - | })) | |||
| 194 | - | ||||
| 195 | - | // Add default Echo logger middleware for HTTP server | |||
| 196 | - | p.httpServer.Use(middleware.Logger()) | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | // createBlacklistMiddleware returns a middleware function that checks IPs against the blacklist | |||
| 200 | - | func (p *Proxy) createBlacklistMiddleware() echo.MiddlewareFunc { | |||
| 201 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |||
| 202 | - | return func(c echo.Context) error { | |||
| 203 | - | // Get client IP | |||
| 204 | - | clientIP := c.RealIP() | |||
| 205 | - | ||||
| 206 | - | // Get host and strip port if present | |||
| 207 | - | host := c.Request().Host | |||
| 208 | - | if strings.Contains(host, ":") { | |||
| 209 | - | host = strings.Split(host, ":")[0] | |||
| 210 | - | } | |||
| 211 | - | ||||
| 212 | - | // Check if the host is an IP address | |||
| 213 | - | if net.ParseIP(host) != nil { | |||
| 214 | - | // This is an IP address being used as a hostname - silently reject | |||
| 215 | - | // This prevents log spam without adding IPs to the blacklist | |||
| 216 | - | // Set context for other middleware | |||
| 217 | - | c.Set("reqContext", &requestContext{blacklisted: true}) | |||
| 218 | - | return c.String(http.StatusForbidden, "Forbidden") | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | // Debug log for all requests to check if the IP is being properly recognized | |||
| 222 | - | log.Debug("Received request", "ip", clientIP, "path", c.Request().URL.Path) | |||
| 223 | - | ||||
| 224 | - | // Check if IP is blacklisted (if blacklist exists) | |||
| 225 | - | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | |||
| 226 | - | // Set context for other middleware | |||
| 227 | - | c.Set("reqContext", &requestContext{blacklisted: true}) | |||
| 228 | - | ||||
| 229 | - | // Rate-limited logging of blocked IPs | |||
| 230 | - | p.logBlockedIP(clientIP, c.Request().URL.Path, c.Request().UserAgent()) | |||
| 231 | - | ||||
| 232 | - | // Return forbidden without calling next handlers at all | |||
| 233 | - | return c.String(http.StatusForbidden, "Forbidden") | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | // Store the context in the request context for non-blacklisted IPs | |||
| 237 | - | c.Set("reqContext", &requestContext{blacklisted: false}) | |||
| 238 | - | return next(c) | |||
| 239 | - | } | |||
| 240 | - | } | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | // setupCertManager configures the autocert manager for Let's Encrypt | |||
| 244 | - | func (p *Proxy) setupCertManager() { | |||
| 245 | - | // Create a cache directory if it doesn't exist | |||
| 246 | - | dir := p.config.CertDir | |||
| 247 | - | if dir == "" { | |||
| 248 | - | dir = p.app.GetConfig().General.StorageDir + "/certs" | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | // Ensure directory exists | |||
| 252 | - | if err := os.MkdirAll(dir, 0755); err != nil { | |||
| 253 | - | log.Warn("Failed to create certificate cache directory", | |||
| 254 | - | "dir", dir, | |||
| 255 | - | "error", err) | |||
| 256 | - | } | |||
| 257 | - | ||||
| 258 | - | // Set up the certificate manager | |||
| 259 | - | certManager := &autocert.Manager{ | |||
| 260 | - | Prompt: autocert.AcceptTOS, | |||
| 261 | - | Cache: autocert.DirCache(dir), | |||
| 262 | - | } | |||
| 263 | - | ||||
| 264 | - | // Configure the email if provided | |||
| 265 | - | if p.config.Email != "" { | |||
| 266 | - | certManager.Email = p.config.Email | |||
| 267 | - | } | |||
| 268 | - | ||||
| 269 | - | // Configure the Let's Encrypt client | |||
| 270 | - | if p.config.LetsEncryptMode == "staging" { | |||
| 271 | - | certManager.Client = &acme.Client{ | |||
| 272 | - | DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory", | |||
| 273 | - | } | |||
| 274 | - | log.Debug("Using Let's Encrypt staging environment", | |||
| 275 | - | "url", "https://acme-staging-v02.api.letsencrypt.org/directory") | |||
| 276 | - | } else { | |||
| 277 | - | // Explicitly set production URL when not in staging mode | |||
| 278 | - | certManager.Client = &acme.Client{ | |||
| 279 | - | DirectoryURL: acme.LetsEncryptURL, // "https://acme-v02.api.letsencrypt.org/directory" | |||
| 280 | - | } | |||
| 281 | - | log.Debug("Using Let's Encrypt production environment", | |||
| 282 | - | "url", acme.LetsEncryptURL) | |||
| 283 | - | } | |||
| 284 | - | ||||
| 285 | - | // Set HostPolicy to allow the admin domain | |||
| 286 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 287 | - | rootDomain := p.app.GetConfig().Http.Domain | |||
| 288 | - | ||||
| 289 | - | // Use a more permissive hostpolicy that logs unknown domains rather than rejecting them | |||
| 290 | - | // This helps debug certificate acquisition issues | |||
| 291 | - | certManager.HostPolicy = func(_ context.Context, host string) error { | |||
| 292 | - | // Always allow the admin domain and root domain | |||
| 293 | - | if host == adminDomain || host == rootDomain { | |||
| 294 | - | return nil | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | - | // For other domains, check if they are in our routes | |||
| 298 | - | p.mu.RLock() | |||
| 299 | - | defer p.mu.RUnlock() | |||
| 300 | - | ||||
| 301 | - | if _, ok := p.routes[host]; ok { | |||
| 302 | - | return nil | |||
| 303 | - | } | |||
| 304 | - | ||||
| 305 | - | // Log the attempt but still allow it - this helps debug | |||
| 306 | - | // certificate acquisition issues during development | |||
| 307 | - | log.Warn("Unknown host in certificate request", | |||
| 308 | - | "host", host, | |||
| 309 | - | "adminDomain", adminDomain, | |||
| 310 | - | "allowed", "yes") | |||
| 311 | - | ||||
| 312 | - | // Allow unknown domains temporarily to help diagnose issues | |||
| 313 | - | // Change this to return an error in production for stricter security | |||
| 314 | - | return nil | |||
| 315 | - | } | |||
| 316 | - | ||||
| 317 | - | // Generate a fallback self-signed certificate for the admin domain | |||
| 318 | - | var fallbackDomains []string | |||
| 319 | - | fallbackDomains = append(fallbackDomains, adminDomain) | |||
| 320 | - | ||||
| 321 | - | // Include root domain in fallback certificate if it's different from admin domain | |||
| 322 | - | if rootDomain != "" && rootDomain != adminDomain { | |||
| 323 | - | fallbackDomains = append(fallbackDomains, rootDomain) | |||
| 324 | - | } | |||
| 325 | - | ||||
| 326 | - | fallbackCert, err := generateFallbackCertificates(fallbackDomains) | |||
| 327 | - | if err != nil { | |||
| 328 | - | log.Warn("Failed to generate fallback certificate", "error", err) | |||
| 329 | - | } else { | |||
| 330 | - | log.Info("Generated fallback self-signed certificate for admin domain", | |||
| 331 | - | "domain", adminDomain, | |||
| 332 | - | "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05")) | |||
| 333 | - | // Store the fallback certificate | |||
| 334 | - | p.fallbackCert = fallbackCert | |||
| 335 | - | } | |||
| 336 | - | ||||
| 337 | - | p.certManager = certManager | |||
| 338 | - | log.Debug("Certificate manager setup completed", | |||
| 339 | - | "directory", dir, | |||
| 340 | - | "mode", p.config.LetsEncryptMode) | |||
| 341 | - | ||||
| 342 | - | // Request the certificate for the admin domain | |||
| 343 | - | go p.requestAdminCertificate() | |||
| 344 | - | } | |||
| 345 | - | ||||
| 346 | - | // checkCertificateInCache checks if a valid certificate for the given domain exists in the cache | |||
| 347 | - | // Returns true if a valid certificate exists, false otherwise | |||
| 348 | - | func (p *Proxy) checkCertificateInCache(domain string) bool { | |||
| 349 | - | if p.certManager == nil || p.certManager.Cache == nil { | |||
| 350 | - | return false | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | // The cache key format used by autocert is "cert-" + domain | |||
| 354 | - | cacheKey := "cert-" + domain | |||
| 355 | - | ||||
| 356 | - | // Create a context with a short timeout for the cache check | |||
| 357 | - | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 358 | - | defer cancel() | |||
| 359 | - | ||||
| 360 | - | // Try to get the certificate from the cache | |||
| 361 | - | certData, err := p.certManager.Cache.Get(ctx, cacheKey) | |||
| 362 | - | if err != nil { | |||
| 363 | - | // ErrCacheMiss or other error means the certificate is not in the cache | |||
| 364 | - | log.Debug("Certificate not found in cache", | |||
| 365 | - | "domain", domain, | |||
| 366 | - | "error", err) | |||
| 367 | - | return false | |||
| 368 | - | } | |||
| 369 | - | ||||
| 370 | - | // Parse the certificate to check its validity | |||
| 371 | - | block, _ := pem.Decode(certData) | |||
| 372 | - | if block == nil || block.Type != "CERTIFICATE" { | |||
| 373 | - | log.Warn("Invalid certificate data in cache", | |||
| 374 | - | "domain", domain) | |||
| 375 | - | return false | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | cert, err := x509.ParseCertificate(block.Bytes) | |||
| 379 | - | if err != nil { | |||
| 380 | - | log.Warn("Failed to parse certificate from cache", | |||
| 381 | - | "domain", domain, | |||
| 382 | - | "error", err) | |||
| 383 | - | return false | |||
| 384 | - | } | |||
| 385 | - | ||||
| 386 | - | // Check if the certificate is still valid | |||
| 387 | - | now := time.Now() | |||
| 388 | - | if now.After(cert.NotAfter) || now.Before(cert.NotBefore) { | |||
| 389 | - | log.Info("Certificate in cache has expired or is not yet valid", | |||
| 390 | - | "domain", domain, | |||
| 391 | - | "not_before", cert.NotBefore, | |||
| 392 | - | "not_after", cert.NotAfter) | |||
| 393 | - | return false | |||
| 394 | - | } | |||
| 395 | - | ||||
| 396 | - | // Check if the certificate is about to expire (within 30 days) | |||
| 397 | - | if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) { | |||
| 398 | - | log.Info("Certificate in cache is valid but will expire soon", | |||
| 399 | - | "domain", domain, | |||
| 400 | - | "expires_in", cert.NotAfter.Sub(now).Hours()/24, | |||
| 401 | - | "days") | |||
| 402 | - | // Return false to trigger renewal if it's about to expire | |||
| 403 | - | return false | |||
| 404 | - | } | |||
| 405 | - | ||||
| 406 | - | log.Info("Valid certificate found in cache", | |||
| 407 | - | "domain", domain, | |||
| 408 | - | "expires_in", cert.NotAfter.Sub(now).Hours()/24, | |||
| 409 | - | "days") | |||
| 410 | - | return true | |||
| 411 | - | } | |||
| 412 | - | ||||
| 413 | - | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | |||
| 414 | - | // for the Gordon admin interface | |||
| 415 | - | func (p *Proxy) requestAdminCertificate() { | |||
| 416 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 417 | - | if adminDomain == "" { | |||
| 418 | - | return | |||
| 419 | - | } | |||
| 420 | - | ||||
| 421 | - | // Only proceed if HTTPS is enabled | |||
| 422 | - | if !p.app.GetConfig().Http.Https { | |||
| 423 | - | log.Debug("HTTPS is disabled, skipping admin certificate request") | |||
| 424 | - | return | |||
| 425 | - | } | |||
| 426 | - | ||||
| 427 | - | // Extract hostname from admin domain, resolving it to see if it's publicly accessible | |||
| 428 | - | host := adminDomain | |||
| 429 | - | ips, err := net.LookupIP(host) | |||
| 430 | - | if err != nil { | |||
| 431 | - | log.Error("Could not resolve admin domain, Let's Encrypt will likely fail", | |||
| 432 | - | "domain", adminDomain, | |||
| 433 | - | "error", err.Error(), | |||
| 434 | - | "solution", "Check DNS settings and ensure domain points to this server") | |||
| 435 | - | return | |||
| 436 | - | } | |||
| 437 | - | ||||
| 438 | - | var ipStrings []string | |||
| 439 | - | for _, ip := range ips { | |||
| 440 | - | ipStrings = append(ipStrings, ip.String()) | |||
| 441 | - | } | |||
| 442 | - | ||||
| 443 | - | log.Info("Successfully resolved admin domain", | |||
| 444 | - | "domain", adminDomain, | |||
| 445 | - | "ips", ipStrings) | |||
| 446 | - | ||||
| 447 | - | // Check if we already have a valid certificate in the cache | |||
| 448 | - | if p.checkCertificateInCache(adminDomain) { | |||
| 449 | - | log.Info("Using existing certificate from cache", | |||
| 450 | - | "domain", adminDomain, | |||
| 451 | - | "action", "skipping Let's Encrypt request to avoid rate limits") | |||
| 452 | - | return | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | // Check if environment is production | |||
| 456 | - | mode := "staging" | |||
| 457 | - | email := p.config.Email | |||
| 458 | - | ||||
| 459 | - | if !p.app.IsDevEnvironment() && email != "" { | |||
| 460 | - | mode = "production" | |||
| 461 | - | } | |||
| 462 | - | ||||
| 463 | - | // Log the certificate request intent | |||
| 464 | - | log.Info("Initiating Let's Encrypt certificate request for admin domain", | |||
| 465 | - | "domain", adminDomain, | |||
| 466 | - | "email", email, | |||
| 467 | - | "mode", mode) | |||
| 468 | - | ||||
| 469 | - | log.Info("⏳ Waiting for Let's Encrypt to validate domain ownership", | |||
| 470 | - | "domain", adminDomain, | |||
| 471 | - | "validation_method", "HTTP-01 challenge", | |||
| 472 | - | "requirements", "Domain must be publicly accessible on port 80", | |||
| 473 | - | "timeout", "1 minute") | |||
| 474 | - | ||||
| 475 | - | // Create a context with timeout for the initial certificate request | |||
| 476 | - | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) | |||
| 477 | - | defer cancel() | |||
| 478 | - | ||||
| 479 | - | // Try to obtain the certificate with timeout | |||
| 480 | - | var certResult *tls.Certificate | |||
| 481 | - | var certErr error | |||
| 482 | - | ||||
| 483 | - | // Use a channel to handle the timeout | |||
| 484 | - | certChan := make(chan struct { | |||
| 485 | - | cert *tls.Certificate | |||
| 486 | - | err error | |||
| 487 | - | }) | |||
| 488 | - | ||||
| 489 | - | go func() { | |||
| 490 | - | c, e := p.certManager.GetCertificate(&tls.ClientHelloInfo{ | |||
| 491 | - | ServerName: adminDomain, | |||
| 492 | - | }) | |||
| 493 | - | certChan <- struct { | |||
| 494 | - | cert *tls.Certificate | |||
| 495 | - | err error | |||
| 496 | - | }{c, e} | |||
| 497 | - | }() | |||
| 498 | - | ||||
| 499 | - | // Wait for either the certificate request to complete or the timeout | |||
| 500 | - | select { | |||
| 501 | - | case result := <-certChan: | |||
| 502 | - | certResult = result.cert | |||
| 503 | - | certErr = result.err | |||
| 504 | - | case <-ctx.Done(): | |||
| 505 | - | certErr = fmt.Errorf("certificate request timed out after 2 minutes: %w", ctx.Err()) | |||
| 506 | - | log.Error("Let's Encrypt certificate request timed out", | |||
| 507 | - | "domain", adminDomain, | |||
| 508 | - | "timeout", "2 minutes", | |||
| 509 | - | "error", certErr) | |||
| 510 | - | } | |||
| 511 | - | ||||
| 512 | - | if certErr != nil { | |||
| 513 | - | // Check for rate limit errors | |||
| 514 | - | if strings.Contains(strings.ToLower(certErr.Error()), "ratelimited") { | |||
| 515 | - | // Extract the retry-after time if available in the error message | |||
| 516 | - | retryAfterStr := "" | |||
| 517 | - | re := regexp.MustCompile(`retry after ([^:]+)`) | |||
| 518 | - | matches := re.FindStringSubmatch(certErr.Error()) | |||
| 519 | - | if len(matches) > 1 { | |||
| 520 | - | retryAfterStr = matches[1] | |||
| 521 | - | } | |||
| 522 | - | ||||
| 523 | - | log.Error("Let's Encrypt rate limit reached", | |||
| 524 | - | "domain", adminDomain, | |||
| 525 | - | "error", certErr, | |||
| 526 | - | "retry_after", retryAfterStr, | |||
| 527 | - | "solution", "Wait until the rate limit expires, then restart the server") | |||
| 528 | - | ||||
| 529 | - | // Don't retry on rate limits | |||
| 530 | - | log.Info("Skipping certificate request retries due to rate limiting", | |||
| 531 | - | "domain", adminDomain) | |||
| 532 | - | } else { | |||
| 533 | - | log.Error("Failed to obtain Let's Encrypt certificate", | |||
| 534 | - | "domain", adminDomain, | |||
| 535 | - | "error", certErr) | |||
| 536 | - | ||||
| 537 | - | log.Info("⏳ Retrying certificate request with exponential backoff", | |||
| 538 | - | "domain", adminDomain, | |||
| 539 | - | "timeout", "2 minutes") | |||
| 540 | - | // Implement retry logic with backoff | |||
| 541 | - | go p.retryCertificateRequest(adminDomain, 3, 10*time.Second) | |||
| 542 | - | } | |||
| 543 | - | } else if certResult != nil { | |||
| 544 | - | log.Info("Successfully obtained Let's Encrypt certificate", | |||
| 545 | - | "domain", adminDomain) | |||
| 546 | - | } else { | |||
| 547 | - | log.Error("Unexpected error: received nil certificate but no error", | |||
| 548 | - | "domain", adminDomain, | |||
| 549 | - | "timeout", "2 minutes") | |||
| 550 | - | // Implement retry logic with backoff | |||
| 551 | - | go p.retryCertificateRequest(adminDomain, 3, 10*time.Second) | |||
| 552 | - | } | |||
| 553 | - | } | |||
| 554 | - | ||||
| 555 | - | // requestDomainCertificate proactively requests a Let's Encrypt certificate | |||
| 556 | - | // for a container domain | |||
| 557 | - | func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 558 | - | if domain == "" { | |||
| 559 | - | log.Warn("Empty domain provided to requestDomainCertificate") | |||
| 560 | - | return | |||
| 561 | - | } | |||
| 562 | - | ||||
| 563 | - | // Extract hostname from domain, resolving it to see if it's publicly accessible | |||
| 564 | - | ips, err := net.LookupIP(domain) | |||
| 565 | - | if err != nil { | |||
| 566 | - | log.Error("Could not resolve domain, Let's Encrypt will likely fail", | |||
| 567 | - | "domain", domain, | |||
| 568 | - | "error", err.Error(), | |||
| 569 | - | "solution", "Check DNS settings and ensure domain points to this server") | |||
| 570 | - | return | |||
| 571 | - | } | |||
| 572 | - | ||||
| 573 | - | var ipStrings []string | |||
| 574 | - | for _, ip := range ips { | |||
| 575 | - | ipStrings = append(ipStrings, ip.String()) | |||
| 576 | - | } | |||
| 577 | - | ||||
| 578 | - | log.Info("Successfully resolved domain for certificate request", | |||
| 579 | - | "domain", domain, | |||
| 580 | - | "ips", ipStrings) | |||
| 581 | - | ||||
| 582 | - | // Check if we already have a valid certificate in the cache | |||
| 583 | - | if p.checkCertificateInCache(domain) { | |||
| 584 | - | log.Info("Using existing certificate from cache", | |||
| 585 | - | "domain", domain, | |||
| 586 | - | "action", "skipping Let's Encrypt request to avoid rate limits") | |||
| 587 | - | return | |||
| 588 | - | } | |||
| 589 | - | ||||
| 590 | - | // Check if environment is production | |||
| 591 | - | mode := "staging" | |||
| 592 | - | email := p.config.Email | |||
| 593 | - | ||||
| 594 | - | if !p.app.IsDevEnvironment() && email != "" { | |||
| 595 | - | mode = "production" | |||
| 596 | - | } | |||
| 597 | - | ||||
| 598 | - | // Log the certificate request intent | |||
| 599 | - | log.Info("Initiating Let's Encrypt certificate request for container domain", | |||
| 600 | - | "domain", domain, | |||
| 601 | - | "email", email, | |||
| 602 | - | "mode", mode) | |||
| 603 | - | ||||
| 604 | - | log.Info("⏳ Waiting for Let's Encrypt to validate domain ownership", | |||
| 605 | - | "domain", domain, | |||
| 606 | - | "validation_method", "HTTP-01 challenge", | |||
| 607 | - | "requirements", "Domain must be publicly accessible on port 80", | |||
| 608 | - | "timeout", "1 minute") | |||
| 609 | - | ||||
| 610 | - | // Create a context with timeout for the initial certificate request | |||
| 611 | - | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) | |||
| 612 | - | defer cancel() | |||
| 613 | - | ||||
| 614 | - | // Try to obtain the certificate with timeout | |||
| 615 | - | var certResult *tls.Certificate | |||
| 616 | - | var certErr error | |||
| 617 | - | ||||
| 618 | - | // Use a channel to handle the timeout | |||
| 619 | - | certChan := make(chan struct { | |||
| 620 | - | cert *tls.Certificate | |||
| 621 | - | err error | |||
| 622 | - | }) | |||
| 623 | - | ||||
| 624 | - | go func() { | |||
| 625 | - | c, e := p.certManager.GetCertificate(&tls.ClientHelloInfo{ | |||
| 626 | - | ServerName: domain, | |||
| 627 | - | }) | |||
| 628 | - | certChan <- struct { | |||
| 629 | - | cert *tls.Certificate | |||
| 630 | - | err error | |||
| 631 | - | }{c, e} | |||
| 632 | - | }() | |||
| 633 | - | ||||
| 634 | - | // Wait for either the certificate request to complete or the timeout | |||
| 635 | - | select { | |||
| 636 | - | case result := <-certChan: | |||
| 637 | - | certResult = result.cert | |||
| 638 | - | certErr = result.err | |||
| 639 | - | case <-ctx.Done(): | |||
| 640 | - | certErr = fmt.Errorf("certificate request timed out after 1 minute: %w", ctx.Err()) | |||
| 641 | - | log.Error("Let's Encrypt certificate request timed out", | |||
| 642 | - | "domain", domain, | |||
| 643 | - | "timeout", "1 minute", | |||
| 644 | - | "error", certErr) | |||
| 645 | - | } | |||
| 646 | - | ||||
| 647 | - | if certErr != nil { | |||
| 648 | - | // Check for rate limit errors | |||
| 649 | - | if strings.Contains(strings.ToLower(certErr.Error()), "ratelimited") { | |||
| 650 | - | // Extract the retry-after time if available in the error message | |||
| 651 | - | retryAfterStr := "" | |||
| 652 | - | re := regexp.MustCompile(`retry after ([^:]+)`) | |||
| 653 | - | matches := re.FindStringSubmatch(certErr.Error()) | |||
| 654 | - | if len(matches) > 1 { | |||
| 655 | - | retryAfterStr = matches[1] | |||
| 656 | - | } | |||
| 657 | - | ||||
| 658 | - | log.Error("Let's Encrypt rate limit reached", | |||
| 659 | - | "domain", domain, | |||
| 660 | - | "error", certErr, | |||
| 661 | - | "retry_after", retryAfterStr, | |||
| 662 | - | "solution", "Wait until the rate limit expires, then restart the server") | |||
| 663 | - | ||||
| 664 | - | // Don't retry on rate limits | |||
| 665 | - | log.Info("Skipping certificate request retries due to rate limiting", | |||
| 666 | - | "domain", domain) | |||
| 667 | - | } else { | |||
| 668 | - | log.Error("Failed to obtain Let's Encrypt certificate", | |||
| 669 | - | "domain", domain, | |||
| 670 | - | "error", certErr) | |||
| 671 | - | ||||
| 672 | - | log.Info("⏳ Retrying certificate request with exponential backoff", | |||
| 673 | - | "domain", domain, | |||
| 674 | - | "timeout", "2 minutes") | |||
| 675 | - | // Implement retry logic with backoff | |||
| 676 | - | go p.retryCertificateRequest(domain, 3, 10*time.Second) | |||
| 677 | - | } | |||
| 678 | - | } else if certResult != nil { | |||
| 679 | - | log.Info("Successfully obtained Let's Encrypt certificate", | |||
| 680 | - | "domain", domain) | |||
| 681 | - | } else { | |||
| 682 | - | log.Error("Unexpected error: received nil certificate but no error", | |||
| 683 | - | "domain", domain, | |||
| 684 | - | "timeout", "1 minute") | |||
| 685 | - | // Implement retry logic with backoff | |||
| 686 | - | go p.retryCertificateRequest(domain, 3, 10*time.Second) | |||
| 687 | - | } | |||
| 688 | - | } | |||
| 689 | - | ||||
| 690 | - | // retryCertificateRequest attempts to request a certificate with exponential backoff | |||
| 691 | - | func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) { | |||
| 692 | - | backoff := initialBackoff | |||
| 693 | - | ||||
| 694 | - | for attempt := 1; attempt <= maxRetries; attempt++ { | |||
| 695 | - | // Wait for backoff period | |||
| 696 | - | log.Info("Retrying Let's Encrypt certificate request", | |||
| 697 | - | "domain", domain, | |||
| 698 | - | "attempt", attempt, | |||
| 699 | - | "max_retries", maxRetries, | |||
| 700 | - | "wait_time", backoff) | |||
| 701 | - | time.Sleep(backoff) | |||
| 702 | - | ||||
| 703 | - | // Before each retry, check if the HTTP challenge endpoint is accessible | |||
| 704 | - | if attempt > 1 { | |||
| 705 | - | // Try connecting to our own HTTP server on port 80 | |||
| 706 | - | // to verify its availability for Let's Encrypt validation | |||
| 707 | - | client := &http.Client{ | |||
| 708 | - | Timeout: 5 * time.Second, | |||
| 709 | - | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 710 | - | // Don't follow redirects for this test | |||
| 711 | - | return http.ErrUseLastResponse | |||
| 712 | - | }, | |||
| 713 | - | } | |||
| 714 | - | ||||
| 715 | - | // Test the ACME challenge path with a fake token | |||
| 716 | - | testURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test-token", domain) | |||
| 717 | - | resp, err := client.Get(testURL) | |||
| 718 | - | ||||
| 719 | - | if err != nil { | |||
| 720 | - | log.Warn("HTTP challenge endpoint might not be accessible", | |||
| 721 | - | "domain", domain, | |||
| 722 | - | "url", testURL, | |||
| 723 | - | "error", err, | |||
| 724 | - | "solution", "Ensure port 80 is accessible and not blocked by firewall") | |||
| 725 | - | } else { | |||
| 726 | - | resp.Body.Close() | |||
| 727 | - | log.Info("HTTP challenge endpoint is accessible", | |||
| 728 | - | "domain", domain, | |||
| 729 | - | "url", testURL, | |||
| 730 | - | "status", resp.StatusCode) | |||
| 731 | - | } | |||
| 732 | - | } | |||
| 733 | - | ||||
| 734 | - | // Create context with timeout for this attempt | |||
| 735 | - | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) | |||
| 736 | - | ||||
| 737 | - | // Request certificate | |||
| 738 | - | err := func(ctx context.Context) error { | |||
| 739 | - | defer cancel() | |||
| 740 | - | cert, certErr := p.certManager.GetCertificate(&tls.ClientHelloInfo{ | |||
| 741 | - | ServerName: domain, | |||
| 742 | - | }) | |||
| 743 | - | if cert == nil && certErr == nil { | |||
| 744 | - | return fmt.Errorf("certificate is nil but no error returned") | |||
| 745 | - | } | |||
| 746 | - | return certErr | |||
| 747 | - | }(ctx) | |||
| 748 | - | ||||
| 749 | - | // Check result | |||
| 750 | - | if err == nil { | |||
| 751 | - | log.Info("Successfully obtained Let's Encrypt certificate on retry", | |||
| 752 | - | "domain", domain, | |||
| 753 | - | "attempt", attempt) | |||
| 754 | - | return | |||
| 755 | - | } | |||
| 756 | - | ||||
| 757 | - | log.Error("Let's Encrypt certificate request retry failed", | |||
| 758 | - | "domain", domain, | |||
| 759 | - | "attempt", attempt, | |||
| 760 | - | "error", err) | |||
| 761 | - | ||||
| 762 | - | // Provide more detailed diagnostics based on the error | |||
| 763 | - | if strings.Contains(strings.ToLower(err.Error()), "connection refused") || | |||
| 764 | - | strings.Contains(strings.ToLower(err.Error()), "timeout") { | |||
| 765 | - | log.Error("Let's Encrypt connection failed - this typically indicates:", | |||
| 766 | - | "issue_1", "Port 80 is not accessible from the internet", | |||
| 767 | - | "issue_2", "Firewall is blocking inbound connections", | |||
| 768 | - | "issue_3", "DNS records not properly propagated", | |||
| 769 | - | "solution", "Check firewall settings and DNS configuration") | |||
| 770 | - | } else if strings.Contains(strings.ToLower(err.Error()), "unauthorized") { | |||
| 771 | - | log.Error("Let's Encrypt authorization failed - this typically indicates:", | |||
| 772 | - | "issue", "Domain ownership validation failed", | |||
| 773 | - | "solution", "Ensure the server is publicly accessible on port 80") | |||
| 774 | - | } else if strings.Contains(strings.ToLower(err.Error()), "ratelimited") { | |||
| 775 | - | // Extract the retry-after date if present | |||
| 776 | - | retryAfterStr := "see error message for details" | |||
| 777 | - | if strings.Contains(err.Error(), "retry after") { | |||
| 778 | - | parts := strings.Split(err.Error(), "retry after") | |||
| 779 | - | if len(parts) > 1 { | |||
| 780 | - | retryAfterStr = strings.TrimSpace(parts[1]) | |||
| 781 | - | if idx := strings.Index(retryAfterStr, ":"); idx > 0 { | |||
| 782 | - | retryAfterStr = retryAfterStr[:idx] | |||
| 783 | - | } | |||
| 784 | - | } | |||
| 785 | - | } | |||
| 786 | - | ||||
| 787 | - | log.Error("Let's Encrypt rate limit reached - cannot issue more certificates for this domain yet", | |||
| 788 | - | "domain", domain, | |||
| 789 | - | "retry_after", retryAfterStr, | |||
| 790 | - | "solution", "Wait until the rate limit expires, then restart the server") | |||
| 791 | - | ||||
| 792 | - | // No point in retrying on rate limit errors | |||
| 793 | - | return | |||
| 794 | - | } | |||
| 795 | - | ||||
| 796 | - | // Increase backoff for next attempt (exponential backoff) | |||
| 797 | - | backoff *= 2 | |||
| 798 | - | } | |||
| 799 | - | ||||
| 800 | - | log.Error("All Let's Encrypt certificate request retries failed", | |||
| 801 | - | "domain", domain, | |||
| 802 | - | "max_retries", maxRetries, | |||
| 803 | - | "fallback", "Using self-signed certificate") | |||
| 804 | - | ||||
| 805 | - | // At this point, all retries have failed, so we'll rely on the fallback self-signed certificate | |||
| 806 | - | // But let's log this prominently for debugging | |||
| 807 | - | log.Error("⚠️ HTTPS is using a self-signed certificate which browsers will warn about", | |||
| 808 | - | "domain", domain, | |||
| 809 | - | "reason", "Let's Encrypt certificate issuance failed", | |||
| 810 | - | "solution", "Check network settings and Let's Encrypt status") | |||
| 811 | - | ||||
| 812 | - | // Suggest checking the common issues | |||
| 813 | - | log.Error("Common Let's Encrypt issues to check:", | |||
| 814 | - | "check_1", "Ensure ports 80 and 443 are open on your firewall", | |||
| 815 | - | "check_2", "Verify DNS records point to your server IP", | |||
| 816 | - | "check_3", "Make sure no other services are running on ports 80/443", | |||
| 817 | - | "check_4", "Check if Let's Encrypt service is having issues (https://letsencrypt.status.io/)") | |||
| 818 | - | } | |||
| 819 | - | ||||
| 820 | - | // Start loads the routes from the database and starts the proxy server | |||
| 821 | - | func (p *Proxy) Start() error { | |||
| 822 | - | // Check if there might be port conflicts with the main server | |||
| 823 | - | mainServerPort := p.app.GetConfig().Http.Port | |||
| 824 | - | ||||
| 825 | - | // Check HTTP port conflict | |||
| 826 | - | if p.config.HttpPort == mainServerPort { | |||
| 827 | - | log.Warn("HTTP port for reverse proxy conflicts with main server port", | |||
| 828 | - | "port", p.config.HttpPort, | |||
| 829 | - | "solution", "reverse proxy HTTP server will be disabled") | |||
| 830 | - | return nil | |||
| 831 | - | } | |||
| 832 | - | ||||
| 833 | - | // Check HTTPS port conflict (less common, but still possible) | |||
| 834 | - | if p.config.Port == mainServerPort { | |||
| 835 | - | log.Warn("HTTPS port for reverse proxy conflicts with main server port", | |||
| 836 | - | "port", p.config.Port, | |||
| 837 | - | "solution", "reverse proxy HTTPS server will be disabled") | |||
| 838 | - | return nil | |||
| 839 | - | } | |||
| 840 | - | ||||
| 841 | - | // Load routes from the database | |||
| 842 | - | if err := p.loadRoutes(); err != nil { | |||
| 843 | - | return fmt.Errorf("failed to load routes: %w", err) | |||
| 844 | - | } | |||
| 845 | - | ||||
| 846 | - | // Add a special route for the admin domain (Gordon itself) | |||
| 847 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 848 | - | ||||
| 849 | - | // In a container environment, we need to use the host container IP | |||
| 850 | - | // instead of 127.0.0.1 because each container has its own localhost | |||
| 851 | - | containerIP := "localhost" // Default to localhost for most reliable connectivity | |||
| 852 | - | ||||
| 853 | - | // Fall back options for container IP | |||
| 854 | - | if os.Getenv("GORDON_ADMIN_HOST") != "" { | |||
| 855 | - | // Allow explicit configuration via env var | |||
| 856 | - | containerIP = os.Getenv("GORDON_ADMIN_HOST") | |||
| 857 | - | log.Debug("Using admin host from environment variable", | |||
| 858 | - | "host", containerIP) | |||
| 859 | - | } else if os.Getenv("HOSTNAME") != "" { | |||
| 860 | - | // Use container's own hostname as they're on the same network | |||
| 861 | - | containerIP = os.Getenv("HOSTNAME") | |||
| 862 | - | log.Debug("Using container hostname for admin routing", | |||
| 863 | - | "hostname", containerIP) | |||
| 864 | - | } | |||
| 865 | - | ||||
| 866 | - | // Don't test connections yet - the server isn't running | |||
| 867 | - | // We'll add the admin route with the default host for now | |||
| 868 | - | // and test connections later with TestAdminConnectionLater | |||
| 869 | - | ||||
| 870 | - | p.mu.Lock() | |||
| 871 | - | // Only add if it doesn't already exist | |||
| 872 | - | if _, exists := p.routes[adminDomain]; !exists { | |||
| 873 | - | p.routes[adminDomain] = &ProxyRouteInfo{ | |||
| 874 | - | Domain: adminDomain, | |||
| 875 | - | ContainerIP: containerIP, | |||
| 876 | - | ContainerPort: p.app.GetConfig().Http.Port, | |||
| 877 | - | ContainerID: "gordon-server", | |||
| 878 | - | Protocol: "http", // Gordon server uses HTTP internally | |||
| 879 | - | Path: "/", | |||
| 880 | - | Active: true, | |||
| 881 | - | } | |||
| 882 | - | log.Info("Added special route for admin domain", | |||
| 883 | - | "domain", adminDomain, | |||
| 884 | - | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | |||
| 885 | - | } | |||
| 886 | - | ||||
| 887 | - | // Add support for the root domain, redirecting to admin subdomain | |||
| 888 | - | rootDomain := p.app.GetConfig().Http.Domain | |||
| 889 | - | if rootDomain != "" && rootDomain != adminDomain { | |||
| 890 | - | if _, exists := p.routes[rootDomain]; !exists { | |||
| 891 | - | p.routes[rootDomain] = &ProxyRouteInfo{ | |||
| 892 | - | Domain: rootDomain, | |||
| 893 | - | ContainerIP: containerIP, | |||
| 894 | - | ContainerPort: p.app.GetConfig().Http.Port, | |||
| 895 | - | ContainerID: "gordon-server", | |||
| 896 | - | Protocol: "http", // Gordon server uses HTTP internally | |||
| 897 | - | Path: "/", | |||
| 898 | - | Active: true, | |||
| 899 | - | } | |||
| 900 | - | log.Info("Added route for root domain with redirect to admin subdomain", | |||
| 901 | - | "root_domain", rootDomain, | |||
| 902 | - | "admin_domain", adminDomain) | |||
| 903 | - | } | |||
| 904 | - | } | |||
| 905 | - | p.mu.Unlock() | |||
| 906 | - | ||||
| 907 | - | // Configure the proxy routes | |||
| 908 | - | p.configureRoutes() | |||
| 909 | - | ||||
| 910 | - | // Configure HTTP server to handle Let's Encrypt HTTP-01 challenges | |||
| 911 | - | // and redirect everything else to HTTPS | |||
| 912 | - | p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil))) | |||
| 913 | - | ||||
| 914 | - | // Handle all other HTTP requests by redirecting to HTTPS | |||
| 915 | - | p.httpServer.Any("/*", func(c echo.Context) error { | |||
| 916 | - | // Skip handling acme challenges (already handled above) | |||
| 917 | - | if strings.HasPrefix(c.Request().URL.Path, "/.well-known/acme-challenge/") { | |||
| 918 | - | return nil | |||
| 919 | - | } | |||
| 920 | - | ||||
| 921 | - | // No need to check for blacklisting here as it's already checked in middleware | |||
| 922 | - | // and all blacklisted requests will be stopped before reaching this handler | |||
| 923 | - | ||||
| 924 | - | host := c.Request().Host | |||
| 925 | - | if strings.Contains(host, ":") { | |||
| 926 | - | host = strings.Split(host, ":")[0] | |||
| 927 | - | } | |||
| 928 | - | ||||
| 929 | - | // Special handling for root domain - redirect to admin subdomain | |||
| 930 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 931 | - | rootDomain := p.app.GetConfig().Http.Domain | |||
| 932 | - | ||||
| 933 | - | if host == rootDomain && rootDomain != adminDomain { | |||
| 934 | - | // Redirect to HTTPS admin subdomain | |||
| 935 | - | redirectURL := fmt.Sprintf("https://%s%s", adminDomain, c.Request().RequestURI) | |||
| 936 | - | log.Debug("Redirecting HTTP root domain request to HTTPS admin subdomain", | |||
| 937 | - | "from", host, | |||
| 938 | - | "to", adminDomain, | |||
| 939 | - | "redirect_url", redirectURL) | |||
| 940 | - | return c.Redirect(http.StatusPermanentRedirect, redirectURL) | |||
| 941 | - | } | |||
| 942 | - | ||||
| 943 | - | // Redirect to HTTPS | |||
| 944 | - | return c.Redirect(http.StatusMovedPermanently, | |||
| 945 | - | fmt.Sprintf("https://%s%s", host, c.Request().RequestURI)) | |||
| 946 | - | }) | |||
| 947 | - | ||||
| 948 | - | // Start the HTTPS server | |||
| 949 | - | httpsServer := &http.Server{ | |||
| 950 | - | Addr: ":" + p.config.Port, | |||
| 951 | - | Handler: p.httpsServer, | |||
| 952 | - | TLSConfig: &tls.Config{ | |||
| 953 | - | GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 954 | - | // Enhanced logging to debug SNI issues | |||
| 955 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 956 | - | ||||
| 957 | - | // If SNI is missing, use the admin domain | |||
| 958 | - | if hello.ServerName == "" { | |||
| 959 | - | log.Debug("TLS handshake without SNI, using default admin domain", | |||
| 960 | - | "default_domain", adminDomain) | |||
| 961 | - | hello.ServerName = adminDomain | |||
| 962 | - | } else { | |||
| 963 | - | log.Debug("TLS handshake with SNI", | |||
| 964 | - | "server_name", hello.ServerName) | |||
| 965 | - | } | |||
| 966 | - | ||||
| 967 | - | // Try to get the certificate from the autocert manager | |||
| 968 | - | cert, err := p.certManager.GetCertificate(hello) | |||
| 969 | - | ||||
| 970 | - | // If we can't get a certificate and we have a fallback, use it for the admin domain | |||
| 971 | - | if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil { | |||
| 972 | - | log.Debug("Using fallback certificate for admin domain", | |||
| 973 | - | "domain", adminDomain, | |||
| 974 | - | "error", err) | |||
| 975 | - | return p.fallbackCert, nil | |||
| 976 | - | } | |||
| 977 | - | ||||
| 978 | - | return cert, err | |||
| 979 | - | }, | |||
| 980 | - | MinVersion: tls.VersionTLS12, | |||
| 981 | - | // Add server name to use when client doesn't send SNI | |||
| 982 | - | ServerName: p.app.GetConfig().Http.FullDomain(), | |||
| 983 | - | // Add support for TLS-ALPN-01 challenges | |||
| 984 | - | NextProtos: []string{acme.ALPNProto}, | |||
| 985 | - | }, | |||
| 986 | - | // Add timeouts to prevent hanging connections | |||
| 987 | - | ReadTimeout: 30 * time.Second, | |||
| 988 | - | WriteTimeout: 30 * time.Second, | |||
| 989 | - | IdleTimeout: 120 * time.Second, | |||
| 990 | - | } | |||
| 991 | - | ||||
| 992 | - | // Start the HTTP server (for redirects and Let's Encrypt challenges) | |||
| 993 | - | httpServer := &http.Server{ | |||
| 994 | - | Addr: ":" + p.config.HttpPort, | |||
| 995 | - | Handler: p.httpServer, | |||
| 996 | - | ReadTimeout: 30 * time.Second, | |||
| 997 | - | WriteTimeout: 30 * time.Second, | |||
| 998 | - | IdleTimeout: 120 * time.Second, | |||
| 999 | - | } | |||
| 1000 | - | ||||
| 1001 | - | p.serverStarted = true | |||
| 1002 | - | ||||
| 1003 | - | // Start the HTTPS server | |||
| 1004 | - | go func() { | |||
| 1005 | - | log.Info("Starting HTTPS reverse proxy server", "port", p.config.Port) | |||
| 1006 | - | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | |||
| 1007 | - | log.Error("Failed to start HTTPS reverse proxy server", "err", err) | |||
| 1008 | - | } | |||
| 1009 | - | }() | |||
| 1010 | - | ||||
| 1011 | - | // Start the HTTP server (for redirects and Let's Encrypt challenges) | |||
| 1012 | - | go func() { | |||
| 1013 | - | log.Info("Starting HTTP reverse proxy server (for redirects and Let's Encrypt challenges)", "port", p.config.HttpPort) | |||
| 1014 | - | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { | |||
| 1015 | - | log.Error("Failed to start HTTP reverse proxy server", "err", err) | |||
| 1016 | - | } | |||
| 1017 | - | }() | |||
| 1018 | - | ||||
| 1019 | - | return nil | |||
| 1020 | - | } | |||
| 1021 | - | ||||
| 1022 | - | // testAdminConnection attempts to find a working connection to the Gordon admin | |||
| 1023 | - | // by testing different hostnames and IPs | |||
| 1024 | - | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |||
| 1025 | - | log.Info("Testing connections to Gordon admin server") | |||
| 1026 | - | ||||
| 1027 | - | // Create a client with a short timeout to quickly test connections | |||
| 1028 | - | client := &http.Client{ | |||
| 1029 | - | Timeout: 2 * time.Second, | |||
| 1030 | - | } | |||
| 1031 | - | ||||
| 1032 | - | // List of potential hosts to try, in order of preference | |||
| 1033 | - | hostsToTry := []string{ | |||
| 1034 | - | "localhost", | |||
| 1035 | - | "127.0.0.1", // Always try localhost first | |||
| 1036 | - | defaultHost, // The current host value | |||
| 1037 | - | "host.docker.internal", // Docker Desktop default | |||
| 1038 | - | "host.containers.internal", // Modern Docker/Podman default | |||
| 1039 | - | } | |||
| 1040 | - | ||||
| 1041 | - | // Add hostname if available | |||
| 1042 | - | if hostname := os.Getenv("HOSTNAME"); hostname != "" { | |||
| 1043 | - | hostsToTry = append(hostsToTry, hostname) | |||
| 1044 | - | } | |||
| 1045 | - | ||||
| 1046 | - | // If GORDON_ADMIN_HOST is defined, make it highest priority after localhost | |||
| 1047 | - | if adminHost := os.Getenv("GORDON_ADMIN_HOST"); adminHost != "" && adminHost != defaultHost { | |||
| 1048 | - | // Add after localhost | |||
| 1049 | - | hostsToTry = append(hostsToTry[:1], append([]string{adminHost}, hostsToTry[1:]...)...) | |||
| 1050 | - | } | |||
| 1051 | - | ||||
| 1052 | - | // Deduplicate | |||
| 1053 | - | seen := make(map[string]bool) | |||
| 1054 | - | var uniqueHosts []string | |||
| 1055 | - | for _, host := range hostsToTry { | |||
| 1056 | - | if !seen[host] && host != "" { | |||
| 1057 | - | seen[host] = true | |||
| 1058 | - | uniqueHosts = append(uniqueHosts, host) | |||
| 1059 | - | } | |||
| 1060 | - | } | |||
| 1061 | - | hostsToTry = uniqueHosts | |||
| 1062 | - | ||||
| 1063 | - | log.Debug("Will test the following hosts for admin connections", | |||
| 1064 | - | "hosts", strings.Join(hostsToTry, ", ")) | |||
| 1065 | - | ||||
| 1066 | - | // Test each host | |||
| 1067 | - | for _, host := range hostsToTry { | |||
| 1068 | - | testPath := fmt.Sprintf("http://%s:%s%s/ping", host, port, p.app.GetConfig().Admin.Path) | |||
| 1069 | - | log.Info("Testing connection to Gordon admin", "host", host, "url", testPath) | |||
| 1070 | - | ||||
| 1071 | - | startTime := time.Now() | |||
| 1072 | - | resp, err := client.Get(testPath) | |||
| 1073 | - | duration := time.Since(startTime) | |||
| 1074 | - | ||||
| 1075 | - | if err == nil { | |||
| 1076 | - | statusCode := resp.StatusCode | |||
| 1077 | - | resp.Body.Close() | |||
| 1078 | - | ||||
| 1079 | - | log.Info("Successfully connected to Gordon admin", | |||
| 1080 | - | "host", host, | |||
| 1081 | - | "status", statusCode, | |||
| 1082 | - | "duration", duration) | |||
| 1083 | - | ||||
| 1084 | - | // Consider any connection successful, even non-2xx responses | |||
| 1085 | - | // This is important since the route might be valid even if the specific endpoint | |||
| 1086 | - | // returns a different status (e.g. 404 if /ping doesn't exist) | |||
| 1087 | - | return host | |||
| 1088 | - | } else { | |||
| 1089 | - | log.Debug("Failed to connect to Gordon admin", | |||
| 1090 | - | "host", host, | |||
| 1091 | - | "error", err.Error(), | |||
| 1092 | - | "duration", duration) | |||
| 1093 | - | } | |||
| 1094 | - | } | |||
| 1095 | - | ||||
| 1096 | - | // If no connections worked, default to the original | |||
| 1097 | - | log.Warn("Could not establish connection to Gordon admin on any tested host", | |||
| 1098 | - | "fallback", defaultHost) | |||
| 1099 | - | return defaultHost | |||
| 1100 | - | } | |||
| 1101 | - | ||||
| 1102 | - | // Stop stops the proxy server | |||
| 1103 | - | func (p *Proxy) Stop() error { | |||
| 1104 | - | if !p.serverStarted { | |||
| 1105 | - | return nil | |||
| 1106 | - | } | |||
| 1107 | - | ||||
| 1108 | - | // Create a context with a timeout | |||
| 1109 | - | ctx, cancel := context.WithTimeout( | |||
| 1110 | - | context.Background(), | |||
| 1111 | - | time.Duration(p.config.GracePeriod)*time.Second, | |||
| 1112 | - | ) | |||
| 1113 | - | defer cancel() | |||
| 1114 | - | ||||
| 1115 | - | // Shutdown the servers | |||
| 1116 | - | log.Info("Stopping reverse proxy servers") | |||
| 1117 | - | ||||
| 1118 | - | // Stop HTTPS server | |||
| 1119 | - | if err := p.httpsServer.Shutdown(ctx); err != nil { | |||
| 1120 | - | log.Error("Error stopping HTTPS server", "err", err) | |||
| 1121 | - | } | |||
| 1122 | - | ||||
| 1123 | - | // Stop HTTP server | |||
| 1124 | - | if err := p.httpServer.Shutdown(ctx); err != nil { | |||
| 1125 | - | log.Error("Error stopping HTTP server", "err", err) | |||
| 1126 | - | } | |||
| 1127 | - | ||||
| 1128 | - | return nil | |||
| 1129 | - | } | |||
| 1130 | - | ||||
| 1131 | - | // loadRoutes loads the routes from the database | |||
| 1132 | - | func (p *Proxy) loadRoutes() error { | |||
| 1133 | - | // Lock the routes map | |||
| 1134 | - | p.mu.Lock() | |||
| 1135 | - | defer p.mu.Unlock() | |||
| 1136 | - | ||||
| 1137 | - | log.Debug("Loading proxy routes from database") | |||
| 1138 | - | ||||
| 1139 | - | // Query the database for active proxy routes | |||
| 1140 | - | rows, err := p.app.GetDB().Query(` | |||
| 1141 | - | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |||
| 1142 | - | FROM proxy_route pr | |||
| 1143 | - | JOIN domain d ON pr.domain_id = d.id | |||
| 1144 | - | WHERE pr.active = 1 | |||
| 1145 | - | `) | |||
| 1146 | - | if err != nil { | |||
| 1147 | - | if err == sql.ErrNoRows { | |||
| 1148 | - | log.Info("No active proxy routes found") | |||
| 1149 | - | return nil | |||
| 1150 | - | } | |||
| 1151 | - | return fmt.Errorf("failed to query database: %w", err) | |||
| 1152 | - | } | |||
| 1153 | - | defer rows.Close() | |||
| 1154 | - | ||||
| 1155 | - | // Clear the routes map | |||
| 1156 | - | p.routes = make(map[string]*ProxyRouteInfo) | |||
| 1157 | - | ||||
| 1158 | - | // Populate the routes map | |||
| 1159 | - | for rows.Next() { | |||
| 1160 | - | var id, domain, containerID, containerIP, containerPort, protocol, path string | |||
| 1161 | - | var active bool | |||
| 1162 | - | if err := rows.Scan(&id, &domain, &containerID, &containerIP, &containerPort, &protocol, &path, &active); err != nil { | |||
| 1163 | - | return fmt.Errorf("failed to scan row: %w", err) | |||
| 1164 | - | } | |||
| 1165 | - | ||||
| 1166 | - | // Add the route to the map | |||
| 1167 | - | p.routes[domain] = &ProxyRouteInfo{ | |||
| 1168 | - | Domain: domain, | |||
| 1169 | - | ContainerID: containerID, | |||
| 1170 | - | ContainerIP: containerIP, | |||
| 1171 | - | ContainerPort: containerPort, | |||
| 1172 | - | Protocol: protocol, | |||
| 1173 | - | Path: path, | |||
| 1174 | - | Active: active, | |||
| 1175 | - | } | |||
| 1176 | - | ||||
| 1177 | - | log.Debug("Loaded proxy route", | |||
| 1178 | - | "domain", domain, | |||
| 1179 | - | "containerIP", containerIP, | |||
| 1180 | - | "containerPort", containerPort, | |||
| 1181 | - | ) | |||
| 1182 | - | } | |||
| 1183 | - | ||||
| 1184 | - | log.Info("Loaded proxy routes", "count", len(p.routes)) | |||
| 1185 | - | return nil | |||
| 1186 | - | } | |||
| 1187 | - | ||||
| 1188 | - | // configureRoutes sets up the HTTP and HTTPS routes | |||
| 1189 | - | func (p *Proxy) configureRoutes() { | |||
| 1190 | - | // Lock the routes map for reading | |||
| 1191 | - | p.mu.RLock() | |||
| 1192 | - | defer p.mu.RUnlock() | |||
| 1193 | - | ||||
| 1194 | - | // Log all available routes for debugging | |||
| 1195 | - | log.Debug("Configuring proxy routes") | |||
| 1196 | - | for domain, route := range p.routes { | |||
| 1197 | - | log.Debug("Configured route", | |||
| 1198 | - | "domain", domain, | |||
| 1199 | - | "target", fmt.Sprintf("%s://%s:%s", route.Protocol, route.ContainerIP, route.ContainerPort), | |||
| 1200 | - | "active", route.Active) | |||
| 1201 | - | } | |||
| 1202 | - | ||||
| 1203 | - | // Add a handler for all incoming requests (HTTPS) | |||
| 1204 | - | p.httpsServer.Any("/*", func(c echo.Context) error { | |||
| 1205 | - | // No need to check for blacklisting here as it's already checked in middleware | |||
| 1206 | - | // and all blacklisted requests will be stopped before reaching this handler | |||
| 1207 | - | ||||
| 1208 | - | host := c.Request().Host | |||
| 1209 | - | ||||
| 1210 | - | // Strip the port from the host | |||
| 1211 | - | if strings.Contains(host, ":") { | |||
| 1212 | - | host = strings.Split(host, ":")[0] | |||
| 1213 | - | } | |||
| 1214 | - | ||||
| 1215 | - | log.Debug("Processing request", | |||
| 1216 | - | "host", host, | |||
| 1217 | - | "path", c.Request().URL.Path, | |||
| 1218 | - | "method", c.Request().Method) | |||
| 1219 | - | ||||
| 1220 | - | // Special handling for root domain - redirect to admin subdomain | |||
| 1221 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 1222 | - | rootDomain := p.app.GetConfig().Http.Domain | |||
| 1223 | - | ||||
| 1224 | - | if host == rootDomain && rootDomain != adminDomain { | |||
| 1225 | - | redirectURL := fmt.Sprintf("https://%s%s", adminDomain, c.Request().URL.Path) | |||
| 1226 | - | log.Debug("Redirecting root domain request to admin subdomain", | |||
| 1227 | - | "from", host, | |||
| 1228 | - | "to", adminDomain, | |||
| 1229 | - | "redirect_url", redirectURL) | |||
| 1230 | - | return c.Redirect(http.StatusPermanentRedirect, redirectURL) | |||
| 1231 | - | } | |||
| 1232 | - | ||||
| 1233 | - | // Find the route for this host | |||
| 1234 | - | route, ok := p.routes[host] | |||
| 1235 | - | if !ok { | |||
| 1236 | - | // Check if the host is an IP address - silently handle without logging warnings | |||
| 1237 | - | if net.ParseIP(host) != nil { | |||
| 1238 | - | // For IP-based requests, just return a 404 without logging warnings | |||
| 1239 | - | return c.String(http.StatusNotFound, "Domain not found") | |||
| 1240 | - | } | |||
| 1241 | - | ||||
| 1242 | - | // Create list of available domains for debugging | |||
| 1243 | - | availableDomains := make([]string, 0, len(p.routes)) | |||
| 1244 | - | for d := range p.routes { | |||
| 1245 | - | availableDomains = append(availableDomains, d) | |||
| 1246 | - | } | |||
| 1247 | - | ||||
| 1248 | - | // Log warning for non-IP hosts that aren't configured | |||
| 1249 | - | log.Warn("Request with unknown host", | |||
| 1250 | - | "requested_host", host, | |||
| 1251 | - | "client_ip", c.RealIP(), | |||
| 1252 | - | "available_domains", strings.Join(availableDomains, ", ")) | |||
| 1253 | - | return c.String(http.StatusNotFound, "Domain not found") | |||
| 1254 | - | } | |||
| 1255 | - | ||||
| 1256 | - | // Check if the route is active | |||
| 1257 | - | if !route.Active { | |||
| 1258 | - | log.Warn("Route is not active", | |||
| 1259 | - | "domain", host, | |||
| 1260 | - | "client_ip", c.RealIP()) | |||
| 1261 | - | return c.String(http.StatusServiceUnavailable, "Route is not active") | |||
| 1262 | - | } | |||
| 1263 | - | ||||
| 1264 | - | // Special handling for the admin domain | |||
| 1265 | - | if host == adminDomain { | |||
| 1266 | - | log.Debug("Proxying request to admin domain", | |||
| 1267 | - | "domain", host, | |||
| 1268 | - | "target", fmt.Sprintf("%s://%s:%s", route.Protocol, route.ContainerIP, route.ContainerPort), | |||
| 1269 | - | "path", c.Request().URL.Path) | |||
| 1270 | - | } | |||
| 1271 | - | ||||
| 1272 | - | // Create the target URL | |||
| 1273 | - | targetURL := &url.URL{ | |||
| 1274 | - | Scheme: route.Protocol, | |||
| 1275 | - | Host: fmt.Sprintf("%s:%s", route.ContainerIP, route.ContainerPort), | |||
| 1276 | - | } | |||
| 1277 | - | ||||
| 1278 | - | // Create a reverse proxy | |||
| 1279 | - | proxy := httputil.NewSingleHostReverseProxy(targetURL) | |||
| 1280 | - | ||||
| 1281 | - | // Update headers to allow for SSL redirection | |||
| 1282 | - | originalDirector := proxy.Director | |||
| 1283 | - | proxy.Director = func(req *http.Request) { | |||
| 1284 | - | originalDirector(req) | |||
| 1285 | - | req.Header.Set("X-Forwarded-Proto", "https") | |||
| 1286 | - | req.Header.Set("X-Forwarded-Host", host) | |||
| 1287 | - | req.Header.Set("X-Forwarded-For", c.RealIP()) | |||
| 1288 | - | req.Header.Set("X-Real-IP", c.RealIP()) | |||
| 1289 | - | ||||
| 1290 | - | // Debug information | |||
| 1291 | - | log.Debug("Proxying request", | |||
| 1292 | - | "host", host, | |||
| 1293 | - | "target", targetURL.String(), | |||
| 1294 | - | "path", req.URL.Path, | |||
| 1295 | - | "clientIP", c.RealIP()) | |||
| 1296 | - | } | |||
| 1297 | - | ||||
| 1298 | - | // Add error handling | |||
| 1299 | - | proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { | |||
| 1300 | - | log.Error("Proxy error", | |||
| 1301 | - | "host", host, | |||
| 1302 | - | "path", r.URL.Path, | |||
| 1303 | - | "error", err) | |||
| 1304 | - | c.String(http.StatusBadGateway, "Proxy Error: "+err.Error()) | |||
| 1305 | - | } | |||
| 1306 | - | ||||
| 1307 | - | // Serve the request | |||
| 1308 | - | proxy.ServeHTTP(c.Response(), c.Request()) | |||
| 1309 | - | return nil | |||
| 1310 | - | }) | |||
| 1311 | - | } | |||
| 1312 | - | ||||
| 1313 | - | // Reload reloads the routes from the database and reconfigures the proxy | |||
| 1314 | - | func (p *Proxy) Reload() error { | |||
| 1315 | - | // Load routes from the database | |||
| 1316 | - | if err := p.loadRoutes(); err != nil { | |||
| 1317 | - | return fmt.Errorf("failed to load routes: %w", err) | |||
| 1318 | - | } | |||
| 1319 | - | ||||
| 1320 | - | // Reconfigure the routes | |||
| 1321 | - | p.configureRoutes() | |||
| 1322 | - | ||||
| 1323 | - | return nil | |||
| 1324 | - | } | |||
| 1325 | - | ||||
| 1326 | - | // AddRoute adds a new route to the database and reloads the proxy | |||
| 1327 | - | func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error { | |||
| 1328 | - | // Begin a transaction | |||
| 1329 | - | tx, err := p.app.GetDB().Begin() | |||
| 1330 | - | if err != nil { | |||
| 1331 | - | return fmt.Errorf("failed to begin transaction: %w", err) | |||
| 1332 | - | } | |||
| 1333 | - | defer tx.Rollback() | |||
| 1334 | - | ||||
| 1335 | - | // Extract the hostname from domainName if it contains a protocol | |||
| 1336 | - | hostname := domainName | |||
| 1337 | - | if strings.Contains(hostname, "://") { | |||
| 1338 | - | parsedURL, err := url.Parse(hostname) | |||
| 1339 | - | if err == nil && parsedURL.Host != "" { | |||
| 1340 | - | hostname = parsedURL.Host | |||
| 1341 | - | } | |||
| 1342 | - | } | |||
| 1343 | - | ||||
| 1344 | - | // Check if the domain exists | |||
| 1345 | - | var domainID string | |||
| 1346 | - | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", hostname).Scan(&domainID) | |||
| 1347 | - | if err != nil { | |||
| 1348 | - | if err == sql.ErrNoRows { | |||
| 1349 | - | // Domain doesn't exist, create it | |||
| 1350 | - | domainID = generateUUID() | |||
| 1351 | - | now := time.Now().Format(time.RFC3339) | |||
| 1352 | - | _, err = tx.Exec( | |||
| 1353 | - | "INSERT INTO domain (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)", | |||
| 1354 | - | domainID, hostname, now, now, | |||
| 1355 | - | ) | |||
| 1356 | - | if err != nil { | |||
| 1357 | - | return fmt.Errorf("failed to insert domain: %w", err) | |||
| 1358 | - | } | |||
| 1359 | - | ||||
| 1360 | - | log.Debug("Added domain to database for Let's Encrypt", | |||
| 1361 | - | "domain", hostname) | |||
| 1362 | - | ||||
| 1363 | - | // Also add the subdomain.domain format to the database if this is a domain with components | |||
| 1364 | - | hostParts := strings.Split(hostname, ".") | |||
| 1365 | - | if len(hostParts) >= 3 { | |||
| 1366 | - | // This appears to be in format subdomain.domain.tld | |||
| 1367 | - | // Extract subdomain and domain parts | |||
| 1368 | - | subdomain := hostParts[0] | |||
| 1369 | - | domainPart := strings.Join(hostParts[1:], ".") | |||
| 1370 | - | ||||
| 1371 | - | // Check if the domain part already exists | |||
| 1372 | - | var domainPartID string | |||
| 1373 | - | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainPart).Scan(&domainPartID) | |||
| 1374 | - | if err != nil { | |||
| 1375 | - | if err == sql.ErrNoRows { | |||
| 1376 | - | // Domain part doesn't exist, create it | |||
| 1377 | - | domainPartID = generateUUID() | |||
| 1378 | - | _, err = tx.Exec( | |||
| 1379 | - | "INSERT INTO domain (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)", | |||
| 1380 | - | domainPartID, domainPart, now, now, | |||
| 1381 | - | ) | |||
| 1382 | - | if err != nil { | |||
| 1383 | - | log.Warn("Failed to insert domain part, continuing anyway", | |||
| 1384 | - | "domain_part", domainPart, | |||
| 1385 | - | "error", err) | |||
| 1386 | - | } else { | |||
| 1387 | - | log.Debug("Added domain part to database for Let's Encrypt", | |||
| 1388 | - | "domain_part", domainPart, | |||
| 1389 | - | "subdomain", subdomain) | |||
| 1390 | - | } | |||
| 1391 | - | } | |||
| 1392 | - | } | |||
| 1393 | - | } | |||
| 1394 | - | } else { | |||
| 1395 | - | return fmt.Errorf("failed to query domain: %w", err) | |||
| 1396 | - | } | |||
| 1397 | - | } | |||
| 1398 | - | ||||
| 1399 | - | // Check if a route already exists for this domain | |||
| 1400 | - | var existingRouteID string | |||
| 1401 | - | err = tx.QueryRow("SELECT id FROM proxy_route WHERE domain_id = ?", domainID).Scan(&existingRouteID) | |||
| 1402 | - | if err == nil { | |||
| 1403 | - | // Route exists, update it | |||
| 1404 | - | now := time.Now().Format(time.RFC3339) | |||
| 1405 | - | _, err = tx.Exec( | |||
| 1406 | - | `UPDATE proxy_route SET | |||
| 1407 | - | container_id = ?, | |||
| 1408 | - | container_ip = ?, | |||
| 1409 | - | container_port = ?, | |||
| 1410 | - | protocol = ?, | |||
| 1411 | - | path = ?, | |||
| 1412 | - | active = ?, | |||
| 1413 | - | updated_at = ? | |||
| 1414 | - | WHERE id = ?`, | |||
| 1415 | - | containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID, | |||
| 1416 | - | ) | |||
| 1417 | - | if err != nil { | |||
| 1418 | - | return fmt.Errorf("failed to update route: %w", err) | |||
| 1419 | - | } | |||
| 1420 | - | } else if err == sql.ErrNoRows { | |||
| 1421 | - | // Route doesn't exist, create it | |||
| 1422 | - | routeID := generateUUID() | |||
| 1423 | - | now := time.Now().Format(time.RFC3339) | |||
| 1424 | - | _, err = tx.Exec( | |||
| 1425 | - | `INSERT INTO proxy_route ( | |||
| 1426 | - | id, domain_id, container_id, container_ip, container_port, | |||
| 1427 | - | protocol, path, active, created_at, updated_at | |||
| 1428 | - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |||
| 1429 | - | routeID, domainID, containerID, containerIP, containerPort, | |||
| 1430 | - | protocol, path, true, now, now, | |||
| 1431 | - | ) | |||
| 1432 | - | if err != nil { | |||
| 1433 | - | return fmt.Errorf("failed to insert route: %w", err) | |||
| 1434 | - | } | |||
| 1435 | - | } else { | |||
| 1436 | - | return fmt.Errorf("failed to query route: %w", err) | |||
| 1437 | - | } | |||
| 1438 | - | ||||
| 1439 | - | // Commit the transaction | |||
| 1440 | - | if err := tx.Commit(); err != nil { | |||
| 1441 | - | return fmt.Errorf("failed to commit transaction: %w", err) | |||
| 1442 | - | } | |||
| 1443 | - | ||||
| 1444 | - | // Reload the routes | |||
| 1445 | - | if err := p.Reload(); err != nil { | |||
| 1446 | - | return fmt.Errorf("failed to reload routes: %w", err) | |||
| 1447 | - | } | |||
| 1448 | - | ||||
| 1449 | - | log.Info("Added proxy route", | |||
| 1450 | - | "domain", hostname, | |||
| 1451 | - | "containerIP", containerIP, | |||
| 1452 | - | "containerPort", containerPort, | |||
| 1453 | - | ) | |||
| 1454 | - | ||||
| 1455 | - | // Request a certificate if this is an HTTPS route | |||
| 1456 | - | if strings.ToLower(protocol) == "https" { | |||
| 1457 | - | log.Info("Requesting Let's Encrypt certificate for new HTTPS route", | |||
| 1458 | - | "domain", hostname) | |||
| 1459 | - | // Run in a goroutine to avoid blocking | |||
| 1460 | - | go p.requestDomainCertificate(hostname) | |||
| 1461 | - | } | |||
| 1462 | - | ||||
| 1463 | - | return nil | |||
| 1464 | - | } | |||
| 1465 | - | ||||
| 1466 | - | // RemoveRoute removes a route from the database and reloads the proxy | |||
| 1467 | - | func (p *Proxy) RemoveRoute(domainName string) error { | |||
| 1468 | - | // Begin a transaction | |||
| 1469 | - | tx, err := p.app.GetDB().Begin() | |||
| 1470 | - | if err != nil { | |||
| 1471 | - | return fmt.Errorf("failed to begin transaction: %w", err) | |||
| 1472 | - | } | |||
| 1473 | - | defer tx.Rollback() | |||
| 1474 | - | ||||
| 1475 | - | // Get the domain ID | |||
| 1476 | - | var domainID string | |||
| 1477 | - | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID) | |||
| 1478 | - | if err != nil { | |||
| 1479 | - | if err == sql.ErrNoRows { | |||
| 1480 | - | return fmt.Errorf("domain not found: %s", domainName) | |||
| 1481 | - | } | |||
| 1482 | - | return fmt.Errorf("failed to query domain: %w", err) | |||
| 1483 | - | } | |||
| 1484 | - | ||||
| 1485 | - | // Delete the route | |||
| 1486 | - | _, err = tx.Exec("DELETE FROM proxy_route WHERE domain_id = ?", domainID) | |||
| 1487 | - | if err != nil { | |||
| 1488 | - | return fmt.Errorf("failed to delete route: %w", err) | |||
| 1489 | - | } | |||
| 1490 | - | ||||
| 1491 | - | // Delete the domain | |||
| 1492 | - | _, err = tx.Exec("DELETE FROM domain WHERE id = ?", domainID) | |||
| 1493 | - | if err != nil { | |||
| 1494 | - | return fmt.Errorf("failed to delete domain: %w", err) | |||
| 1495 | - | } | |||
| 1496 | - | ||||
| 1497 | - | // Commit the transaction | |||
| 1498 | - | if err := tx.Commit(); err != nil { | |||
| 1499 | - | return fmt.Errorf("failed to commit transaction: %w", err) | |||
| 1500 | - | } | |||
| 1501 | - | ||||
| 1502 | - | // Reload the routes | |||
| 1503 | - | if err := p.Reload(); err != nil { | |||
| 1504 | - | return fmt.Errorf("failed to reload routes: %w", err) | |||
| 1505 | - | } | |||
| 1506 | - | ||||
| 1507 | - | log.Info("Removed proxy route", "domain", domainName) | |||
| 1508 | - | return nil | |||
| 1509 | - | } | |||
| 1510 | - | ||||
| 1511 | - | // generateUUID generates a UUID for use as a primary key | |||
| 1512 | - | func generateUUID() string { | |||
| 1513 | - | return fmt.Sprintf("%d", time.Now().UnixNano()) | |||
| 1514 | - | } | |||
| 1515 | - | ||||
| 1516 | - | // GetRoutes returns a copy of the routes map | |||
| 1517 | - | func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | |||
| 1518 | - | p.mu.RLock() | |||
| 1519 | - | defer p.mu.RUnlock() | |||
| 1520 | - | ||||
| 1521 | - | // Create a copy of the routes map | |||
| 1522 | - | routes := make(map[string]*ProxyRouteInfo, len(p.routes)) | |||
| 1523 | - | for k, v := range p.routes { | |||
| 1524 | - | routes[k] = v | |||
| 1525 | - | } | |||
| 1526 | - | ||||
| 1527 | - | return routes | |||
| 1528 | - | } | |||
| 1529 | - | ||||
| 1530 | - | // Add a helper method to log blocked IPs with rate limiting | |||
| 1531 | - | func (p *Proxy) logBlockedIP(clientIP, path, userAgent string) { | |||
| 1532 | - | p.blockedIPCountMu.Lock() | |||
| 1533 | - | defer p.blockedIPCountMu.Unlock() | |||
| 1534 | - | ||||
| 1535 | - | now := time.Now() | |||
| 1536 | - | ||||
| 1537 | - | // If this is the first block or it's been more than 5 minutes since the last summary, | |||
| 1538 | - | // or if this is a new IP we haven't seen recently | |||
| 1539 | - | if p.lastBlockedLog.IsZero() || | |||
| 1540 | - | now.Sub(p.lastBlockedLog) > 5*time.Minute || | |||
| 1541 | - | p.blockedIPCounter[clientIP] == 0 { | |||
| 1542 | - | ||||
| 1543 | - | // Log this block and update counters | |||
| 1544 | - | log.Info("Blocked request from blacklisted IP", | |||
| 1545 | - | "ip", clientIP, | |||
| 1546 | - | "path", path, | |||
| 1547 | - | "user_agent", userAgent) | |||
| 1548 | - | ||||
| 1549 | - | // If we're resetting due to time, clear the counters | |||
| 1550 | - | if p.lastBlockedLog.IsZero() || now.Sub(p.lastBlockedLog) > 5*time.Minute { | |||
| 1551 | - | p.blockedIPCounter = make(map[string]int) | |||
| 1552 | - | } | |||
| 1553 | - | ||||
| 1554 | - | p.blockedIPCounter[clientIP] = 1 | |||
| 1555 | - | p.lastBlockedLog = now | |||
| 1556 | - | return | |||
| 1557 | - | } | |||
| 1558 | - | ||||
| 1559 | - | // Increment counter for this IP | |||
| 1560 | - | p.blockedIPCounter[clientIP]++ | |||
| 1561 | - | ||||
| 1562 | - | // Only log summaries every 5 minutes by default | |||
| 1563 | - | if now.Sub(p.lastBlockedLog) >= 5*time.Minute { | |||
| 1564 | - | // Log summary of blocked requests | |||
| 1565 | - | totalBlocked := 0 | |||
| 1566 | - | for _, count := range p.blockedIPCounter { | |||
| 1567 | - | totalBlocked += count | |||
| 1568 | - | } | |||
| 1569 | - | ||||
| 1570 | - | log.Info("Blocked IP summary", | |||
| 1571 | - | "unique_ips", len(p.blockedIPCounter), | |||
| 1572 | - | "total_requests", totalBlocked, | |||
| 1573 | - | "since", p.lastBlockedLog.Format(time.RFC3339)) | |||
| 1574 | - | ||||
| 1575 | - | // Reset counters | |||
| 1576 | - | p.blockedIPCounter = make(map[string]int) | |||
| 1577 | - | p.lastBlockedLog = now | |||
| 1578 | - | } | |||
| 1579 | - | ||||
| 1580 | - | // Debug logging only if needed | |||
| 1581 | - | if false { | |||
| 1582 | - | log.Debug("Request marked as blacklisted for logging skip", "ip", clientIP) | |||
| 1583 | - | } | |||
| 1584 | - | } | |||
| 1585 | - | ||||
| 1586 | - | // generateFallbackCertificates creates a self-signed certificate for use when no certificate is available. | |||
| 1587 | - | // This prevents initial handshake failures while waiting for Let's Encrypt certificates. | |||
| 1588 | - | func generateFallbackCertificates(domains []string) (*tls.Certificate, error) { | |||
| 1589 | - | // Generate a private key | |||
| 1590 | - | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 1591 | - | if err != nil { | |||
| 1592 | - | return nil, err | |||
| 1593 | - | } | |||
| 1594 | - | ||||
| 1595 | - | // Create a certificate template | |||
| 1596 | - | template := x509.Certificate{ | |||
| 1597 | - | SerialNumber: big.NewInt(1), | |||
| 1598 | - | Subject: pkix.Name{ | |||
| 1599 | - | Organization: []string{"Gordon Server Temporary Certificate"}, | |||
| 1600 | - | CommonName: domains[0], | |||
| 1601 | - | }, | |||
| 1602 | - | NotBefore: time.Now(), | |||
| 1603 | - | NotAfter: time.Now().Add(24 * time.Hour), // Valid for 24 hours | |||
| 1604 | - | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, | |||
| 1605 | - | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |||
| 1606 | - | BasicConstraintsValid: true, | |||
| 1607 | - | DNSNames: domains, | |||
| 1608 | - | } | |||
| 1609 | - | ||||
| 1610 | - | // Create the certificate | |||
| 1611 | - | certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) | |||
| 1612 | - | if err != nil { | |||
| 1613 | - | return nil, err | |||
| 1614 | - | } | |||
| 1615 | - | ||||
| 1616 | - | // Convert to PEM format | |||
| 1617 | - | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |||
| 1618 | - | ||||
| 1619 | - | // Convert private key to PKCS8 format | |||
| 1620 | - | keyBytes, err := x509.MarshalPKCS8PrivateKey(key) | |||
| 1621 | - | if err != nil { | |||
| 1622 | - | return nil, err | |||
| 1623 | - | } | |||
| 1624 | - | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) | |||
| 1625 | - | ||||
| 1626 | - | // Parse the certificate | |||
| 1627 | - | cert, err := tls.X509KeyPair(certPEM, keyPEM) | |||
| 1628 | - | if err != nil { | |||
| 1629 | - | return nil, err | |||
| 1630 | - | } | |||
| 1631 | - | ||||
| 1632 | - | return &cert, nil | |||
| 1633 | - | } | |||
| 1634 | - | ||||
| 1635 | - | // TestAdminConnectionLater tests connections to the admin server after startup | |||
| 1636 | - | func (p *Proxy) TestAdminConnectionLater() { | |||
| 1637 | - | log.Debug("Starting deferred admin connection testing") | |||
| 1638 | - | ||||
| 1639 | - | // Wait a moment for the server to fully start | |||
| 1640 | - | time.Sleep(2 * time.Second) | |||
| 1641 | - | ||||
| 1642 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 1643 | - | ||||
| 1644 | - | // Get the current route | |||
| 1645 | - | p.mu.RLock() | |||
| 1646 | - | currentRoute, exists := p.routes[adminDomain] | |||
| 1647 | - | p.mu.RUnlock() | |||
| 1648 | - | ||||
| 1649 | - | if !exists { | |||
| 1650 | - | log.Warn("Admin route not found when testing connections") | |||
| 1651 | - | return | |||
| 1652 | - | } | |||
| 1653 | - | ||||
| 1654 | - | log.Debug("Testing admin connections after server startup", | |||
| 1655 | - | "domain", adminDomain, | |||
| 1656 | - | "current_ip", currentRoute.ContainerIP) | |||
| 1657 | - | ||||
| 1658 | - | // Try to detect the optimal Gordon admin host by testing connections | |||
| 1659 | - | testedIP := p.testAdminConnection(currentRoute.ContainerIP, p.app.GetConfig().Http.Port) | |||
| 1660 | - | if testedIP != "" && testedIP != currentRoute.ContainerIP { | |||
| 1661 | - | log.Info("Auto-detected working connection to Gordon admin", | |||
| 1662 | - | "host", testedIP, | |||
| 1663 | - | "original", currentRoute.ContainerIP) | |||
| 1664 | - | ||||
| 1665 | - | // Update the route with the working IP | |||
| 1666 | - | p.mu.Lock() | |||
| 1667 | - | if route, exists := p.routes[adminDomain]; exists { | |||
| 1668 | - | route.ContainerIP = testedIP | |||
| 1669 | - | log.Info("Updated admin route with working connection", | |||
| 1670 | - | "domain", adminDomain, | |||
| 1671 | - | "target", fmt.Sprintf("http://%s:%s", testedIP, p.app.GetConfig().Http.Port)) | |||
| 1672 | - | } | |||
| 1673 | - | p.mu.Unlock() | |||
| 1674 | - | } | |||
| 1675 | - | ||||
| 1676 | - | // Now that we have a working connection, check if ports 80 and 443 are accessible | |||
| 1677 | - | p.checkExternalPortAccess() | |||
| 1678 | - | } | |||
| 1679 | - | ||||
| 1680 | - | // Helper function to check if ports 80 and 443 are accessible | |||
| 1681 | - | func (p *Proxy) checkExternalPortAccess() { | |||
| 1682 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 1683 | - | if adminDomain == "" { | |||
| 1684 | - | return | |||
| 1685 | - | } | |||
| 1686 | - | ||||
| 1687 | - | // Try to connect to our own HTTP server on port 80 | |||
| 1688 | - | client := &http.Client{ | |||
| 1689 | - | Timeout: 5 * time.Second, | |||
| 1690 | - | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 1691 | - | // Don't follow redirects for this test | |||
| 1692 | - | return http.ErrUseLastResponse | |||
| 1693 | - | }, | |||
| 1694 | - | } | |||
| 1695 | - | ||||
| 1696 | - | // Testing port 80 (HTTP) | |||
| 1697 | - | httpURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test-token", adminDomain) | |||
| 1698 | - | _, httpErr := client.Get(httpURL) | |||
| 1699 | - | ||||
| 1700 | - | if httpErr != nil { | |||
| 1701 | - | log.Warn("External HTTP port 80 might not be accessible", | |||
| 1702 | - | "domain", adminDomain, | |||
| 1703 | - | "error", httpErr.Error(), | |||
| 1704 | - | "solution", "Ensure port 80 is open in your firewall and not blocked by ISP") | |||
| 1705 | - | } else { | |||
| 1706 | - | log.Info("External HTTP port 80 is accessible - good for Let's Encrypt validation", | |||
| 1707 | - | "domain", adminDomain) | |||
| 1708 | - | } | |||
| 1709 | - | ||||
| 1710 | - | // Testing port 443 (HTTPS) | |||
| 1711 | - | // Use a custom transport with InsecureSkipVerify since we might have a self-signed cert | |||
| 1712 | - | tr := &http.Transport{ | |||
| 1713 | - | TLSClientConfig: &tls.Config{ | |||
| 1714 | - | InsecureSkipVerify: true, | |||
| 1715 | - | }, | |||
| 1716 | - | } | |||
| 1717 | - | httpsClient := &http.Client{ | |||
| 1718 | - | Timeout: 5 * time.Second, | |||
| 1719 | - | Transport: tr, | |||
| 1720 | - | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 1721 | - | return http.ErrUseLastResponse | |||
| 1722 | - | }, | |||
| 1723 | - | } | |||
| 1724 | - | ||||
| 1725 | - | httpsURL := fmt.Sprintf("https://%s/", adminDomain) | |||
| 1726 | - | _, httpsErr := httpsClient.Get(httpsURL) | |||
| 1727 | - | ||||
| 1728 | - | if httpsErr != nil { | |||
| 1729 | - | log.Warn("External HTTPS port 443 might not be accessible", | |||
| 1730 | - | "domain", adminDomain, | |||
| 1731 | - | "error", httpsErr.Error(), | |||
| 1732 | - | "solution", "Ensure port 443 is open in your firewall and not blocked by ISP") | |||
| 1733 | - | } else { | |||
| 1734 | - | log.Info("External HTTPS port 443 is accessible", | |||
| 1735 | - | "domain", adminDomain) | |||
| 1736 | - | } | |||
| 1737 | - | } | |||
A internal/proxy/routes.go +1318 -0
| @@ -0,0 +1,1318 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "database/sql" | |||
| 5 | + | "fmt" | |||
| 6 | + | "net" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httputil" | |||
| 9 | + | "net/url" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/pkg/docker" | |||
| 14 | + | "github.com/charmbracelet/log" | |||
| 15 | + | "github.com/labstack/echo/v4" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // ProxyRouteInfo contains the information needed to route traffic to a container | |||
| 19 | + | type ProxyRouteInfo struct { | |||
| 20 | + | Domain string | |||
| 21 | + | ContainerIP string | |||
| 22 | + | ContainerPort string | |||
| 23 | + | ContainerID string | |||
| 24 | + | Protocol string | |||
| 25 | + | Path string | |||
| 26 | + | Active bool | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // loadRoutes loads the routes from the database | |||
| 30 | + | func (p *Proxy) loadRoutes() error { | |||
| 31 | + | // Lock the routes map | |||
| 32 | + | p.mu.Lock() | |||
| 33 | + | defer p.mu.Unlock() | |||
| 34 | + | ||||
| 35 | + | log.Debug("Loading proxy routes from database") | |||
| 36 | + | ||||
| 37 | + | // Query the database for active proxy routes | |||
| 38 | + | rows, err := p.app.GetDB().Query(` | |||
| 39 | + | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |||
| 40 | + | FROM proxy_route pr | |||
| 41 | + | JOIN domain d ON pr.domain_id = d.id | |||
| 42 | + | WHERE pr.active = 1 | |||
| 43 | + | `) | |||
| 44 | + | if err != nil { | |||
| 45 | + | if err == sql.ErrNoRows { | |||
| 46 | + | log.Info("No active proxy routes found") | |||
| 47 | + | return nil | |||
| 48 | + | } | |||
| 49 | + | return fmt.Errorf("failed to query database: %w", err) | |||
| 50 | + | } | |||
| 51 | + | defer rows.Close() | |||
| 52 | + | ||||
| 53 | + | // Clear the routes map | |||
| 54 | + | p.routes = make(map[string]*ProxyRouteInfo) | |||
| 55 | + | ||||
| 56 | + | // Populate the routes map | |||
| 57 | + | for rows.Next() { | |||
| 58 | + | var id, domain, containerID, containerIP, containerPort, protocol, path string | |||
| 59 | + | var active bool | |||
| 60 | + | if err := rows.Scan(&id, &domain, &containerID, &containerIP, &containerPort, &protocol, &path, &active); err != nil { | |||
| 61 | + | return fmt.Errorf("failed to scan row: %w", err) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Add the route to the map | |||
| 65 | + | p.routes[domain] = &ProxyRouteInfo{ | |||
| 66 | + | Domain: domain, | |||
| 67 | + | ContainerID: containerID, | |||
| 68 | + | ContainerIP: containerIP, | |||
| 69 | + | ContainerPort: containerPort, | |||
| 70 | + | Protocol: protocol, | |||
| 71 | + | Path: path, | |||
| 72 | + | Active: active, | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | log.Debug("Loaded proxy route", | |||
| 76 | + | "domain", domain, | |||
| 77 | + | "containerIP", containerIP, | |||
| 78 | + | "containerPort", containerPort, | |||
| 79 | + | ) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | log.Info("Loaded proxy routes", "count", len(p.routes)) | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // configureRoutes sets up the HTTP and HTTPS routes | |||
| 87 | + | func (p *Proxy) configureRoutes() { | |||
| 88 | + | // Get the admin domain from the config | |||
| 89 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 90 | + | log.Debug("Configuring proxy routes", "admin_domain", adminDomain) | |||
| 91 | + | ||||
| 92 | + | // Handler function - will be assigned to both HTTP and HTTPS servers | |||
| 93 | + | handler := echo.HandlerFunc(func(c echo.Context) error { | |||
| 94 | + | host := c.Request().Host | |||
| 95 | + | ||||
| 96 | + | // Important: Get the route freshly from the map for every request | |||
| 97 | + | // This ensures we always use the most up-to-date IP | |||
| 98 | + | p.mu.RLock() | |||
| 99 | + | route, ok := p.routes[host] | |||
| 100 | + | p.mu.RUnlock() | |||
| 101 | + | ||||
| 102 | + | if !ok { | |||
| 103 | + | // Check if the host is an IP address - silently handle without logging warnings | |||
| 104 | + | if net.ParseIP(host) != nil { | |||
| 105 | + | // For IP-based requests, just return a 404 without logging warnings | |||
| 106 | + | return c.String(http.StatusNotFound, "Domain not found") | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Create list of available domains for debugging | |||
| 110 | + | availableDomains := make([]string, 0, len(p.routes)) | |||
| 111 | + | p.mu.RLock() | |||
| 112 | + | for d := range p.routes { | |||
| 113 | + | availableDomains = append(availableDomains, d) | |||
| 114 | + | } | |||
| 115 | + | p.mu.RUnlock() | |||
| 116 | + | ||||
| 117 | + | // Log warning for non-IP hosts that aren't configured | |||
| 118 | + | log.Warn("Request with unknown host", | |||
| 119 | + | "requested_host", host, | |||
| 120 | + | "client_ip", c.RealIP(), | |||
| 121 | + | "available_domains", strings.Join(availableDomains, ", ")) | |||
| 122 | + | return c.String(http.StatusNotFound, "Domain not found") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // Check if the route is active | |||
| 126 | + | if !route.Active { | |||
| 127 | + | log.Warn("Route is not active", | |||
| 128 | + | "domain", host, | |||
| 129 | + | "client_ip", c.RealIP()) | |||
| 130 | + | return c.String(http.StatusServiceUnavailable, "Route is not active") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Special handling for the admin domain | |||
| 134 | + | if host == adminDomain { | |||
| 135 | + | log.Debug("Proxying request to admin domain", | |||
| 136 | + | "domain", host, | |||
| 137 | + | "target", fmt.Sprintf("%s://%s:%s", route.Protocol, route.ContainerIP, route.ContainerPort), | |||
| 138 | + | "path", c.Request().URL.Path) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // For container routes, verify the IP is reachable before proxying | |||
| 142 | + | if host != adminDomain { | |||
| 143 | + | // Test TCP connection to container before proxying | |||
| 144 | + | dialer := net.Dialer{Timeout: 500 * time.Millisecond} | |||
| 145 | + | // Format address properly for both IPv4 and IPv6 | |||
| 146 | + | target := route.ContainerIP | |||
| 147 | + | if strings.Contains(target, ":") { | |||
| 148 | + | // IPv6 address needs to be wrapped in brackets | |||
| 149 | + | target = "[" + target + "]" | |||
| 150 | + | } | |||
| 151 | + | conn, err := dialer.Dial("tcp", net.JoinHostPort(target, route.ContainerPort)) | |||
| 152 | + | if err != nil { | |||
| 153 | + | // Connection failed, check if container exists but has a new IP | |||
| 154 | + | containerID := route.ContainerID | |||
| 155 | + | if containerID != "" && containerID != "gordon-server" { | |||
| 156 | + | // Try to get container info and check if it has a different IP | |||
| 157 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |||
| 158 | + | if err == nil && containerInfo.NetworkSettings != nil { | |||
| 159 | + | // Check if container has a different IP | |||
| 160 | + | networkName := p.app.GetConfig().ContainerEngine.Network | |||
| 161 | + | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && | |||
| 162 | + | networkSettings.IPAddress != "" && networkSettings.IPAddress != route.ContainerIP { | |||
| 163 | + | // Found container with different IP, update the route and use new IP for this request | |||
| 164 | + | log.Warn("Container IP mismatch detected, using live IP instead of cached value", | |||
| 165 | + | "domain", host, | |||
| 166 | + | "cached_ip", route.ContainerIP, | |||
| 167 | + | "live_ip", networkSettings.IPAddress) | |||
| 168 | + | ||||
| 169 | + | // Update the in-memory route for this request | |||
| 170 | + | route.ContainerIP = networkSettings.IPAddress | |||
| 171 | + | ||||
| 172 | + | // Update the database in background to persist the change | |||
| 173 | + | go func(domain, containerID, containerIP, containerPort string) { | |||
| 174 | + | // Get the domain ID | |||
| 175 | + | var domainID string | |||
| 176 | + | err := p.app.GetDB().QueryRow("SELECT id FROM domain WHERE name = ?", domain).Scan(&domainID) | |||
| 177 | + | if err != nil { | |||
| 178 | + | log.Error("Failed to find domain ID for IP update", "domain", domain, "error", err) | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // Update the proxy_route record | |||
| 183 | + | now := time.Now().Format(time.RFC3339) | |||
| 184 | + | _, err = p.app.GetDB().Exec(` | |||
| 185 | + | UPDATE proxy_route SET container_ip = ?, updated_at = ? | |||
| 186 | + | WHERE domain_id = ? AND container_id = ?`, | |||
| 187 | + | containerIP, now, domainID, containerID) | |||
| 188 | + | ||||
| 189 | + | if err != nil { | |||
| 190 | + | log.Error("Failed to update container IP in database", | |||
| 191 | + | "domain", domain, | |||
| 192 | + | "containerID", containerID, | |||
| 193 | + | "error", err) | |||
| 194 | + | } else { | |||
| 195 | + | log.Info("Updated container IP in database after live detection", | |||
| 196 | + | "domain", domain, | |||
| 197 | + | "containerID", containerID, | |||
| 198 | + | "old_ip", route.ContainerIP, | |||
| 199 | + | "new_ip", containerIP) | |||
| 200 | + | } | |||
| 201 | + | }(host, containerID, networkSettings.IPAddress, route.ContainerPort) | |||
| 202 | + | } else { | |||
| 203 | + | log.Debug("Container exists but connection still failed", | |||
| 204 | + | "domain", host, | |||
| 205 | + | "container_id", containerID, | |||
| 206 | + | "container_ip", route.ContainerIP, | |||
| 207 | + | "container_port", route.ContainerPort, | |||
| 208 | + | "error", err) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } else { | |||
| 213 | + | // Connection successful, close it | |||
| 214 | + | conn.Close() | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // Create the target URL | |||
| 219 | + | targetURL := &url.URL{ | |||
| 220 | + | Scheme: route.Protocol, | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // Format host properly for both IPv4 and IPv6 | |||
| 224 | + | containerIP := route.ContainerIP | |||
| 225 | + | if strings.Contains(containerIP, ":") { | |||
| 226 | + | // IPv6 address needs to be wrapped in brackets | |||
| 227 | + | containerIP = "[" + containerIP + "]" | |||
| 228 | + | } | |||
| 229 | + | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, route.ContainerPort) | |||
| 230 | + | ||||
| 231 | + | // Create a reverse proxy | |||
| 232 | + | proxy := httputil.NewSingleHostReverseProxy(targetURL) | |||
| 233 | + | ||||
| 234 | + | // Update headers to allow for SSL redirection | |||
| 235 | + | originalDirector := proxy.Director | |||
| 236 | + | proxy.Director = func(req *http.Request) { | |||
| 237 | + | originalDirector(req) | |||
| 238 | + | req.Header.Set("X-Forwarded-Proto", "https") | |||
| 239 | + | req.Header.Set("X-Forwarded-Host", host) | |||
| 240 | + | req.Header.Set("X-Forwarded-For", c.RealIP()) | |||
| 241 | + | req.Header.Set("X-Real-IP", c.RealIP()) | |||
| 242 | + | ||||
| 243 | + | // Debug information | |||
| 244 | + | log.Debug("Proxying request", | |||
| 245 | + | "host", host, | |||
| 246 | + | "target", targetURL.String(), | |||
| 247 | + | "path", req.URL.Path, | |||
| 248 | + | "clientIP", c.RealIP()) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // Add error handling | |||
| 252 | + | proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { | |||
| 253 | + | log.Error("Proxy error", | |||
| 254 | + | "host", host, | |||
| 255 | + | "path", r.URL.Path, | |||
| 256 | + | "error", err) | |||
| 257 | + | c.String(http.StatusBadGateway, "Proxy Error: "+err.Error()) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // Serve the request | |||
| 261 | + | proxy.ServeHTTP(c.Response(), c.Request()) | |||
| 262 | + | return nil | |||
| 263 | + | }) | |||
| 264 | + | ||||
| 265 | + | // Add the handler to the HTTPS server | |||
| 266 | + | if p.httpsServer != nil { | |||
| 267 | + | p.httpsServer.Any("/*", handler) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // Also add the handler to the HTTP server for HTTP-01 challenges | |||
| 271 | + | if p.httpServer != nil { | |||
| 272 | + | p.httpServer.Any("/*", handler) | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // AddRoute adds a new route to the database and reloads the proxy | |||
| 277 | + | func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error { | |||
| 278 | + | // Begin a transaction | |||
| 279 | + | tx, err := p.app.GetDB().Begin() | |||
| 280 | + | if err != nil { | |||
| 281 | + | return fmt.Errorf("failed to begin transaction: %w", err) | |||
| 282 | + | } | |||
| 283 | + | defer tx.Rollback() | |||
| 284 | + | ||||
| 285 | + | // Extract the hostname from domainName if it contains a protocol | |||
| 286 | + | hostname := domainName | |||
| 287 | + | if strings.Contains(hostname, "://") { | |||
| 288 | + | parsedURL, err := url.Parse(hostname) | |||
| 289 | + | if err == nil && parsedURL.Host != "" { | |||
| 290 | + | hostname = parsedURL.Host | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // Get the account ID - default to the first account if available | |||
| 295 | + | var accountID string | |||
| 296 | + | err = tx.QueryRow("SELECT id FROM account LIMIT 1").Scan(&accountID) | |||
| 297 | + | if err != nil { | |||
| 298 | + | if err == sql.ErrNoRows { | |||
| 299 | + | log.Warn("No account found when adding route, domains will have NULL account_id") | |||
| 300 | + | } else { | |||
| 301 | + | log.Warn("Error fetching account for route, continuing anyway", "error", err) | |||
| 302 | + | } | |||
| 303 | + | // Continue with NULL account_id if no account found | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Check if the domain exists | |||
| 307 | + | var domainID string | |||
| 308 | + | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", hostname).Scan(&domainID) | |||
| 309 | + | if err != nil { | |||
| 310 | + | if err == sql.ErrNoRows { | |||
| 311 | + | // Domain doesn't exist, create it | |||
| 312 | + | domainID = proxyGenerateUUID() | |||
| 313 | + | now := time.Now().Format(time.RFC3339) | |||
| 314 | + | _, err = tx.Exec( | |||
| 315 | + | "INSERT INTO domain (id, name, account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", | |||
| 316 | + | domainID, hostname, accountID, now, now, | |||
| 317 | + | ) | |||
| 318 | + | if err != nil { | |||
| 319 | + | return fmt.Errorf("failed to insert domain: %w", err) | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | log.Debug("Added domain to database for Let's Encrypt", | |||
| 323 | + | "domain", hostname) | |||
| 324 | + | ||||
| 325 | + | // Also add the subdomain.domain format to the database if this is a domain with components | |||
| 326 | + | hostParts := strings.Split(hostname, ".") | |||
| 327 | + | if len(hostParts) >= 3 { | |||
| 328 | + | // This appears to be in format subdomain.domain.tld | |||
| 329 | + | // Extract subdomain and domain parts | |||
| 330 | + | subdomain := hostParts[0] | |||
| 331 | + | domainPart := strings.Join(hostParts[1:], ".") | |||
| 332 | + | ||||
| 333 | + | // Check if the domain part already exists | |||
| 334 | + | var domainPartID string | |||
| 335 | + | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainPart).Scan(&domainPartID) | |||
| 336 | + | if err != nil { | |||
| 337 | + | if err == sql.ErrNoRows { | |||
| 338 | + | // Domain part doesn't exist, create it | |||
| 339 | + | domainPartID = proxyGenerateUUID() | |||
| 340 | + | _, err = tx.Exec( | |||
| 341 | + | "INSERT INTO domain (id, name, account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", | |||
| 342 | + | domainPartID, domainPart, accountID, now, now, | |||
| 343 | + | ) | |||
| 344 | + | if err != nil { | |||
| 345 | + | log.Warn("Failed to insert domain part, continuing anyway", | |||
| 346 | + | "domain_part", domainPart, | |||
| 347 | + | "error", err) | |||
| 348 | + | } else { | |||
| 349 | + | log.Debug("Added domain part to database for Let's Encrypt", | |||
| 350 | + | "domain_part", domainPart, | |||
| 351 | + | "subdomain", subdomain) | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | } else { | |||
| 357 | + | return fmt.Errorf("failed to query domain: %w", err) | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | // Check if a route already exists for this domain | |||
| 362 | + | var existingRouteID string | |||
| 363 | + | var existingContainerID string | |||
| 364 | + | var existingContainerPort string | |||
| 365 | + | err = tx.QueryRow(` | |||
| 366 | + | SELECT id, container_id, container_port | |||
| 367 | + | FROM proxy_route | |||
| 368 | + | WHERE domain_id = ?`, domainID).Scan(&existingRouteID, &existingContainerID, &existingContainerPort) | |||
| 369 | + | ||||
| 370 | + | if err == nil { | |||
| 371 | + | // Route exists, update it | |||
| 372 | + | now := time.Now().Format(time.RFC3339) | |||
| 373 | + | ||||
| 374 | + | // Log more detailed information about the update | |||
| 375 | + | if existingContainerID != containerID { | |||
| 376 | + | log.Info("Updating proxy route for recreated container", | |||
| 377 | + | "domain", hostname, | |||
| 378 | + | "old_container_id", existingContainerID, | |||
| 379 | + | "new_container_id", containerID) | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | if existingContainerPort != containerPort { | |||
| 383 | + | log.Info("Container port changed for domain", | |||
| 384 | + | "domain", hostname, | |||
| 385 | + | "old_port", existingContainerPort, | |||
| 386 | + | "new_port", containerPort) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | _, err = tx.Exec( | |||
| 390 | + | `UPDATE proxy_route SET | |||
| 391 | + | container_id = ?, | |||
| 392 | + | container_ip = ?, | |||
| 393 | + | container_port = ?, | |||
| 394 | + | protocol = ?, | |||
| 395 | + | path = ?, | |||
| 396 | + | active = ?, | |||
| 397 | + | updated_at = ? | |||
| 398 | + | WHERE id = ?`, | |||
| 399 | + | containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID, | |||
| 400 | + | ) | |||
| 401 | + | if err != nil { | |||
| 402 | + | return fmt.Errorf("failed to update route: %w", err) | |||
| 403 | + | } | |||
| 404 | + | } else if err == sql.ErrNoRows { | |||
| 405 | + | // Route doesn't exist, create it | |||
| 406 | + | routeID := proxyGenerateUUID() | |||
| 407 | + | now := time.Now().Format(time.RFC3339) | |||
| 408 | + | _, err = tx.Exec( | |||
| 409 | + | `INSERT INTO proxy_route ( | |||
| 410 | + | id, domain_id, container_id, container_ip, container_port, | |||
| 411 | + | protocol, path, active, created_at, updated_at | |||
| 412 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |||
| 413 | + | routeID, domainID, containerID, containerIP, containerPort, | |||
| 414 | + | protocol, path, true, now, now, | |||
| 415 | + | ) | |||
| 416 | + | if err != nil { | |||
| 417 | + | return fmt.Errorf("failed to insert route: %w", err) | |||
| 418 | + | } | |||
| 419 | + | } else { | |||
| 420 | + | return fmt.Errorf("failed to query route: %w", err) | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | // Commit the transaction | |||
| 424 | + | if err := tx.Commit(); err != nil { | |||
| 425 | + | return fmt.Errorf("failed to commit transaction: %w", err) | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | // Reload the routes | |||
| 429 | + | if err := p.Reload(); err != nil { | |||
| 430 | + | return fmt.Errorf("failed to reload routes: %w", err) | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | log.Info("Added proxy route", | |||
| 434 | + | "domain", hostname, | |||
| 435 | + | "containerIP", containerIP, | |||
| 436 | + | "containerPort", containerPort, | |||
| 437 | + | ) | |||
| 438 | + | ||||
| 439 | + | // Request a certificate if this is an HTTPS route | |||
| 440 | + | if strings.ToLower(protocol) == "https" { | |||
| 441 | + | log.Info("Requesting Let's Encrypt certificate for new HTTPS route", | |||
| 442 | + | "domain", hostname) | |||
| 443 | + | // Run in a goroutine to avoid blocking | |||
| 444 | + | go func(domain string) { | |||
| 445 | + | // Set the flag to indicate we're processing a specific domain | |||
| 446 | + | p.processingSpecificDomain = true | |||
| 447 | + | // Try to request certificate for domain | |||
| 448 | + | p.requestDomainCertificate(domain) | |||
| 449 | + | // Reset the flag after we're done | |||
| 450 | + | p.processingSpecificDomain = false | |||
| 451 | + | }(hostname) | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | return nil | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | // RemoveRoute removes a route from the database and reloads the proxy | |||
| 458 | + | func (p *Proxy) RemoveRoute(domainName string) error { | |||
| 459 | + | // Begin a transaction | |||
| 460 | + | tx, err := p.app.GetDB().Begin() | |||
| 461 | + | if err != nil { | |||
| 462 | + | return fmt.Errorf("failed to begin transaction: %w", err) | |||
| 463 | + | } | |||
| 464 | + | defer tx.Rollback() | |||
| 465 | + | ||||
| 466 | + | // Get the domain ID | |||
| 467 | + | var domainID string | |||
| 468 | + | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID) | |||
| 469 | + | if err != nil { | |||
| 470 | + | if err == sql.ErrNoRows { | |||
| 471 | + | return fmt.Errorf("domain not found: %s", domainName) | |||
| 472 | + | } | |||
| 473 | + | return fmt.Errorf("failed to query domain: %w", err) | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | // Delete the route | |||
| 477 | + | _, err = tx.Exec("DELETE FROM proxy_route WHERE domain_id = ?", domainID) | |||
| 478 | + | if err != nil { | |||
| 479 | + | return fmt.Errorf("failed to delete route: %w", err) | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | // Delete the domain | |||
| 483 | + | _, err = tx.Exec("DELETE FROM domain WHERE id = ?", domainID) | |||
| 484 | + | if err != nil { | |||
| 485 | + | return fmt.Errorf("failed to delete domain: %w", err) | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | // Commit the transaction | |||
| 489 | + | if err := tx.Commit(); err != nil { | |||
| 490 | + | return fmt.Errorf("failed to commit transaction: %w", err) | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | // Reload the routes | |||
| 494 | + | if err := p.Reload(); err != nil { | |||
| 495 | + | return fmt.Errorf("failed to reload routes: %w", err) | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | log.Info("Removed proxy route", "domain", domainName) | |||
| 499 | + | return nil | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | // GetRoutes returns a copy of the routes map | |||
| 503 | + | func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | |||
| 504 | + | p.mu.RLock() | |||
| 505 | + | defer p.mu.RUnlock() | |||
| 506 | + | ||||
| 507 | + | // Create a copy of the routes map | |||
| 508 | + | routes := make(map[string]*ProxyRouteInfo, len(p.routes)) | |||
| 509 | + | for k, v := range p.routes { | |||
| 510 | + | routes[k] = v | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | return routes | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | // Reload reloads the routes from the database and reconfigures the proxy | |||
| 517 | + | func (p *Proxy) Reload() error { | |||
| 518 | + | log.Info("Reloading proxy routes from database") | |||
| 519 | + | ||||
| 520 | + | // Create a new route map instead of modifying the existing one | |||
| 521 | + | // This ensures a clean reload without any potential race conditions | |||
| 522 | + | newRoutes := make(map[string]*ProxyRouteInfo) | |||
| 523 | + | ||||
| 524 | + | // Load routes from the database into the new map | |||
| 525 | + | rows, err := p.app.GetDB().Query(` | |||
| 526 | + | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |||
| 527 | + | FROM proxy_route pr | |||
| 528 | + | JOIN domain d ON pr.domain_id = d.id | |||
| 529 | + | WHERE pr.active = 1 | |||
| 530 | + | `) | |||
| 531 | + | if err != nil { | |||
| 532 | + | if err == sql.ErrNoRows { | |||
| 533 | + | log.Info("No active proxy routes found during reload") | |||
| 534 | + | // Even if no routes found, proceed with the swap to clear any old routes | |||
| 535 | + | } else { | |||
| 536 | + | return fmt.Errorf("failed to query database during reload: %w", err) | |||
| 537 | + | } | |||
| 538 | + | } else { | |||
| 539 | + | defer rows.Close() | |||
| 540 | + | ||||
| 541 | + | // Populate the new routes map | |||
| 542 | + | for rows.Next() { | |||
| 543 | + | var id, domain, containerID, containerIP, containerPort, protocol, path string | |||
| 544 | + | var active bool | |||
| 545 | + | if err := rows.Scan(&id, &domain, &containerID, &containerIP, &containerPort, &protocol, &path, &active); err != nil { | |||
| 546 | + | return fmt.Errorf("failed to scan row during reload: %w", err) | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | // Add the route to the new map | |||
| 550 | + | newRoutes[domain] = &ProxyRouteInfo{ | |||
| 551 | + | Domain: domain, | |||
| 552 | + | ContainerID: containerID, | |||
| 553 | + | ContainerIP: containerIP, | |||
| 554 | + | ContainerPort: containerPort, | |||
| 555 | + | Protocol: protocol, | |||
| 556 | + | Path: path, | |||
| 557 | + | Active: active, | |||
| 558 | + | } | |||
| 559 | + | ||||
| 560 | + | log.Info("Loaded route during reload", | |||
| 561 | + | "domain", domain, | |||
| 562 | + | "containerID", containerID, | |||
| 563 | + | "containerIP", containerIP, | |||
| 564 | + | "containerPort", containerPort) | |||
| 565 | + | } | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | // Atomically swap the new routes map with the old one | |||
| 569 | + | // This ensures that no requests use a partially updated map | |||
| 570 | + | p.mu.Lock() | |||
| 571 | + | p.routes = newRoutes | |||
| 572 | + | p.mu.Unlock() | |||
| 573 | + | ||||
| 574 | + | // Rebuild route configuration | |||
| 575 | + | p.configureRoutes() | |||
| 576 | + | ||||
| 577 | + | // Force recreation of the route handlers if servers are initialized | |||
| 578 | + | if p.httpServer != nil { | |||
| 579 | + | log.Debug("Refreshing HTTP routes") | |||
| 580 | + | p.httpServer.Routes() | |||
| 581 | + | } | |||
| 582 | + | ||||
| 583 | + | if p.httpsServer != nil { | |||
| 584 | + | log.Debug("Refreshing HTTPS routes") | |||
| 585 | + | p.httpsServer.Routes() | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | // Add a small delay to allow any in-flight requests to complete | |||
| 589 | + | // and ensure new requests use the updated routes | |||
| 590 | + | time.Sleep(100 * time.Millisecond) | |||
| 591 | + | ||||
| 592 | + | log.Info("Successfully reloaded proxy routes", "count", len(newRoutes)) | |||
| 593 | + | return nil | |||
| 594 | + | } | |||
| 595 | + | ||||
| 596 | + | // ForceUpdateRouteIP updates a container's IP address both in the database and in-memory cache | |||
| 597 | + | // This function is useful when a container's IP has changed but the proxy is still using the old IP | |||
| 598 | + | func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error { | |||
| 599 | + | // First, check if the route exists | |||
| 600 | + | p.mu.RLock() | |||
| 601 | + | route, exists := p.routes[domain] | |||
| 602 | + | p.mu.RUnlock() | |||
| 603 | + | ||||
| 604 | + | if !exists { | |||
| 605 | + | return fmt.Errorf("route not found for domain: %s", domain) | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | // Get the container ID from the route | |||
| 609 | + | containerID := route.ContainerID | |||
| 610 | + | ||||
| 611 | + | // First, update the in-memory route immediately | |||
| 612 | + | p.mu.Lock() | |||
| 613 | + | if r, ok := p.routes[domain]; ok { | |||
| 614 | + | oldIP := r.ContainerIP | |||
| 615 | + | r.ContainerIP = newIP | |||
| 616 | + | log.Info("Force updated route IP in-memory", | |||
| 617 | + | "domain", domain, | |||
| 618 | + | "old_ip", oldIP, | |||
| 619 | + | "new_ip", newIP) | |||
| 620 | + | } | |||
| 621 | + | p.mu.Unlock() | |||
| 622 | + | ||||
| 623 | + | // Next, update the database | |||
| 624 | + | // Get the domain ID | |||
| 625 | + | var domainID string | |||
| 626 | + | err := p.app.GetDB().QueryRow("SELECT id FROM domain WHERE name = ?", domain).Scan(&domainID) | |||
| 627 | + | if err != nil { | |||
| 628 | + | return fmt.Errorf("failed to find domain ID: %w", err) | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | // Update the proxy_route record | |||
| 632 | + | now := time.Now().Format(time.RFC3339) | |||
| 633 | + | result, err := p.app.GetDB().Exec(` | |||
| 634 | + | UPDATE proxy_route SET container_ip = ?, updated_at = ? | |||
| 635 | + | WHERE domain_id = ? AND container_id = ?`, | |||
| 636 | + | newIP, now, domainID, containerID) | |||
| 637 | + | ||||
| 638 | + | if err != nil { | |||
| 639 | + | return fmt.Errorf("failed to update database: %w", err) | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | rowsAffected, _ := result.RowsAffected() | |||
| 643 | + | if rowsAffected == 0 { | |||
| 644 | + | return fmt.Errorf("no rows affected when updating route in database") | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | log.Info("Force updated route IP in database", | |||
| 648 | + | "domain", domain, | |||
| 649 | + | "container_id", containerID, | |||
| 650 | + | "new_ip", newIP) | |||
| 651 | + | ||||
| 652 | + | return nil | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | // FindRoutesByContainerName finds routes associated with a specific container name | |||
| 656 | + | func (p *Proxy) FindRoutesByContainerName(containerName string) []string { | |||
| 657 | + | // Get all containers to map IDs to names | |||
| 658 | + | containers, err := docker.ListRunningContainers() | |||
| 659 | + | if err != nil { | |||
| 660 | + | log.Error("Failed to list containers when finding routes by container name", "error", err) | |||
| 661 | + | return nil | |||
| 662 | + | } | |||
| 663 | + | ||||
| 664 | + | // Create a map of container names to IDs | |||
| 665 | + | containerNameToID := make(map[string]string) | |||
| 666 | + | for _, c := range containers { | |||
| 667 | + | for _, name := range c.Names { | |||
| 668 | + | // Container names in the Docker API start with a slash | |||
| 669 | + | cleanName := strings.TrimPrefix(name, "/") | |||
| 670 | + | containerNameToID[cleanName] = c.ID | |||
| 671 | + | } | |||
| 672 | + | } | |||
| 673 | + | ||||
| 674 | + | // This is the exact container ID we're looking for | |||
| 675 | + | targetContainerID := containerNameToID[containerName] | |||
| 676 | + | if targetContainerID == "" { | |||
| 677 | + | log.Debug("Container name not found in running containers", "container_name", containerName) | |||
| 678 | + | return nil | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | p.mu.RLock() | |||
| 682 | + | defer p.mu.RUnlock() | |||
| 683 | + | ||||
| 684 | + | // Find all domains that route to this container ID | |||
| 685 | + | domains := []string{} | |||
| 686 | + | for domain, route := range p.routes { | |||
| 687 | + | if route.ContainerID == targetContainerID { | |||
| 688 | + | domains = append(domains, domain) | |||
| 689 | + | } | |||
| 690 | + | } | |||
| 691 | + | ||||
| 692 | + | return domains | |||
| 693 | + | } | |||
| 694 | + | ||||
| 695 | + | // FindRoutesByOldName tries to find routes that might have been associated with a container | |||
| 696 | + | // that was recreated with the same name but a new ID | |||
| 697 | + | func (p *Proxy) FindRoutesByOldName(containerName string) map[string]*ProxyRouteInfo { | |||
| 698 | + | p.mu.RLock() | |||
| 699 | + | defer p.mu.RUnlock() | |||
| 700 | + | ||||
| 701 | + | result := make(map[string]*ProxyRouteInfo) | |||
| 702 | + | ||||
| 703 | + | // Query the database to find if any route might be associated with this container name | |||
| 704 | + | rows, err := p.app.GetDB().Query(` | |||
| 705 | + | SELECT d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path | |||
| 706 | + | FROM proxy_route pr | |||
| 707 | + | JOIN domain d ON pr.domain_id = d.id | |||
| 708 | + | WHERE pr.active = 1 | |||
| 709 | + | `) | |||
| 710 | + | if err != nil { | |||
| 711 | + | log.Error("Failed to query database for routes by old name", "error", err) | |||
| 712 | + | return result | |||
| 713 | + | } | |||
| 714 | + | defer rows.Close() | |||
| 715 | + | ||||
| 716 | + | // Check each route to see if it might be associated with the container name | |||
| 717 | + | for rows.Next() { | |||
| 718 | + | var domain, containerID, containerIP, containerPort, protocol, path string | |||
| 719 | + | if err := rows.Scan(&domain, &containerID, &containerIP, &containerPort, &protocol, &path); err != nil { | |||
| 720 | + | log.Error("Failed to scan row", "error", err) | |||
| 721 | + | continue | |||
| 722 | + | } | |||
| 723 | + | ||||
| 724 | + | // Try to get the container name from this ID | |||
| 725 | + | name, err := docker.GetContainerName(containerID) | |||
| 726 | + | if err != nil { | |||
| 727 | + | // Container might not exist anymore - this could be our candidate | |||
| 728 | + | // Let's check if this route appears to be orphaned (container no longer exists) | |||
| 729 | + | if !docker.ContainerExists(containerID) { | |||
| 730 | + | // This is a good candidate for our container | |||
| 731 | + | info, err := docker.GetContainerInfo(containerName) | |||
| 732 | + | if err == nil { | |||
| 733 | + | // Check if the new container has matching labels to the route | |||
| 734 | + | if domainLabel, exists := info.Config.Labels["gordon.domain"]; exists && domainLabel == domain { | |||
| 735 | + | result[domain] = &ProxyRouteInfo{ | |||
| 736 | + | Domain: domain, | |||
| 737 | + | ContainerID: containerID, // old ID | |||
| 738 | + | ContainerIP: containerIP, | |||
| 739 | + | ContainerPort: containerPort, | |||
| 740 | + | Protocol: protocol, | |||
| 741 | + | Path: path, | |||
| 742 | + | Active: true, | |||
| 743 | + | } | |||
| 744 | + | } | |||
| 745 | + | } | |||
| 746 | + | } | |||
| 747 | + | } else { | |||
| 748 | + | // We found an existing container - if it has the same name, this is interesting | |||
| 749 | + | containerName = strings.TrimPrefix(name, "/") | |||
| 750 | + | if containerName == containerName { | |||
| 751 | + | // This is an exact match - shouldn't happen if we're looking for recreated containers | |||
| 752 | + | // but include it anyway | |||
| 753 | + | result[domain] = &ProxyRouteInfo{ | |||
| 754 | + | Domain: domain, | |||
| 755 | + | ContainerID: containerID, | |||
| 756 | + | ContainerIP: containerIP, | |||
| 757 | + | ContainerPort: containerPort, | |||
| 758 | + | Protocol: protocol, | |||
| 759 | + | Path: path, | |||
| 760 | + | Active: true, | |||
| 761 | + | } | |||
| 762 | + | } | |||
| 763 | + | } | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | return result | |||
| 767 | + | } | |||
| 768 | + | ||||
| 769 | + | // HandleContainerRecreation handles a container that has been recreated with the same name but a new ID | |||
| 770 | + | // It updates all relevant routes with the new container ID and IP | |||
| 771 | + | func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerIP string) { | |||
| 772 | + | log.Info("Handling potential container recreation", | |||
| 773 | + | "container_id", containerID, | |||
| 774 | + | "container_name", containerName, | |||
| 775 | + | "container_ip", containerIP) | |||
| 776 | + | ||||
| 777 | + | // Get container info to check for gordon labels | |||
| 778 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |||
| 779 | + | if err != nil { | |||
| 780 | + | log.Warn("Failed to get container info", "error", err) | |||
| 781 | + | return | |||
| 782 | + | } | |||
| 783 | + | ||||
| 784 | + | // Check if this is a Gordon-managed container | |||
| 785 | + | if _, exists := containerInfo.Config.Labels["gordon.managed"]; !exists { | |||
| 786 | + | log.Debug("Ignoring non-Gordon container", "container_name", containerName) | |||
| 787 | + | return | |||
| 788 | + | } | |||
| 789 | + | ||||
| 790 | + | // First approach: check if there are any domains that should point to this container based on labels | |||
| 791 | + | if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" { | |||
| 792 | + | // Check if there's already a route for this domain | |||
| 793 | + | p.mu.RLock() | |||
| 794 | + | route, domainExists := p.routes[domainLabel] | |||
| 795 | + | p.mu.RUnlock() | |||
| 796 | + | ||||
| 797 | + | if domainExists { | |||
| 798 | + | // Route exists - check if it's for a different container ID | |||
| 799 | + | if route.ContainerID != containerID { | |||
| 800 | + | log.Info("Container was recreated with the same domain label", | |||
| 801 | + | "domain", domainLabel, | |||
| 802 | + | "old_container_id", route.ContainerID, | |||
| 803 | + | "new_container_id", containerID) | |||
| 804 | + | ||||
| 805 | + | // Get the port to use for the proxy from the container labels | |||
| 806 | + | containerPort := "80" // Default | |||
| 807 | + | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 808 | + | containerPort = portLabel | |||
| 809 | + | } | |||
| 810 | + | ||||
| 811 | + | // Get the protocol to use | |||
| 812 | + | protocol := "http" // Default | |||
| 813 | + | if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |||
| 814 | + | (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") { | |||
| 815 | + | protocol = "https" | |||
| 816 | + | } | |||
| 817 | + | ||||
| 818 | + | // Update the route with the new container ID and IP | |||
| 819 | + | err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, route.Path) | |||
| 820 | + | if err != nil { | |||
| 821 | + | log.Error("Failed to update route for recreated container", "error", err) | |||
| 822 | + | } else { | |||
| 823 | + | log.Info("Updated route for recreated container", | |||
| 824 | + | "domain", domainLabel, | |||
| 825 | + | "container_id", containerID, | |||
| 826 | + | "container_ip", containerIP) | |||
| 827 | + | } | |||
| 828 | + | } else { | |||
| 829 | + | // Same container ID but maybe IP changed | |||
| 830 | + | if route.ContainerIP != containerIP { | |||
| 831 | + | log.Info("Container IP changed", | |||
| 832 | + | "domain", domainLabel, | |||
| 833 | + | "container_id", containerID, | |||
| 834 | + | "old_ip", route.ContainerIP, | |||
| 835 | + | "new_ip", containerIP) | |||
| 836 | + | ||||
| 837 | + | // Update the IP | |||
| 838 | + | err = p.ForceUpdateRouteIP(domainLabel, containerIP) | |||
| 839 | + | if err != nil { | |||
| 840 | + | log.Error("Failed to update IP for container", "error", err) | |||
| 841 | + | } | |||
| 842 | + | } | |||
| 843 | + | } | |||
| 844 | + | } else { | |||
| 845 | + | // No existing route for this domain - might be a new container | |||
| 846 | + | log.Info("New container with domain label", | |||
| 847 | + | "domain", domainLabel, | |||
| 848 | + | "container_id", containerID) | |||
| 849 | + | ||||
| 850 | + | // Get the port to use for the proxy from the container labels | |||
| 851 | + | containerPort := "80" // Default | |||
| 852 | + | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 853 | + | containerPort = portLabel | |||
| 854 | + | } | |||
| 855 | + | ||||
| 856 | + | // Get the protocol to use | |||
| 857 | + | protocol := "http" // Default | |||
| 858 | + | if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |||
| 859 | + | (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") { | |||
| 860 | + | protocol = "https" | |||
| 861 | + | } | |||
| 862 | + | ||||
| 863 | + | // Add a new route | |||
| 864 | + | err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, "/") | |||
| 865 | + | if err != nil { | |||
| 866 | + | log.Error("Failed to add route for new container", "error", err) | |||
| 867 | + | } | |||
| 868 | + | } | |||
| 869 | + | } | |||
| 870 | + | ||||
| 871 | + | // Second approach: check for routes with name-based matching that might need updating | |||
| 872 | + | // This handles the case where the container name is used instead of explicit domain labels | |||
| 873 | + | if domainCheck := p.FindRoutesByOldName(containerName); len(domainCheck) > 0 { | |||
| 874 | + | log.Info("Found potential routes for recreated container by name matching", | |||
| 875 | + | "container_name", containerName, | |||
| 876 | + | "domains_count", len(domainCheck)) | |||
| 877 | + | ||||
| 878 | + | for domain, oldRoute := range domainCheck { | |||
| 879 | + | log.Info("Updating route for recreated container by name matching", | |||
| 880 | + | "domain", domain, | |||
| 881 | + | "old_container_id", oldRoute.ContainerID, | |||
| 882 | + | "new_container_id", containerID) | |||
| 883 | + | ||||
| 884 | + | // Get the port to use for the proxy from the container labels | |||
| 885 | + | containerPort := oldRoute.ContainerPort // Use the existing port by default | |||
| 886 | + | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 887 | + | // But prefer the container label if present | |||
| 888 | + | containerPort = portLabel | |||
| 889 | + | } | |||
| 890 | + | ||||
| 891 | + | // Get the protocol to use | |||
| 892 | + | protocol := oldRoute.Protocol // Use existing protocol by default | |||
| 893 | + | if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists { | |||
| 894 | + | // But prefer the container label if present | |||
| 895 | + | if sslLabel == "true" || sslLabel == "1" || sslLabel == "yes" { | |||
| 896 | + | protocol = "https" | |||
| 897 | + | } else { | |||
| 898 | + | protocol = "http" | |||
| 899 | + | } | |||
| 900 | + | } | |||
| 901 | + | ||||
| 902 | + | // Update the route | |||
| 903 | + | err = p.AddRoute(domain, containerID, containerIP, containerPort, protocol, oldRoute.Path) | |||
| 904 | + | if err != nil { | |||
| 905 | + | log.Error("Failed to update route for recreated container", "error", err) | |||
| 906 | + | } else { | |||
| 907 | + | log.Info("Updated route for recreated container", | |||
| 908 | + | "domain", domain, | |||
| 909 | + | "container_id", containerID, | |||
| 910 | + | "container_ip", containerIP) | |||
| 911 | + | } | |||
| 912 | + | } | |||
| 913 | + | } | |||
| 914 | + | } | |||
| 915 | + | ||||
| 916 | + | // StartContainerEventListener starts a goroutine that listens for container events | |||
| 917 | + | // and handles container recreation | |||
| 918 | + | func (p *Proxy) StartContainerEventListener() error { | |||
| 919 | + | // Get the network name from app config | |||
| 920 | + | networkName := p.app.GetConfig().ContainerEngine.Network | |||
| 921 | + | if networkName == "" { | |||
| 922 | + | return fmt.Errorf("container network name is not configured") | |||
| 923 | + | } | |||
| 924 | + | ||||
| 925 | + | log.Info("Starting container event listener", "network", networkName) | |||
| 926 | + | ||||
| 927 | + | // Create a callback function to handle container events | |||
| 928 | + | containerEventCallback := func(containerID, containerName, containerIP string) { | |||
| 929 | + | p.HandleContainerRecreation(containerID, containerName, containerIP) | |||
| 930 | + | } | |||
| 931 | + | ||||
| 932 | + | // Start the container event listener | |||
| 933 | + | return docker.ListenForContainerEvents(networkName, containerEventCallback) | |||
| 934 | + | } | |||
| 935 | + | ||||
| 936 | + | // StartPeriodicIPVerification starts a goroutine that periodically checks container IPs | |||
| 937 | + | // and updates the routes if they have changed. This helps ensure routes always have | |||
| 938 | + | // the correct IP address, even if the event listener missed something. | |||
| 939 | + | func (p *Proxy) StartPeriodicIPVerification(interval time.Duration) { | |||
| 940 | + | log.Info("Starting periodic IP verification", "interval", interval) | |||
| 941 | + | ||||
| 942 | + | // Run in a background goroutine | |||
| 943 | + | go func() { | |||
| 944 | + | // Use a ticker for periodic execution | |||
| 945 | + | ticker := time.NewTicker(interval) | |||
| 946 | + | defer ticker.Stop() | |||
| 947 | + | ||||
| 948 | + | for { | |||
| 949 | + | select { | |||
| 950 | + | case <-ticker.C: | |||
| 951 | + | log.Debug("Running periodic IP verification") | |||
| 952 | + | p.verifyAllContainerIPs() | |||
| 953 | + | ||||
| 954 | + | // Also check for any containers that should have routes but don't | |||
| 955 | + | p.DiscoverMissingRoutes() | |||
| 956 | + | ||||
| 957 | + | // Log current active containers and routes for monitoring | |||
| 958 | + | if log.GetLevel() <= log.DebugLevel { | |||
| 959 | + | // Only log the container list in debug mode during periodic checks | |||
| 960 | + | // to avoid overwhelming logs in production | |||
| 961 | + | p.LogActiveContainersAndRoutes() | |||
| 962 | + | } | |||
| 963 | + | } | |||
| 964 | + | } | |||
| 965 | + | }() | |||
| 966 | + | } | |||
| 967 | + | ||||
| 968 | + | // verifyAllContainerIPs checks all routes and verifies their container IPs | |||
| 969 | + | func (p *Proxy) verifyAllContainerIPs() { | |||
| 970 | + | // Get the network name from app config | |||
| 971 | + | networkName := p.app.GetConfig().ContainerEngine.Network | |||
| 972 | + | if networkName == "" { | |||
| 973 | + | log.Error("Cannot verify container IPs, network name is not configured") | |||
| 974 | + | return | |||
| 975 | + | } | |||
| 976 | + | ||||
| 977 | + | // Get network info | |||
| 978 | + | networkInfo, err := docker.GetNetworkInfo(networkName) | |||
| 979 | + | if err != nil { | |||
| 980 | + | log.Error("Failed to get network info", "error", err) | |||
| 981 | + | return | |||
| 982 | + | } | |||
| 983 | + | ||||
| 984 | + | // Get a copy of the routes | |||
| 985 | + | p.mu.RLock() | |||
| 986 | + | routes := make(map[string]*ProxyRouteInfo, len(p.routes)) | |||
| 987 | + | for k, v := range p.routes { | |||
| 988 | + | routes[k] = v | |||
| 989 | + | } | |||
| 990 | + | p.mu.RUnlock() | |||
| 991 | + | ||||
| 992 | + | // Track orphaned routes to mark as inactive | |||
| 993 | + | orphanedRoutes := []string{} | |||
| 994 | + | ||||
| 995 | + | // Check each route | |||
| 996 | + | for domain, route := range routes { | |||
| 997 | + | // Check if the container exists in the network | |||
| 998 | + | containerEndpoint, exists := networkInfo.Containers[route.ContainerID] | |||
| 999 | + | if !exists { | |||
| 1000 | + | // Container not found in the network | |||
| 1001 | + | // Could be stopped or removed | |||
| 1002 | + | ||||
| 1003 | + | // Check if container still exists | |||
| 1004 | + | if docker.ContainerExists(route.ContainerID) { | |||
| 1005 | + | // Container exists but not in our network | |||
| 1006 | + | log.Warn("Container exists but not in Gordon network", | |||
| 1007 | + | "domain", domain, | |||
| 1008 | + | "container_id", route.ContainerID) | |||
| 1009 | + | } else { | |||
| 1010 | + | // Container no longer exists | |||
| 1011 | + | log.Warn("Container no longer exists but route is active", | |||
| 1012 | + | "domain", domain, | |||
| 1013 | + | "container_id", route.ContainerID) | |||
| 1014 | + | orphanedRoutes = append(orphanedRoutes, domain) | |||
| 1015 | + | } | |||
| 1016 | + | continue | |||
| 1017 | + | } | |||
| 1018 | + | ||||
| 1019 | + | // Extract container IP from network info | |||
| 1020 | + | containerIP := containerEndpoint.IPv4Address | |||
| 1021 | + | if containerIP == "" { | |||
| 1022 | + | // Try IPv6 | |||
| 1023 | + | containerIP = containerEndpoint.IPv6Address | |||
| 1024 | + | } | |||
| 1025 | + | ||||
| 1026 | + | // Extract just the IP part without subnet | |||
| 1027 | + | if idx := strings.Index(containerIP, "/"); idx > 0 { | |||
| 1028 | + | containerIP = containerIP[:idx] | |||
| 1029 | + | } | |||
| 1030 | + | ||||
| 1031 | + | // Compare with the route's IP | |||
| 1032 | + | if containerIP != route.ContainerIP { | |||
| 1033 | + | log.Info("Container IP has changed, updating route", | |||
| 1034 | + | "domain", domain, | |||
| 1035 | + | "container_id", route.ContainerID, | |||
| 1036 | + | "old_ip", route.ContainerIP, | |||
| 1037 | + | "new_ip", containerIP) | |||
| 1038 | + | ||||
| 1039 | + | // Update the route IP | |||
| 1040 | + | err := p.ForceUpdateRouteIP(domain, containerIP) | |||
| 1041 | + | if err != nil { | |||
| 1042 | + | log.Error("Failed to update route IP", "error", err) | |||
| 1043 | + | } else { | |||
| 1044 | + | log.Debug("Successfully updated route IP", | |||
| 1045 | + | "domain", domain, | |||
| 1046 | + | "new_ip", containerIP) | |||
| 1047 | + | } | |||
| 1048 | + | } | |||
| 1049 | + | } | |||
| 1050 | + | ||||
| 1051 | + | // Mark orphaned routes as inactive | |||
| 1052 | + | if len(orphanedRoutes) > 0 { | |||
| 1053 | + | log.Info("Marking orphaned routes as inactive", "count", len(orphanedRoutes)) | |||
| 1054 | + | p.markRoutesInactive(orphanedRoutes) | |||
| 1055 | + | } | |||
| 1056 | + | ||||
| 1057 | + | log.Debug("Completed IP verification", "routes_checked", len(routes), "orphaned_routes", len(orphanedRoutes)) | |||
| 1058 | + | } | |||
| 1059 | + | ||||
| 1060 | + | // markRoutesInactive marks the specified routes as inactive in the database and memory | |||
| 1061 | + | func (p *Proxy) markRoutesInactive(domains []string) { | |||
| 1062 | + | if len(domains) == 0 { | |||
| 1063 | + | return | |||
| 1064 | + | } | |||
| 1065 | + | ||||
| 1066 | + | // Begin a transaction | |||
| 1067 | + | tx, err := p.app.GetDB().Begin() | |||
| 1068 | + | if err != nil { | |||
| 1069 | + | log.Error("Failed to begin transaction for marking routes inactive", "error", err) | |||
| 1070 | + | return | |||
| 1071 | + | } | |||
| 1072 | + | defer tx.Rollback() | |||
| 1073 | + | ||||
| 1074 | + | // Update each route in the database | |||
| 1075 | + | for _, domain := range domains { | |||
| 1076 | + | // First, get the domain ID | |||
| 1077 | + | var domainID string | |||
| 1078 | + | err := tx.QueryRow("SELECT id FROM domain WHERE name = ?", domain).Scan(&domainID) | |||
| 1079 | + | if err != nil { | |||
| 1080 | + | log.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err) | |||
| 1081 | + | continue | |||
| 1082 | + | } | |||
| 1083 | + | ||||
| 1084 | + | // Update the route to mark it as inactive | |||
| 1085 | + | now := time.Now().Format(time.RFC3339) | |||
| 1086 | + | _, err = tx.Exec("UPDATE proxy_route SET active = 0, updated_at = ? WHERE domain_id = ?", now, domainID) | |||
| 1087 | + | if err != nil { | |||
| 1088 | + | log.Error("Failed to mark route as inactive in database", "domain", domain, "error", err) | |||
| 1089 | + | continue | |||
| 1090 | + | } | |||
| 1091 | + | ||||
| 1092 | + | // Also update the in-memory route | |||
| 1093 | + | p.mu.Lock() | |||
| 1094 | + | if route, exists := p.routes[domain]; exists { | |||
| 1095 | + | route.Active = false | |||
| 1096 | + | log.Info("Marked route as inactive", "domain", domain) | |||
| 1097 | + | } | |||
| 1098 | + | p.mu.Unlock() | |||
| 1099 | + | } | |||
| 1100 | + | ||||
| 1101 | + | // Commit the transaction | |||
| 1102 | + | if err := tx.Commit(); err != nil { | |||
| 1103 | + | log.Error("Failed to commit transaction for marking routes inactive", "error", err) | |||
| 1104 | + | return | |||
| 1105 | + | } | |||
| 1106 | + | ||||
| 1107 | + | log.Info("Successfully marked orphaned routes as inactive", "count", len(domains)) | |||
| 1108 | + | } | |||
| 1109 | + | ||||
| 1110 | + | // DiscoverMissingRoutes finds Gordon-managed containers that don't have routes yet | |||
| 1111 | + | // and adds them to the routing table | |||
| 1112 | + | func (p *Proxy) DiscoverMissingRoutes() { | |||
| 1113 | + | log.Info("Discovering containers that might need routes") | |||
| 1114 | + | ||||
| 1115 | + | // Get the network name from app config | |||
| 1116 | + | networkName := p.app.GetConfig().ContainerEngine.Network | |||
| 1117 | + | if networkName == "" { | |||
| 1118 | + | log.Error("Cannot discover missing routes, network name is not configured") | |||
| 1119 | + | return | |||
| 1120 | + | } | |||
| 1121 | + | ||||
| 1122 | + | // Get all containers in the network | |||
| 1123 | + | networkInfo, err := docker.GetNetworkInfo(networkName) | |||
| 1124 | + | if err != nil { | |||
| 1125 | + | log.Error("Failed to get network info", "error", err) | |||
| 1126 | + | return | |||
| 1127 | + | } | |||
| 1128 | + | ||||
| 1129 | + | // Get a copy of the current routes for domain lookups | |||
| 1130 | + | p.mu.RLock() | |||
| 1131 | + | routes := make(map[string]*ProxyRouteInfo, len(p.routes)) | |||
| 1132 | + | for k, v := range p.routes { | |||
| 1133 | + | routes[k] = v | |||
| 1134 | + | } | |||
| 1135 | + | p.mu.RUnlock() | |||
| 1136 | + | ||||
| 1137 | + | // Create a map of container IDs that already have routes | |||
| 1138 | + | routedContainerIDs := make(map[string]bool) | |||
| 1139 | + | for _, route := range routes { | |||
| 1140 | + | routedContainerIDs[route.ContainerID] = true | |||
| 1141 | + | } | |||
| 1142 | + | ||||
| 1143 | + | // Check each container in the network | |||
| 1144 | + | for containerID, containerEndpoint := range networkInfo.Containers { | |||
| 1145 | + | // Skip if this container already has a route | |||
| 1146 | + | if routedContainerIDs[containerID] { | |||
| 1147 | + | continue | |||
| 1148 | + | } | |||
| 1149 | + | ||||
| 1150 | + | // Get container info to check for Gordon labels | |||
| 1151 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |||
| 1152 | + | if err != nil { | |||
| 1153 | + | log.Debug("Failed to get container info", | |||
| 1154 | + | "container_id", containerID, | |||
| 1155 | + | "error", err) | |||
| 1156 | + | continue | |||
| 1157 | + | } | |||
| 1158 | + | ||||
| 1159 | + | // Check if this is a Gordon-managed container | |||
| 1160 | + | if _, exists := containerInfo.Config.Labels["gordon.managed"]; !exists { | |||
| 1161 | + | continue | |||
| 1162 | + | } | |||
| 1163 | + | ||||
| 1164 | + | // Check if it has a domain label | |||
| 1165 | + | domainLabel, hasDomain := containerInfo.Config.Labels["gordon.domain"] | |||
| 1166 | + | if !hasDomain || domainLabel == "" { | |||
| 1167 | + | log.Debug("Gordon container without domain label", | |||
| 1168 | + | "container_id", containerID, | |||
| 1169 | + | "container_name", strings.TrimPrefix(containerInfo.Name, "/")) | |||
| 1170 | + | continue | |||
| 1171 | + | } | |||
| 1172 | + | ||||
| 1173 | + | // Check if this domain already has a route (different container) | |||
| 1174 | + | if _, exists := routes[domainLabel]; exists { | |||
| 1175 | + | log.Warn("Domain already has a route with a different container", | |||
| 1176 | + | "domain", domainLabel, | |||
| 1177 | + | "container_id", containerID, | |||
| 1178 | + | "existing_container_id", routes[domainLabel].ContainerID) | |||
| 1179 | + | continue | |||
| 1180 | + | } | |||
| 1181 | + | ||||
| 1182 | + | // Extract container IP and port for the new route | |||
| 1183 | + | containerIP := containerEndpoint.IPv4Address | |||
| 1184 | + | if containerIP == "" { | |||
| 1185 | + | containerIP = containerEndpoint.IPv6Address | |||
| 1186 | + | } | |||
| 1187 | + | ||||
| 1188 | + | // Extract just the IP part without subnet | |||
| 1189 | + | if idx := strings.Index(containerIP, "/"); idx > 0 { | |||
| 1190 | + | containerIP = containerIP[:idx] | |||
| 1191 | + | } | |||
| 1192 | + | ||||
| 1193 | + | // Get the port to use for the proxy from the container labels | |||
| 1194 | + | containerPort := "80" // Default | |||
| 1195 | + | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 1196 | + | containerPort = portLabel | |||
| 1197 | + | } | |||
| 1198 | + | ||||
| 1199 | + | // Get the protocol to use | |||
| 1200 | + | protocol := "http" // Default | |||
| 1201 | + | if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |||
| 1202 | + | (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") { | |||
| 1203 | + | protocol = "https" | |||
| 1204 | + | } | |||
| 1205 | + | ||||
| 1206 | + | // Add a new route for this container | |||
| 1207 | + | log.Info("Adding missing route for Gordon container", | |||
| 1208 | + | "domain", domainLabel, | |||
| 1209 | + | "container_id", containerID, | |||
| 1210 | + | "container_ip", containerIP, | |||
| 1211 | + | "container_port", containerPort) | |||
| 1212 | + | ||||
| 1213 | + | err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, "/") | |||
| 1214 | + | if err != nil { | |||
| 1215 | + | log.Error("Failed to add missing route", "error", err) | |||
| 1216 | + | } | |||
| 1217 | + | } | |||
| 1218 | + | } | |||
| 1219 | + | ||||
| 1220 | + | // LogActiveContainersAndRoutes prints a summary of all active containers and their routes | |||
| 1221 | + | // to the logs for debugging and monitoring purposes | |||
| 1222 | + | func (p *Proxy) LogActiveContainersAndRoutes() { | |||
| 1223 | + | // Get the network name from app config | |||
| 1224 | + | networkName := p.app.GetConfig().ContainerEngine.Network | |||
| 1225 | + | if networkName == "" { | |||
| 1226 | + | log.Error("Cannot list containers, network name is not configured") | |||
| 1227 | + | return | |||
| 1228 | + | } | |||
| 1229 | + | ||||
| 1230 | + | // Get all containers in the network | |||
| 1231 | + | networkInfo, err := docker.GetNetworkInfo(networkName) | |||
| 1232 | + | if err != nil { | |||
| 1233 | + | log.Error("Failed to get network info for logging active containers", "error", err) | |||
| 1234 | + | return | |||
| 1235 | + | } | |||
| 1236 | + | ||||
| 1237 | + | // Get all active routes | |||
| 1238 | + | p.mu.RLock() | |||
| 1239 | + | routes := make(map[string]*ProxyRouteInfo, len(p.routes)) | |||
| 1240 | + | for k, v := range p.routes { | |||
| 1241 | + | if v.Active { | |||
| 1242 | + | routes[k] = v | |||
| 1243 | + | } | |||
| 1244 | + | } | |||
| 1245 | + | p.mu.RUnlock() | |||
| 1246 | + | ||||
| 1247 | + | // Map container IDs to names for more readable output | |||
| 1248 | + | containerNames := make(map[string]string) | |||
| 1249 | + | for containerID := range networkInfo.Containers { | |||
| 1250 | + | name, err := docker.GetContainerName(containerID) | |||
| 1251 | + | if err == nil { | |||
| 1252 | + | containerNames[containerID] = strings.TrimPrefix(name, "/") | |||
| 1253 | + | } else { | |||
| 1254 | + | containerNames[containerID] = containerID[:12] + "..." // Shortened ID if name not available | |||
| 1255 | + | } | |||
| 1256 | + | } | |||
| 1257 | + | ||||
| 1258 | + | // Format and log the active containers and routes | |||
| 1259 | + | log.Info("---- Listing active containers and routes ----") | |||
| 1260 | + | ||||
| 1261 | + | // Create a nice table-like format for the logs | |||
| 1262 | + | routeCount := len(routes) | |||
| 1263 | + | if routeCount == 0 { | |||
| 1264 | + | log.Info("No active routes found") | |||
| 1265 | + | } else { | |||
| 1266 | + | // Log header | |||
| 1267 | + | log.Info(fmt.Sprintf("Found %d active routes", routeCount)) | |||
| 1268 | + | ||||
| 1269 | + | // Group routes by container for better readability | |||
| 1270 | + | containerRoutes := make(map[string][]string) | |||
| 1271 | + | for domain, route := range routes { | |||
| 1272 | + | containerRoutes[route.ContainerID] = append(containerRoutes[route.ContainerID], domain) | |||
| 1273 | + | } | |||
| 1274 | + | ||||
| 1275 | + | // Display each container and its routes | |||
| 1276 | + | for containerID, domains := range containerRoutes { | |||
| 1277 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |||
| 1278 | + | if err != nil { | |||
| 1279 | + | log.Debug("Failed to get container info", "container_id", containerID) | |||
| 1280 | + | continue | |||
| 1281 | + | } | |||
| 1282 | + | ||||
| 1283 | + | containerName := containerNames[containerID] | |||
| 1284 | + | if containerName == "" { | |||
| 1285 | + | containerName = containerID[:12] + "..." // Shortened ID if name not available | |||
| 1286 | + | } | |||
| 1287 | + | ||||
| 1288 | + | // Get container IP from network info | |||
| 1289 | + | containerIP := "" | |||
| 1290 | + | if endpoint, exists := networkInfo.Containers[containerID]; exists { | |||
| 1291 | + | containerIP = endpoint.IPv4Address | |||
| 1292 | + | if containerIP == "" { | |||
| 1293 | + | containerIP = endpoint.IPv6Address | |||
| 1294 | + | } | |||
| 1295 | + | ||||
| 1296 | + | // Extract just the IP part without subnet | |||
| 1297 | + | if idx := strings.Index(containerIP, "/"); idx > 0 { | |||
| 1298 | + | containerIP = containerIP[:idx] | |||
| 1299 | + | } | |||
| 1300 | + | } | |||
| 1301 | + | ||||
| 1302 | + | // Log container info | |||
| 1303 | + | log.Info(fmt.Sprintf("Container: %s (%s)", containerName, containerID[:12]), | |||
| 1304 | + | "ip", containerIP, | |||
| 1305 | + | "image", containerInfo.Config.Image) | |||
| 1306 | + | ||||
| 1307 | + | // Log each domain for this container | |||
| 1308 | + | for _, domain := range domains { | |||
| 1309 | + | route := routes[domain] | |||
| 1310 | + | log.Info(fmt.Sprintf(" ├─ Domain: %s", domain), | |||
| 1311 | + | "protocol", route.Protocol, | |||
| 1312 | + | "port", route.ContainerPort) | |||
| 1313 | + | } | |||
| 1314 | + | } | |||
| 1315 | + | } | |||
| 1316 | + | ||||
| 1317 | + | log.Info("---- End of active containers and routes list ----") | |||
| 1318 | + | } | |||
A internal/proxy/server.go +386 -0
| @@ -0,0 +1,386 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "os" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/pkg/docker" | |||
| 12 | + | "github.com/charmbracelet/log" | |||
| 13 | + | "github.com/labstack/echo/v4" | |||
| 14 | + | "golang.org/x/crypto/acme" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // This file contains server operations functions for the proxy. | |||
| 18 | + | // These methods may need to be deleted from proxy.go after validation. | |||
| 19 | + | ||||
| 20 | + | // Start loads the routes from the database and starts the proxy server | |||
| 21 | + | func (p *Proxy) Start() error { | |||
| 22 | + | // Scan Gordon network containers and check for certificates | |||
| 23 | + | if err := p.scanContainersAndCheckCertificates(); err != nil { | |||
| 24 | + | log.Warn("Failed to scan containers for certificate checks", "error", err) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // First, load routes from the database | |||
| 28 | + | if err := p.loadRoutes(); err != nil { | |||
| 29 | + | return fmt.Errorf("failed to load routes: %w", err) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Run an immediate route verification to ensure all routes are valid on startup | |||
| 33 | + | log.Info("Performing initial route verification on startup") | |||
| 34 | + | p.verifyAllContainerIPs() | |||
| 35 | + | ||||
| 36 | + | // Discover any containers that should have routes but don't | |||
| 37 | + | p.DiscoverMissingRoutes() | |||
| 38 | + | ||||
| 39 | + | // Log all active containers and their routes for visibility | |||
| 40 | + | p.LogActiveContainersAndRoutes() | |||
| 41 | + | ||||
| 42 | + | // Start the container event listener for real-time updates | |||
| 43 | + | if err := p.StartContainerEventListener(); err != nil { | |||
| 44 | + | log.Warn("Failed to start container event listener", "error", err) | |||
| 45 | + | // Continue anyway, this is non-critical | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Start periodic IP verification (every 5 minutes) | |||
| 49 | + | // This ensures we catch any IP changes that the event listener might miss | |||
| 50 | + | p.StartPeriodicIPVerification(5 * time.Minute) | |||
| 51 | + | ||||
| 52 | + | // Check if there might be port conflicts with the main server | |||
| 53 | + | mainServerPort := p.app.GetConfig().Http.Port | |||
| 54 | + | ||||
| 55 | + | // Check HTTP port conflict | |||
| 56 | + | if p.config.HttpPort == mainServerPort { | |||
| 57 | + | log.Warn("HTTP port for reverse proxy conflicts with main server port", | |||
| 58 | + | "port", p.config.HttpPort, | |||
| 59 | + | "solution", "reverse proxy HTTP server will be disabled") | |||
| 60 | + | return nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Check HTTPS port conflict (less common, but still possible) | |||
| 64 | + | if p.config.Port == mainServerPort { | |||
| 65 | + | log.Warn("HTTPS port for reverse proxy conflicts with main server port", | |||
| 66 | + | "port", p.config.Port, | |||
| 67 | + | "solution", "reverse proxy HTTPS server will be disabled") | |||
| 68 | + | return nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Add a special route for the admin domain (Gordon itself) | |||
| 72 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 73 | + | ||||
| 74 | + | // In a container environment, we need to use the host container IP | |||
| 75 | + | // instead of 127.0.0.1 because each container has its own localhost | |||
| 76 | + | containerIP := "localhost" // Default to localhost for most reliable connectivity | |||
| 77 | + | ||||
| 78 | + | // Fall back options for container IP | |||
| 79 | + | if os.Getenv("GORDON_ADMIN_HOST") != "" { | |||
| 80 | + | // Allow explicit configuration via env var | |||
| 81 | + | containerIP = os.Getenv("GORDON_ADMIN_HOST") | |||
| 82 | + | log.Debug("Using admin host from environment variable", | |||
| 83 | + | "host", containerIP) | |||
| 84 | + | } else if os.Getenv("HOSTNAME") != "" { | |||
| 85 | + | // Use container's own hostname as they're on the same network | |||
| 86 | + | containerIP = os.Getenv("HOSTNAME") | |||
| 87 | + | log.Debug("Using container hostname for admin routing", | |||
| 88 | + | "hostname", containerIP) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Don't test connections yet - the server isn't running | |||
| 92 | + | // We'll add the admin route with the default host for now | |||
| 93 | + | // and test connections later with TestAdminConnectionLater | |||
| 94 | + | ||||
| 95 | + | p.mu.Lock() | |||
| 96 | + | // Only add if it doesn't already exist | |||
| 97 | + | if _, exists := p.routes[adminDomain]; !exists { | |||
| 98 | + | p.routes[adminDomain] = &ProxyRouteInfo{ | |||
| 99 | + | Domain: adminDomain, | |||
| 100 | + | ContainerIP: containerIP, | |||
| 101 | + | ContainerPort: p.app.GetConfig().Http.Port, | |||
| 102 | + | ContainerID: "gordon-server", | |||
| 103 | + | Protocol: "http", // Gordon server uses HTTP internally | |||
| 104 | + | Path: "/", | |||
| 105 | + | Active: true, | |||
| 106 | + | } | |||
| 107 | + | log.Info("Added special route for admin domain", | |||
| 108 | + | "domain", adminDomain, | |||
| 109 | + | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Add support for the root domain, redirecting to admin subdomain | |||
| 113 | + | rootDomain := p.app.GetConfig().Http.Domain | |||
| 114 | + | if rootDomain != "" && rootDomain != adminDomain { | |||
| 115 | + | if _, exists := p.routes[rootDomain]; !exists { | |||
| 116 | + | p.routes[rootDomain] = &ProxyRouteInfo{ | |||
| 117 | + | Domain: rootDomain, | |||
| 118 | + | ContainerIP: containerIP, | |||
| 119 | + | ContainerPort: p.app.GetConfig().Http.Port, | |||
| 120 | + | ContainerID: "gordon-server", | |||
| 121 | + | Protocol: "http", // Gordon server uses HTTP internally | |||
| 122 | + | Path: "/", | |||
| 123 | + | Active: true, | |||
| 124 | + | } | |||
| 125 | + | log.Info("Added route for root domain with redirect to admin subdomain", | |||
| 126 | + | "root_domain", rootDomain, | |||
| 127 | + | "admin_domain", adminDomain) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | p.mu.Unlock() | |||
| 131 | + | ||||
| 132 | + | // Set up middleware | |||
| 133 | + | p.setupMiddleware() | |||
| 134 | + | ||||
| 135 | + | // Configure routes | |||
| 136 | + | p.configureRoutes() | |||
| 137 | + | ||||
| 138 | + | // Configure HTTP server to handle Let's Encrypt HTTP-01 challenges | |||
| 139 | + | // and redirect everything else to HTTPS | |||
| 140 | + | p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil))) | |||
| 141 | + | ||||
| 142 | + | // Create a custom HTTPS server with proper TLS config | |||
| 143 | + | httpsServer := &http.Server{ | |||
| 144 | + | Addr: fmt.Sprintf(":%s", p.config.Port), | |||
| 145 | + | Handler: p.httpsServer, | |||
| 146 | + | TLSConfig: &tls.Config{ | |||
| 147 | + | GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |||
| 148 | + | // Log SNI information in debug mode | |||
| 149 | + | if hello.ServerName != "" { | |||
| 150 | + | log.Debug("TLS handshake with SNI", | |||
| 151 | + | "server_name", hello.ServerName) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // Try to get the certificate from the autocert manager | |||
| 155 | + | cert, err := p.certManager.GetCertificate(hello) | |||
| 156 | + | ||||
| 157 | + | // If we can't get a certificate and we have a fallback, use it for the admin domain | |||
| 158 | + | if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil { | |||
| 159 | + | log.Debug("Using fallback certificate for admin domain", | |||
| 160 | + | "domain", adminDomain, | |||
| 161 | + | "error", err) | |||
| 162 | + | return p.fallbackCert, nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | return cert, err | |||
| 166 | + | }, | |||
| 167 | + | MinVersion: tls.VersionTLS12, | |||
| 168 | + | // Add server name to use when client doesn't send SNI | |||
| 169 | + | ServerName: p.app.GetConfig().Http.FullDomain(), | |||
| 170 | + | // Add support for TLS-ALPN-01 challenges | |||
| 171 | + | NextProtos: []string{acme.ALPNProto}, | |||
| 172 | + | }, | |||
| 173 | + | // Add timeouts to prevent hanging connections | |||
| 174 | + | ReadTimeout: 30 * time.Second, | |||
| 175 | + | WriteTimeout: 30 * time.Second, | |||
| 176 | + | IdleTimeout: 120 * time.Second, | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // Create a custom HTTP server | |||
| 180 | + | httpServer := &http.Server{ | |||
| 181 | + | Addr: fmt.Sprintf(":%s", p.config.HttpPort), | |||
| 182 | + | Handler: p.httpServer, | |||
| 183 | + | ReadTimeout: 30 * time.Second, | |||
| 184 | + | WriteTimeout: 30 * time.Second, | |||
| 185 | + | IdleTimeout: 120 * time.Second, | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | p.serverStarted = true | |||
| 189 | + | ||||
| 190 | + | // Start HTTPS server | |||
| 191 | + | go func() { | |||
| 192 | + | log.Info("Starting HTTPS server", "address", httpsServer.Addr) | |||
| 193 | + | // Using empty strings for cert and key files since we're using GetCertificate | |||
| 194 | + | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | |||
| 195 | + | log.Error("HTTPS server failed", "error", err) | |||
| 196 | + | } | |||
| 197 | + | }() | |||
| 198 | + | ||||
| 199 | + | // Start HTTP server | |||
| 200 | + | go func() { | |||
| 201 | + | log.Info("Starting HTTP server", "address", httpServer.Addr) | |||
| 202 | + | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { | |||
| 203 | + | log.Error("HTTP server failed", "error", err) | |||
| 204 | + | } | |||
| 205 | + | }() | |||
| 206 | + | ||||
| 207 | + | // Test admin connection after server starts | |||
| 208 | + | go p.TestAdminConnectionLater() | |||
| 209 | + | ||||
| 210 | + | return nil | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // Stop gracefully shuts down the proxy server | |||
| 214 | + | func (p *Proxy) Stop() error { | |||
| 215 | + | log.Info("Stopping reverse proxy") | |||
| 216 | + | ||||
| 217 | + | // Stop the container event listener | |||
| 218 | + | docker.StopContainerEventListener() | |||
| 219 | + | ||||
| 220 | + | // Skip the rest if the server was never started | |||
| 221 | + | if !p.serverStarted { | |||
| 222 | + | log.Debug("Proxy server was never started, nothing to stop") | |||
| 223 | + | return nil | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // Create a context with timeout for shutdown | |||
| 227 | + | timeout := p.config.GracePeriod | |||
| 228 | + | if timeout <= 0 { | |||
| 229 | + | timeout = 10 // Default to 10 seconds if not set | |||
| 230 | + | } | |||
| 231 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) | |||
| 232 | + | defer cancel() | |||
| 233 | + | ||||
| 234 | + | // Shutdown HTTP server if it's running | |||
| 235 | + | if p.httpServer != nil { | |||
| 236 | + | if err := p.httpServer.Shutdown(ctx); err != nil { | |||
| 237 | + | log.Error("Failed to gracefully shutdown HTTP server", "error", err) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // Shutdown HTTPS server if it's running | |||
| 242 | + | if p.httpsServer != nil { | |||
| 243 | + | if err := p.httpsServer.Shutdown(ctx); err != nil { | |||
| 244 | + | log.Error("Failed to gracefully shutdown HTTPS server", "error", err) | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | log.Info("Reverse proxy stopped") | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // testAdminConnection attempts to find a working connection to the Gordon admin | |||
| 253 | + | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |||
| 254 | + | adminPath := p.app.GetConfig().Admin.Path | |||
| 255 | + | url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, adminPath) | |||
| 256 | + | ||||
| 257 | + | log.Debug("Testing admin connection", "url", url) | |||
| 258 | + | ||||
| 259 | + | // Try connecting using the provided default host | |||
| 260 | + | client := &http.Client{ | |||
| 261 | + | // Use a simple TLS config instead of getting it from the app | |||
| 262 | + | Transport: &http.Transport{ | |||
| 263 | + | TLSClientConfig: &tls.Config{ | |||
| 264 | + | InsecureSkipVerify: true, | |||
| 265 | + | }, | |||
| 266 | + | }, | |||
| 267 | + | Timeout: 5 * time.Second, | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | resp, err := client.Get(url) | |||
| 271 | + | if err == nil { | |||
| 272 | + | resp.Body.Close() | |||
| 273 | + | log.Info("Admin connection successful", "host", defaultHost) | |||
| 274 | + | return defaultHost | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // If connection failed, try other common hostnames | |||
| 278 | + | hosts := []string{ | |||
| 279 | + | "localhost", | |||
| 280 | + | "127.0.0.1", | |||
| 281 | + | "host.docker.internal", | |||
| 282 | + | "host.containers.internal", | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | for _, host := range hosts { | |||
| 286 | + | if host == defaultHost { | |||
| 287 | + | continue // Already tried this one | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | url = fmt.Sprintf("http://%s:%s%s", host, port, adminPath) | |||
| 291 | + | log.Debug("Trying alternative host", "url", url) | |||
| 292 | + | ||||
| 293 | + | resp, err := client.Get(url) | |||
| 294 | + | if err == nil { | |||
| 295 | + | resp.Body.Close() | |||
| 296 | + | log.Info("Found working admin connection", "host", host) | |||
| 297 | + | return host | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | log.Warn("Could not establish admin connection on any tested host", "fallback", defaultHost) | |||
| 302 | + | return defaultHost | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // TestAdminConnectionLater tests the admin connection after a short delay | |||
| 306 | + | func (p *Proxy) TestAdminConnectionLater() { | |||
| 307 | + | // Wait for servers to start | |||
| 308 | + | time.Sleep(2 * time.Second) | |||
| 309 | + | ||||
| 310 | + | log.Debug("Testing admin connections after server startup") | |||
| 311 | + | ||||
| 312 | + | // Get domain and current route | |||
| 313 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 314 | + | ||||
| 315 | + | p.mu.RLock() | |||
| 316 | + | route, exists := p.routes[adminDomain] | |||
| 317 | + | p.mu.RUnlock() | |||
| 318 | + | ||||
| 319 | + | if !exists { | |||
| 320 | + | log.Warn("Admin route not found") | |||
| 321 | + | return | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // Test connection and update if needed | |||
| 325 | + | testedIP := p.testAdminConnection(route.ContainerIP, p.app.GetConfig().Http.Port) | |||
| 326 | + | if testedIP != route.ContainerIP { | |||
| 327 | + | p.mu.Lock() | |||
| 328 | + | if r, ok := p.routes[adminDomain]; ok { | |||
| 329 | + | r.ContainerIP = testedIP | |||
| 330 | + | log.Info("Updated admin route with working connection", | |||
| 331 | + | "domain", adminDomain, | |||
| 332 | + | "ip", testedIP) | |||
| 333 | + | } | |||
| 334 | + | p.mu.Unlock() | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | // Check external port access | |||
| 338 | + | p.checkExternalPortAccess() | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // checkExternalPortAccess verifies if ports 80 and 443 are accessible | |||
| 342 | + | func (p *Proxy) checkExternalPortAccess() { | |||
| 343 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 344 | + | if adminDomain == "" { | |||
| 345 | + | return | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | // Test HTTP port 80 | |||
| 349 | + | client := &http.Client{ | |||
| 350 | + | Timeout: 5 * time.Second, | |||
| 351 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 352 | + | return http.ErrUseLastResponse // Don't follow redirects | |||
| 353 | + | }, | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | httpURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test", adminDomain) | |||
| 357 | + | _, httpErr := client.Get(httpURL) | |||
| 358 | + | if httpErr != nil { | |||
| 359 | + | log.Warn("External HTTP port 80 might not be accessible", | |||
| 360 | + | "error", httpErr.Error()) | |||
| 361 | + | } else { | |||
| 362 | + | log.Info("External HTTP port 80 is accessible") | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | // Test HTTPS port 443 | |||
| 366 | + | httpsClient := &http.Client{ | |||
| 367 | + | Timeout: 5 * time.Second, | |||
| 368 | + | Transport: &http.Transport{ | |||
| 369 | + | TLSClientConfig: &tls.Config{ | |||
| 370 | + | InsecureSkipVerify: true, | |||
| 371 | + | }, | |||
| 372 | + | }, | |||
| 373 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 374 | + | return http.ErrUseLastResponse | |||
| 375 | + | }, | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | httpsURL := fmt.Sprintf("https://%s/", adminDomain) | |||
| 379 | + | _, httpsErr := httpsClient.Get(httpsURL) | |||
| 380 | + | if httpsErr != nil { | |||
| 381 | + | log.Warn("External HTTPS port 443 might not be accessible", | |||
| 382 | + | "error", httpsErr.Error()) | |||
| 383 | + | } else { | |||
| 384 | + | log.Info("External HTTPS port 443 is accessible") | |||
| 385 | + | } | |||
| 386 | + | } | |||
A internal/proxy/utils.go +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "fmt" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/google/uuid" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // proxyGenerateUUID generates a random UUID for use in various identifiers | |||
| 12 | + | // This is a renamed version to avoid conflicts until we clean up proxy.go | |||
| 13 | + | func proxyGenerateUUID() string { | |||
| 14 | + | id, err := uuid.NewRandom() | |||
| 15 | + | if err != nil { | |||
| 16 | + | // If we can't generate a UUID using the standard library, | |||
| 17 | + | // fall back to our own implementation | |||
| 18 | + | b := make([]byte, 16) | |||
| 19 | + | _, err := rand.Read(b) | |||
| 20 | + | if err != nil { | |||
| 21 | + | return fmt.Sprintf("%d", time.Now().UnixNano()) | |||
| 22 | + | } | |||
| 23 | + | return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) | |||
| 24 | + | } | |||
| 25 | + | return id.String() | |||
| 26 | + | } | |||
M pkg/docker/containers.go +185 -1
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/charmbracelet/log" | 11 | "github.com/charmbracelet/log" | ||
| 12 | "github.com/docker/docker/api/types" | 12 | "github.com/docker/docker/api/types" | ||
| 13 | "github.com/docker/docker/api/types/container" | 13 | "github.com/docker/docker/api/types/container" | ||
| 14 | + | "github.com/docker/docker/api/types/filters" | |||
| 14 | "github.com/docker/docker/api/types/network" | 15 | "github.com/docker/docker/api/types/network" | ||
| 15 | "github.com/docker/docker/client" | 16 | "github.com/docker/docker/client" | ||
| 16 | "github.com/docker/go-connections/nat" | 17 | "github.com/docker/go-connections/nat" | ||
| @@ -246,7 +247,45 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | |||||
| 246 | return "", fmt.Errorf("failed to check if docker client is initialized: %w", err) | 247 | return "", fmt.Errorf("failed to check if docker client is initialized: %w", err) | ||
| 247 | } | 248 | } | ||
| 248 | 249 | ||||
| 249 | - | log.Info("Creating container with params: %+v\n", cmdParams) | 250 | + | log.Info("Creating container with params", |
| 251 | + | "name", cmdParams.ContainerName, | |||
| 252 | + | "image", cmdParams.ImageName, | |||
| 253 | + | "proxy_port", cmdParams.ProxyPort) | |||
| 254 | + | ||||
| 255 | + | // Check if a container with this name already exists - handle recreation case | |||
| 256 | + | existingContainers, err := ListRunningContainers() | |||
| 257 | + | if err != nil { | |||
| 258 | + | log.Warn("Failed to check for existing containers", "error", err) | |||
| 259 | + | } else { | |||
| 260 | + | for _, container := range existingContainers { | |||
| 261 | + | for _, name := range container.Names { | |||
| 262 | + | // Container names in Docker API have a leading slash | |||
| 263 | + | cleanName := strings.TrimPrefix(name, "/") | |||
| 264 | + | if cleanName == cmdParams.ContainerName { | |||
| 265 | + | log.Warn("Container with this name already exists, it will be replaced", | |||
| 266 | + | "name", cmdParams.ContainerName, | |||
| 267 | + | "existing_id", container.ID) | |||
| 268 | + | ||||
| 269 | + | // Stop and remove the existing container | |||
| 270 | + | if err := StopContainer(container.ID); err != nil { | |||
| 271 | + | log.Warn("Failed to stop existing container", | |||
| 272 | + | "container_id", container.ID, | |||
| 273 | + | "error", err) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | if err := RemoveContainer(container.ID); err != nil { | |||
| 277 | + | log.Warn("Failed to remove existing container", | |||
| 278 | + | "container_id", container.ID, | |||
| 279 | + | "error", err) | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | log.Info("Removed existing container to create a new one", | |||
| 283 | + | "name", cmdParams.ContainerName) | |||
| 284 | + | break | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 250 | 289 | ||||
| 251 | // Check network | 290 | // Check network | ||
| 252 | isNetworkCreated, err := CheckIfNetworkExists(cmdParams.Network) | 291 | isNetworkCreated, err := CheckIfNetworkExists(cmdParams.Network) | ||
| @@ -695,3 +734,148 @@ func formatDuration(d time.Duration) string { | |||||
| 695 | } | 734 | } | ||
| 696 | return fmt.Sprintf("%dm", minutes) | 735 | return fmt.Sprintf("%dm", minutes) | ||
| 697 | } | 736 | } | ||
| 737 | + | ||||
| 738 | + | // ListenForContainerEvents starts listening for container events and calls the provided callback | |||
| 739 | + | // when relevant container events occur. This is used for real-time service discovery and IP updates. | |||
| 740 | + | func ListenForContainerEvents(networkFilter string, callback func(string, string, string)) error { | |||
| 741 | + | // Check if the Docker client has been initialized | |||
| 742 | + | err := CheckIfInitialized() | |||
| 743 | + | if err != nil { | |||
| 744 | + | return err | |||
| 745 | + | } | |||
| 746 | + | ||||
| 747 | + | // Create a context with cancel to allow for clean shutdown | |||
| 748 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 749 | + | ||||
| 750 | + | // Store cancel func to allow shutdown | |||
| 751 | + | containerEventCancel = cancel | |||
| 752 | + | ||||
| 753 | + | // Set up filters for the events we're interested in | |||
| 754 | + | filters := makeEventFilters(networkFilter) | |||
| 755 | + | ||||
| 756 | + | // Start listening for events | |||
| 757 | + | messages, errs := dockerCli.Events(ctx, types.EventsOptions{ | |||
| 758 | + | Filters: filters, | |||
| 759 | + | }) | |||
| 760 | + | ||||
| 761 | + | // Process events in a goroutine | |||
| 762 | + | go func() { | |||
| 763 | + | for { | |||
| 764 | + | select { | |||
| 765 | + | case err := <-errs: | |||
| 766 | + | if err != nil && ctx.Err() == nil { | |||
| 767 | + | // Only log if this isn't due to context cancellation | |||
| 768 | + | log.Error("Error in container event stream", "error", err) | |||
| 769 | + | } | |||
| 770 | + | return | |||
| 771 | + | case event := <-messages: | |||
| 772 | + | // Process container events | |||
| 773 | + | if event.Type == "container" { | |||
| 774 | + | containerID := event.Actor.ID | |||
| 775 | + | ||||
| 776 | + | // Get container name from event attributes | |||
| 777 | + | containerName := event.Actor.Attributes["name"] | |||
| 778 | + | if containerName == "" { | |||
| 779 | + | // If not in attributes, try to get it from the API | |||
| 780 | + | name, err := GetContainerName(containerID) | |||
| 781 | + | if err == nil { | |||
| 782 | + | containerName = strings.TrimPrefix(name, "/") | |||
| 783 | + | } | |||
| 784 | + | } | |||
| 785 | + | ||||
| 786 | + | // Handle container events based on action | |||
| 787 | + | switch event.Action { | |||
| 788 | + | case "start": | |||
| 789 | + | // Container started - need to get its IP and update any proxy routes | |||
| 790 | + | log.Debug("Container started event", | |||
| 791 | + | "container_id", containerID, | |||
| 792 | + | "container_name", containerName) | |||
| 793 | + | ||||
| 794 | + | // Get container IP from the network | |||
| 795 | + | containerIP, err := getContainerIPFromNetwork(containerID, networkFilter) | |||
| 796 | + | if err != nil { | |||
| 797 | + | log.Error("Failed to get container IP", | |||
| 798 | + | "container_id", containerID, | |||
| 799 | + | "error", err) | |||
| 800 | + | continue | |||
| 801 | + | } | |||
| 802 | + | ||||
| 803 | + | // Call the callback with the container ID, name, and IP | |||
| 804 | + | callback(containerID, containerName, containerIP) | |||
| 805 | + | ||||
| 806 | + | case "die", "kill", "destroy": | |||
| 807 | + | // Container stopped - might need to mark routes as inactive | |||
| 808 | + | log.Debug("Container stopped event", | |||
| 809 | + | "container_id", containerID, | |||
| 810 | + | "container_name", containerName, | |||
| 811 | + | "action", event.Action) | |||
| 812 | + | ||||
| 813 | + | case "rename": | |||
| 814 | + | // Container renamed - update related proxy routes | |||
| 815 | + | log.Debug("Container renamed event", | |||
| 816 | + | "container_id", containerID, | |||
| 817 | + | "container_name", containerName, | |||
| 818 | + | "new_name", event.Actor.Attributes["name"]) | |||
| 819 | + | ||||
| 820 | + | // Add other event types as needed | |||
| 821 | + | } | |||
| 822 | + | } | |||
| 823 | + | case <-ctx.Done(): | |||
| 824 | + | return | |||
| 825 | + | } | |||
| 826 | + | } | |||
| 827 | + | }() | |||
| 828 | + | ||||
| 829 | + | log.Info("Started container event listener", "network_filter", networkFilter) | |||
| 830 | + | return nil | |||
| 831 | + | } | |||
| 832 | + | ||||
| 833 | + | // StopContainerEventListener stops the container event listener | |||
| 834 | + | func StopContainerEventListener() { | |||
| 835 | + | if containerEventCancel != nil { | |||
| 836 | + | containerEventCancel() | |||
| 837 | + | log.Info("Stopped container event listener") | |||
| 838 | + | } | |||
| 839 | + | } | |||
| 840 | + | ||||
| 841 | + | // getContainerIPFromNetwork gets a container's IP address from the specified network | |||
| 842 | + | func getContainerIPFromNetwork(containerID, networkName string) (string, error) { | |||
| 843 | + | // Get network info | |||
| 844 | + | networkInfo, err := GetNetworkInfo(networkName) | |||
| 845 | + | if err != nil { | |||
| 846 | + | return "", fmt.Errorf("failed to get network info: %w", err) | |||
| 847 | + | } | |||
| 848 | + | ||||
| 849 | + | // Look for container in the network | |||
| 850 | + | containerEndpoint, exists := networkInfo.Containers[containerID] | |||
| 851 | + | if !exists { | |||
| 852 | + | return "", fmt.Errorf("container not found in network %s", networkName) | |||
| 853 | + | } | |||
| 854 | + | ||||
| 855 | + | // Return the container's IP address | |||
| 856 | + | return containerEndpoint.IPv4Address, nil | |||
| 857 | + | } | |||
| 858 | + | ||||
| 859 | + | // makeEventFilters creates filters for the Events API | |||
| 860 | + | func makeEventFilters(networkName string) filters.Args { | |||
| 861 | + | f := filters.NewArgs() | |||
| 862 | + | ||||
| 863 | + | // Filter for container events only | |||
| 864 | + | f.Add("type", "container") | |||
| 865 | + | ||||
| 866 | + | // Filter for events we're interested in | |||
| 867 | + | f.Add("event", "start") | |||
| 868 | + | f.Add("event", "die") | |||
| 869 | + | f.Add("event", "destroy") | |||
| 870 | + | f.Add("event", "rename") | |||
| 871 | + | ||||
| 872 | + | // If network name provided, filter for containers in that network | |||
| 873 | + | if networkName != "" { | |||
| 874 | + | f.Add("network", networkName) | |||
| 875 | + | } | |||
| 876 | + | ||||
| 877 | + | return f | |||
| 878 | + | } | |||
| 879 | + | ||||
| 880 | + | // Variable to store cancel function for event listener | |||
| 881 | + | var containerEventCancel context.CancelFunc | |||