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 | 46 | - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | |
| 47 | 47 | - GORDON_PROXY_CERT_DIR=/certs | |
| 48 | 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 | 51 | # Session Secret for security | |
| 51 | 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 | 183 | | `GORDON_PROXY_EMAIL` | Email for Let's Encrypt | Empty | | |
| 184 | 184 | | `GORDON_PROXY_CACHE_SIZE` | Certificate cache size | `1000` | | |
| 185 | 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 | 188 | ### Other Configuration | |
| 188 | 189 | ||
| @@ -205,6 +206,7 @@ If not specified in your config.yml, Gordon will automatically apply these recom | |||
| 205 | 206 | | `ReverseProxy.CacheSize` | 1000 entries | Optimizes performance when serving multiple domains | | |
| 206 | 207 | | `ReverseProxy.GracePeriod` | 30 seconds | Allows for graceful server shutdowns | | |
| 207 | 208 | | `ReverseProxy.AutoRenew` | true | Prevents certificate expiration issues | | |
| 209 | + | | `ReverseProxy.EnableLogs` | true | Controls whether HTTP request logs are enabled for the proxy | | |
| 208 | 210 | | `ReverseProxy.LetsEncryptMode` | staging | Safe default for testing; change to "production" for real certificates | | |
| 209 | 211 | ||
| 210 | 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 | 1 | package cmd | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 4 | 7 | "github.com/bnema/gordon/internal/proxy" | |
| 5 | 8 | "github.com/bnema/gordon/internal/server" | |
| 9 | + | "github.com/bnema/gordon/pkg/docker" | |
| 6 | 10 | "github.com/charmbracelet/log" | |
| 7 | 11 | "github.com/spf13/cobra" | |
| 8 | 12 | ) | |
| @@ -17,6 +21,8 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||
| 17 | 21 | protocol string | |
| 18 | 22 | path string | |
| 19 | 23 | remove bool | |
| 24 | + | update bool | |
| 25 | + | force bool | |
| 20 | 26 | ) | |
| 21 | 27 | ||
| 22 | 28 | proxyCmd := &cobra.Command{ | |
| @@ -36,6 +42,53 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||
| 36 | 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 | 92 | // If remove flag is set, remove the route | |
| 40 | 93 | if remove { | |
| 41 | 94 | if domain == "" { | |
| @@ -95,6 +148,10 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||
| 95 | 148 | } | |
| 96 | 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 | 155 | var count int | |
| 99 | 156 | for rows.Next() { | |
| 100 | 157 | var domain, containerID, containerIP, containerPort string | |
| @@ -103,16 +160,25 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||
| 103 | 160 | log.Fatal("Failed to scan row:", err) | |
| 104 | 161 | } | |
| 105 | 162 | ||
| 106 | - | log.Info("Route", | |
| 107 | - | "domain", domain, | |
| 108 | - | "containerIP", containerIP, | |
| 109 | - | "containerPort", containerPort, | |
| 110 | - | "active", active, | |
| 111 | - | ) | |
| 163 | + | // Format container ID to show just first 12 chars | |
| 164 | + | shortID := containerID | |
| 165 | + | if len(containerID) > 12 { | |
| 166 | + | shortID = containerID[:12] | |
| 167 | + | } | |
| 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 | 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 | 190 | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "http", "Protocol (http or https)") | |
| 125 | 191 | proxyCmd.Flags().StringVarP(&path, "path", "a", "/", "Path prefix to route") | |
| 126 | 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 | 196 | return proxyCmd | |
| 129 | 197 | } | |
M internal/common/init_config.go +21 -4
| @@ -112,6 +112,7 @@ type ReverseProxyConfig struct { | |||
| 112 | 112 | Email string `yaml:"email"` // Email for Let's Encrypt | |
| 113 | 113 | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | |
| 114 | 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 | 118 | // Default values | |
| @@ -177,6 +178,12 @@ func applyDefaultsToConfig(config *Config) bool { | |||
| 177 | 178 | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | |
| 178 | 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 | 188 | // Handle autoRenew | |
| 182 | 189 | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | |
| @@ -532,12 +539,20 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 532 | 539 | } | |
| 533 | 540 | } | |
| 534 | 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 | 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 | 608 | LetsEncryptMode: letsEncryptMode, | |
| 594 | 609 | CacheSize: cacheSize, | |
| 595 | 610 | GracePeriod: gracePeriod, | |
| 611 | + | EnableLogs: true, | |
| 596 | 612 | }, | |
| 597 | 613 | } | |
| 598 | 614 | ||
| @@ -730,6 +746,7 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 730 | 746 | LetsEncryptMode: letsEncryptMode, | |
| 731 | 747 | CacheSize: cacheSize, | |
| 732 | 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 | 2 | package handlers | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "database/sql" | |
| 5 | 6 | "encoding/json" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "io" | |
| @@ -9,6 +10,7 @@ import ( | |||
| 9 | 10 | "os" | |
| 10 | 11 | "path/filepath" | |
| 11 | 12 | "strings" | |
| 13 | + | "time" | |
| 12 | 14 | ||
| 13 | 15 | authToken "github.com/bnema/gordon/internal/cli/auth" | |
| 14 | 16 | "github.com/bnema/gordon/internal/common" | |
| @@ -397,6 +399,47 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 397 | 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 | 443 | // Extract the protocol from the target domain | |
| 401 | 444 | protocol := "http" | |
| 402 | 445 | ||
| @@ -408,8 +451,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 408 | 451 | "protocol", protocol) | |
| 409 | 452 | } else { | |
| 410 | 453 | // If target domain doesn't specify protocol, check the container labels | |
| 411 | - | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 412 | - | if err == nil { | |
| 454 | + | if err == nil { // Only check if we have containerInfo | |
| 413 | 455 | // Check if gordon.proxy.ssl label is set to true | |
| 414 | 456 | if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |
| 415 | 457 | (sslValue == "true" || sslValue == "1" || sslValue == "yes") { | |
| @@ -433,6 +475,121 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 433 | 475 | log.Debug("Adding domain for Let's Encrypt verification", | |
| 434 | 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 | 593 | // Add the route - this will also add to the domain database for Let's Encrypt | |
| 437 | 594 | if err := p.AddRoute(cleanDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil { | |
| 438 | 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 | 9 | "net/http" | |
| 10 | 10 | "os" | |
| 11 | 11 | "regexp" | |
| 12 | + | "strings" | |
| 12 | 13 | "sync" | |
| 13 | 14 | "time" | |
| 14 | 15 | ||
| @@ -446,37 +447,106 @@ func getTransferData(transferID string) (*ChunkMetadata, map[int][]byte, error) | |||
| 446 | 447 | ||
| 447 | 448 | // GetContainerIP gets the IP address of a container within its Docker network | |
| 448 | 449 | func GetContainerIP(a *server.App, containerID, containerName string) string { | |
| 449 | - | // Try to get container info | |
| 450 | - | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 451 | - | if err != nil { | |
| 452 | - | log.Warn("Failed to get container info, using container name as IP", "error", err) | |
| 453 | - | return containerName // Use container name as fallback | |
| 454 | - | } | |
| 450 | + | // Add retry logic with exponential backoff to ensure container network is ready | |
| 451 | + | maxRetries := 3 | |
| 452 | + | var containerIP string | |
| 453 | + | var lastErr error | |
| 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 | |
| 457 | - | if containerInfo.NetworkSettings != nil { | |
| 458 | - | // Look for the network specified in the config | |
| 459 | - | networkName := a.Config.ContainerEngine.Network | |
| 460 | - | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | |
| 461 | - | log.Debug("Found container IP", | |
| 462 | - | "containerName", containerName, | |
| 463 | - | "containerIP", networkSettings.IPAddress, | |
| 464 | - | "network", networkName) | |
| 465 | - | return networkSettings.IPAddress | |
| 466 | + | // Try to get container info | |
| 467 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 468 | + | if err != nil { | |
| 469 | + | lastErr = err | |
| 470 | + | continue // Try again | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // Make sure we have the most up-to-date container info by refreshing if needed | |
| 474 | + | // This is crucial for recreated containers | |
| 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 | |
| 469 | - | for networkName, networkSettings := range containerInfo.NetworkSettings.Networks { | |
| 470 | - | if networkSettings.IPAddress != "" { | |
| 471 | - | log.Debug("Found container IP in alternative network", | |
| 490 | + | // Extract IP address from container info | |
| 491 | + | if containerInfo.NetworkSettings != nil { | |
| 492 | + | // Look for the network specified in the config | |
| 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 | 497 | "containerName", containerName, | |
| 473 | - | "containerIP", networkSettings.IPAddress, | |
| 498 | + | "containerID", containerID, | |
| 499 | + | "containerIP", containerIP, | |
| 474 | 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 | 551 | return containerName | |
| 482 | 552 | } | |
M internal/proxy/blacklist.go +56 -0
| @@ -406,3 +406,59 @@ func (b *BlacklistConfig) RemoveRange(cidr string) error { | |||
| 406 | 406 | // Force a reload to rebuild Networks and the cache | |
| 407 | 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 | 11 | "github.com/charmbracelet/log" | |
| 12 | 12 | "github.com/docker/docker/api/types" | |
| 13 | 13 | "github.com/docker/docker/api/types/container" | |
| 14 | + | "github.com/docker/docker/api/types/filters" | |
| 14 | 15 | "github.com/docker/docker/api/types/network" | |
| 15 | 16 | "github.com/docker/docker/client" | |
| 16 | 17 | "github.com/docker/go-connections/nat" | |
| @@ -246,7 +247,45 @@ func CreateContainer(cmdParams ContainerCommandParams) (string, error) { | |||
| 246 | 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 | 290 | // Check network | |
| 252 | 291 | isNetworkCreated, err := CheckIfNetworkExists(cmdParams.Network) | |
| @@ -695,3 +734,148 @@ func formatDuration(d time.Duration) string { | |||
| 695 | 734 | } | |
| 696 | 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 | |