detached d205b18

feat: Add proxy logging configuration and IP detection improvements

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close