feat: Enhance admin login, configuration defaults, and connection testing
- Add token-based login form with error handling - Implement default configuration values for ReverseProxy settings - Improve admin connection testing with fallback mechanisms - Add non-blocking user input for configuration setup - Enhance logging and debugging for configuration and proxy initialization - Implement self-signed certificate generation for initial HTTPS setup - Add rate limiting middleware to prevent spam attacks - Improve Podman and container engine detection
- commit
- afb33a1b830c798820ecd69a82a7d950d8a59df3
- author
- bamen · · 2025-03-09 15:54 UTC
- parent
- 40d351c
- signature
- unsigned
12 files · +1341 -281
M docs/CONTAINER_CONFIG.md +28 -0
| @@ -191,6 +191,34 @@ Gordon supports the following environment variables for configuration: | |||
| 191 | 191 | | `SESSION_SECRET` | Secret for session encryption | Required | | |
| 192 | 192 | | `RUN_ENV` | Runtime environment (`dev` or `prod`) | `prod` | | |
| 193 | 193 | ||
| 194 | + | ## Default Values for Configuration | |
| 195 | + | ||
| 196 | + | Gordon now automatically applies default values for critical configuration settings that are missing or set to zero values in your config.yml. This ensures that your reverse proxy and Let's Encrypt integration work properly without requiring you to manually specify every field. | |
| 197 | + | ||
| 198 | + | ### Important Default Values Applied | |
| 199 | + | ||
| 200 | + | If not specified in your config.yml, Gordon will automatically apply these recommended values: | |
| 201 | + | ||
| 202 | + | | Setting | Default Value | Why It's Important | | |
| 203 | + | |---------|--------------|---------------------| | |
| 204 | + | | `ReverseProxy.RenewBefore` | 30 days | Ensures certificates are renewed well before expiry | | |
| 205 | + | | `ReverseProxy.CacheSize` | 1000 entries | Optimizes performance when serving multiple domains | | |
| 206 | + | | `ReverseProxy.GracePeriod` | 30 seconds | Allows for graceful server shutdowns | | |
| 207 | + | | `ReverseProxy.AutoRenew` | true | Prevents certificate expiration issues | | |
| 208 | + | | `ReverseProxy.LetsEncryptMode` | staging | Safe default for testing; change to "production" for real certificates | | |
| 209 | + | ||
| 210 | + | This behavior prevents issues that could occur with zero values like: | |
| 211 | + | - Certificates only being renewed at the last moment (or not at all) | |
| 212 | + | - Poor performance due to disabled caching | |
| 213 | + | - Abrupt connection termination during shutdowns | |
| 214 | + | ||
| 215 | + | These defaults are applied when: | |
| 216 | + | 1. A new configuration file is created | |
| 217 | + | 2. An existing configuration file is loaded but is missing these values | |
| 218 | + | 3. The values are explicitly set to zero or empty in your configuration | |
| 219 | + | ||
| 220 | + | You can always override these defaults by explicitly setting values in your config.yml or through environment variables. | |
| 221 | + | ||
| 194 | 222 | ## Secrets Management | |
| 195 | 223 | ||
| 196 | 224 | For sensitive values like `GORDON_TOKEN` and `SESSION_SECRET`, consider using Docker secrets or a proper secrets management system in production deployments. | |
| @@ -191,6 +191,34 @@ Gordon supports the following environment variables for configuration: | |||||
| 191 | | `SESSION_SECRET` | Secret for session encryption | Required | | 191 | | `SESSION_SECRET` | Secret for session encryption | Required | | ||
| 192 | | `RUN_ENV` | Runtime environment (`dev` or `prod`) | `prod` | | 192 | | `RUN_ENV` | Runtime environment (`dev` or `prod`) | `prod` | | ||
| 193 | 193 | ||||
| 194 | + | ## Default Values for Configuration | |||
| 195 | + | ||||
| 196 | + | Gordon now automatically applies default values for critical configuration settings that are missing or set to zero values in your config.yml. This ensures that your reverse proxy and Let's Encrypt integration work properly without requiring you to manually specify every field. | |||
| 197 | + | ||||
| 198 | + | ### Important Default Values Applied | |||
| 199 | + | ||||
| 200 | + | If not specified in your config.yml, Gordon will automatically apply these recommended values: | |||
| 201 | + | ||||
| 202 | + | | Setting | Default Value | Why It's Important | | |||
| 203 | + | |---------|--------------|---------------------| | |||
| 204 | + | | `ReverseProxy.RenewBefore` | 30 days | Ensures certificates are renewed well before expiry | | |||
| 205 | + | | `ReverseProxy.CacheSize` | 1000 entries | Optimizes performance when serving multiple domains | | |||
| 206 | + | | `ReverseProxy.GracePeriod` | 30 seconds | Allows for graceful server shutdowns | | |||
| 207 | + | | `ReverseProxy.AutoRenew` | true | Prevents certificate expiration issues | | |||
| 208 | + | | `ReverseProxy.LetsEncryptMode` | staging | Safe default for testing; change to "production" for real certificates | | |||
| 209 | + | ||||
| 210 | + | This behavior prevents issues that could occur with zero values like: | |||
| 211 | + | - Certificates only being renewed at the last moment (or not at all) | |||
| 212 | + | - Poor performance due to disabled caching | |||
| 213 | + | - Abrupt connection termination during shutdowns | |||
| 214 | + | ||||
| 215 | + | These defaults are applied when: | |||
| 216 | + | 1. A new configuration file is created | |||
| 217 | + | 2. An existing configuration file is loaded but is missing these values | |||
| 218 | + | 3. The values are explicitly set to zero or empty in your configuration | |||
| 219 | + | ||||
| 220 | + | You can always override these defaults by explicitly setting values in your config.yml or through environment variables. | |||
| 221 | + | ||||
| 194 | ## Secrets Management | 222 | ## Secrets Management | ||
| 195 | 223 | ||||
| 196 | For sensitive values like `GORDON_TOKEN` and `SESSION_SECRET`, consider using Docker secrets or a proper secrets management system in production deployments. | 224 | For sensitive values like `GORDON_TOKEN` and `SESSION_SECRET`, consider using Docker secrets or a proper secrets management system in production deployments. | ||
M go.mod +1 -1
| @@ -96,7 +96,7 @@ require ( | |||
| 96 | 96 | golang.org/x/net v0.33.0 // indirect | |
| 97 | 97 | golang.org/x/sys v0.31.0 // indirect | |
| 98 | 98 | golang.org/x/text v0.23.0 // indirect | |
| 99 | - | golang.org/x/time v0.8.0 // indirect | |
| 99 | + | golang.org/x/time v0.8.0 // direct | |
| 100 | 100 | golang.org/x/tools v0.24.0 // indirect | |
| 101 | 101 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect | |
| 102 | 102 | gotest.tools/v3 v3.5.0 // indirect | |
| @@ -96,7 +96,7 @@ require ( | |||||
| 96 | golang.org/x/net v0.33.0 // indirect | 96 | golang.org/x/net v0.33.0 // indirect | ||
| 97 | golang.org/x/sys v0.31.0 // indirect | 97 | golang.org/x/sys v0.31.0 // indirect | ||
| 98 | golang.org/x/text v0.23.0 // indirect | 98 | golang.org/x/text v0.23.0 // indirect | ||
| 99 | - | golang.org/x/time v0.8.0 // indirect | 99 | + | golang.org/x/time v0.8.0 // direct |
| 100 | golang.org/x/tools v0.24.0 // indirect | 100 | golang.org/x/tools v0.24.0 // indirect | ||
| 101 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect | 101 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect | ||
| 102 | gotest.tools/v3 v3.5.0 // indirect | 102 | gotest.tools/v3 v3.5.0 // indirect | ||
M internal/cli/cmd/deploy.go +1 -1
| @@ -22,7 +22,7 @@ import ( | |||
| 22 | 22 | ||
| 23 | 23 | func init() { | |
| 24 | 24 | log.SetReportTimestamp(true) | |
| 25 | - | log.SetTimeFormat("15:04") | |
| 25 | + | log.SetTimeFormat("15:04:05") | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | func NewDeployCommand(a *cli.App) *cobra.Command { | |
| @@ -22,7 +22,7 @@ import ( | |||||
| 22 | 22 | ||||
| 23 | func init() { | 23 | func init() { | ||
| 24 | log.SetReportTimestamp(true) | 24 | log.SetReportTimestamp(true) | ||
| 25 | - | log.SetTimeFormat("15:04") | 25 | + | log.SetTimeFormat("15:04:05") |
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | func NewDeployCommand(a *cli.App) *cobra.Command { | 28 | func NewDeployCommand(a *cli.App) *cobra.Command { | ||
M internal/cli/cmd/push.go +1 -1
| @@ -114,7 +114,7 @@ func pushImage(a *cli.App, reader io.Reader, imageName string) error { | |||
| 114 | 114 | ||
| 115 | 115 | if !pushResponse.Success { | |
| 116 | 116 | log.Error("Push failed", "resp", pushResponse.Message) | |
| 117 | - | return fmt.Errorf(pushResponse.Message) | |
| 117 | + | return fmt.Errorf("%s", pushResponse.Message) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | 120 | // Remove the double quotes from the message | |
| @@ -114,7 +114,7 @@ func pushImage(a *cli.App, reader io.Reader, imageName string) error { | |||||
| 114 | 114 | ||||
| 115 | if !pushResponse.Success { | 115 | if !pushResponse.Success { | ||
| 116 | log.Error("Push failed", "resp", pushResponse.Message) | 116 | log.Error("Push failed", "resp", pushResponse.Message) | ||
| 117 | - | return fmt.Errorf(pushResponse.Message) | 117 | + | return fmt.Errorf("%s", pushResponse.Message) |
| 118 | } | 118 | } | ||
| 119 | 119 | ||||
| 120 | // Remove the double quotes from the message | 120 | // Remove the double quotes from the message | ||
M internal/cli/handler/start_server.go +10 -0
| @@ -62,6 +62,16 @@ func StartServer(a *server.App, port string) error { | |||
| 62 | 62 | e = httpserve.RegisterRoutes(e, a) | |
| 63 | 63 | ||
| 64 | 64 | log.Info(fmt.Sprintf("Starting server on port %s", port)) | |
| 65 | + | ||
| 66 | + | // Test admin connections after the server has started (in a separate goroutine) | |
| 67 | + | if p != nil { | |
| 68 | + | go func() { | |
| 69 | + | // Add a delay to ensure the server has fully started | |
| 70 | + | time.Sleep(2 * time.Second) | |
| 71 | + | p.TestAdminConnectionLater() | |
| 72 | + | }() | |
| 73 | + | } | |
| 74 | + | ||
| 65 | 75 | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { | |
| 66 | 76 | log.Fatal(err) | |
| 67 | 77 | } | |
| @@ -62,6 +62,16 @@ func StartServer(a *server.App, port string) error { | |||||
| 62 | e = httpserve.RegisterRoutes(e, a) | 62 | e = httpserve.RegisterRoutes(e, a) | ||
| 63 | 63 | ||||
| 64 | log.Info(fmt.Sprintf("Starting server on port %s", port)) | 64 | log.Info(fmt.Sprintf("Starting server on port %s", port)) | ||
| 65 | + | ||||
| 66 | + | // Test admin connections after the server has started (in a separate goroutine) | |||
| 67 | + | if p != nil { | |||
| 68 | + | go func() { | |||
| 69 | + | // Add a delay to ensure the server has fully started | |||
| 70 | + | time.Sleep(2 * time.Second) | |||
| 71 | + | p.TestAdminConnectionLater() | |||
| 72 | + | }() | |||
| 73 | + | } | |||
| 74 | + | ||||
| 65 | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { | 75 | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { | ||
| 66 | log.Fatal(err) | 76 | log.Fatal(err) | ||
| 67 | } | 77 | } | ||
M internal/common/init_config.go +376 -106
| @@ -13,15 +13,31 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/pkg/docker" | |
| 15 | 15 | "github.com/bnema/gordon/pkg/parser" | |
| 16 | + | "github.com/charmbracelet/log" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | - | // Global singleton configuration instance | |
| 19 | 19 | var ( | |
| 20 | - | globalConfig *Config | |
| 21 | - | globalConfigOnce sync.Once | |
| 22 | - | globalConfigMu sync.RWMutex | |
| 20 | + | logger = log.NewWithOptions(os.Stderr, log.Options{Level: log.InfoLevel}) | |
| 21 | + | globalConfig *Config | |
| 22 | + | globalConfigOnce sync.Once | |
| 23 | + | globalConfigMu sync.RWMutex | |
| 24 | + | configLogsPrinted bool | |
| 23 | 25 | ) | |
| 24 | 26 | ||
| 27 | + | // Initialize package-level logging configuration | |
| 28 | + | func init() { | |
| 29 | + | // Enable timestamp reporting and set format | |
| 30 | + | log.SetReportTimestamp(true) | |
| 31 | + | log.SetTimeFormat("15:04:05") | |
| 32 | + | ||
| 33 | + | // Check for debug environment variable | |
| 34 | + | if os.Getenv("ENV") == "dev" { | |
| 35 | + | // Set logger to debug level | |
| 36 | + | logger.SetLevel(log.DebugLevel) | |
| 37 | + | logger.Debug("Debug logging enabled") | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 25 | 41 | type Config struct { | |
| 26 | 42 | General GeneralConfig `yaml:"General"` | |
| 27 | 43 | Http HttpConfig `yaml:"Http"` | |
| @@ -77,7 +93,7 @@ type ReverseProxyConfig struct { | |||
| 77 | 93 | // Default values | |
| 78 | 94 | var ( | |
| 79 | 95 | sock = "/var/run/docker.sock" | |
| 80 | - | podmansock = "/run/user/1000/podman/podman.sock" | |
| 96 | + | podmansock = "/run/podman/podman.sock" | |
| 81 | 97 | reverseProxyPort = "443" // Changed to 443 for standard HTTPS | |
| 82 | 98 | httpPort = "80" // Standard HTTP port | |
| 83 | 99 | certDir = "/certs" | |
| @@ -88,11 +104,100 @@ var ( | |||
| 88 | 104 | gracePeriod = 30 // seconds | |
| 89 | 105 | ) | |
| 90 | 106 | ||
| 91 | - | // Track whether config logs have been printed already | |
| 92 | - | var configLogsPrinted bool = false | |
| 107 | + | // applyDefaultsToConfig applies default values to any fields that have zero values | |
| 108 | + | // Returns true if any defaults were applied | |
| 109 | + | func applyDefaultsToConfig(config *Config) bool { | |
| 110 | + | defaultsApplied := false | |
| 111 | + | ||
| 112 | + | // Apply defaults to ReverseProxy config | |
| 113 | + | if config.ReverseProxy.Port == "" { | |
| 114 | + | config.ReverseProxy.Port = reverseProxyPort | |
| 115 | + | logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort) | |
| 116 | + | defaultsApplied = true | |
| 117 | + | } | |
| 118 | + | if config.ReverseProxy.HttpPort == "" { | |
| 119 | + | config.ReverseProxy.HttpPort = httpPort | |
| 120 | + | logger.Debug("Applied default value for ReverseProxy.HttpPort", "value", httpPort) | |
| 121 | + | defaultsApplied = true | |
| 122 | + | } | |
| 123 | + | if config.ReverseProxy.CertDir == "" { | |
| 124 | + | config.ReverseProxy.CertDir = certDir | |
| 125 | + | logger.Debug("Applied default value for ReverseProxy.CertDir", "value", certDir) | |
| 126 | + | defaultsApplied = true | |
| 127 | + | } | |
| 128 | + | if config.ReverseProxy.RenewBefore == 0 { | |
| 129 | + | config.ReverseProxy.RenewBefore = renewBefore | |
| 130 | + | logger.Debug("Applied default value for ReverseProxy.RenewBefore", "value", renewBefore) | |
| 131 | + | defaultsApplied = true | |
| 132 | + | } | |
| 133 | + | if config.ReverseProxy.LetsEncryptMode == "" { | |
| 134 | + | config.ReverseProxy.LetsEncryptMode = letsEncryptMode | |
| 135 | + | logger.Debug("Applied default value for ReverseProxy.LetsEncryptMode", "value", letsEncryptMode) | |
| 136 | + | defaultsApplied = true | |
| 137 | + | } | |
| 138 | + | if config.ReverseProxy.CacheSize == 0 { | |
| 139 | + | config.ReverseProxy.CacheSize = cacheSize | |
| 140 | + | logger.Debug("Applied default value for ReverseProxy.CacheSize", "value", cacheSize) | |
| 141 | + | defaultsApplied = true | |
| 142 | + | } | |
| 143 | + | if config.ReverseProxy.GracePeriod == 0 { | |
| 144 | + | config.ReverseProxy.GracePeriod = gracePeriod | |
| 145 | + | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | |
| 146 | + | defaultsApplied = true | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Handle autoRenew | |
| 150 | + | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | |
| 151 | + | if fileExists(configFilePath) { | |
| 152 | + | yamlContent, err := os.ReadFile(configFilePath) | |
| 153 | + | if err == nil { | |
| 154 | + | // Check if "autoRenew" is explicitly mentioned in the config file | |
| 155 | + | if !strings.Contains(string(yamlContent), "autoRenew:") { | |
| 156 | + | config.ReverseProxy.AutoRenew = autoRenew | |
| 157 | + | logger.Debug("Applied default value for ReverseProxy.AutoRenew", "value", autoRenew) | |
| 158 | + | defaultsApplied = true | |
| 159 | + | } else { | |
| 160 | + | logger.Debug("Keeping explicit value for ReverseProxy.AutoRenew", "value", config.ReverseProxy.AutoRenew) | |
| 161 | + | } | |
| 162 | + | } else { | |
| 163 | + | // If we can't read the file for some reason, apply the default | |
| 164 | + | if !config.ReverseProxy.AutoRenew { | |
| 165 | + | config.ReverseProxy.AutoRenew = autoRenew | |
| 166 | + | logger.Debug("Applied default value for ReverseProxy.AutoRenew", "value", autoRenew) | |
| 167 | + | defaultsApplied = true | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } else { | |
| 171 | + | // If config file doesn't exist yet, set the default | |
| 172 | + | config.ReverseProxy.AutoRenew = autoRenew | |
| 173 | + | logger.Debug("Applied default value for ReverseProxy.AutoRenew", "value", autoRenew) | |
| 174 | + | defaultsApplied = true | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | return defaultsApplied | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // getConfigDirMustExist returns the config directory and creates it if it doesn't exist | |
| 181 | + | // This is a helper function to avoid error handling in applyDefaultsToConfig | |
| 182 | + | func getConfigDirMustExist() string { | |
| 183 | + | configDir, err := getConfigDir() | |
| 184 | + | if err != nil { | |
| 185 | + | // Fallback to a reasonable default if there's an error | |
| 186 | + | homeDir, err := os.UserHomeDir() | |
| 187 | + | if err != nil { | |
| 188 | + | return "/tmp/.gordon" | |
| 189 | + | } | |
| 190 | + | return filepath.Join(homeDir, ".gordon") | |
| 191 | + | } | |
| 192 | + | return configDir | |
| 193 | + | } | |
| 93 | 194 | ||
| 94 | 195 | // GetGlobalConfig returns the singleton config instance, loading it if necessary | |
| 95 | 196 | func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | |
| 197 | + | logger.Debug("GetGlobalConfig: Starting with buildConfig", "buildConfig", buildConfig != nil) | |
| 198 | + | ||
| 199 | + | // Try read lock first | |
| 200 | + | logger.Debug("GetGlobalConfig: Acquiring read lock") | |
| 96 | 201 | globalConfigMu.RLock() | |
| 97 | 202 | if globalConfig != nil { | |
| 98 | 203 | // If buildConfig is provided, update the Build field | |
| @@ -100,15 +205,21 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | |||
| 100 | 205 | globalConfig.Build = *buildConfig | |
| 101 | 206 | } | |
| 102 | 207 | config := globalConfig | |
| 208 | + | logger.Debug("GetGlobalConfig: Config exists, releasing read lock") | |
| 103 | 209 | globalConfigMu.RUnlock() | |
| 104 | - | fmt.Println("Using existing global configuration (already loaded)") | |
| 210 | + | logger.Debug("GetGlobalConfig: Using existing global configuration (already loaded)") | |
| 105 | 211 | return config, nil | |
| 106 | 212 | } | |
| 213 | + | logger.Debug("GetGlobalConfig: Config not found, releasing read lock") | |
| 107 | 214 | globalConfigMu.RUnlock() | |
| 108 | 215 | ||
| 109 | - | // Need to load the config | |
| 216 | + | // Need to load the config - acquire write lock | |
| 217 | + | logger.Debug("GetGlobalConfig: No existing config, acquiring write lock") | |
| 110 | 218 | globalConfigMu.Lock() | |
| 111 | - | defer globalConfigMu.Unlock() | |
| 219 | + | defer func() { | |
| 220 | + | logger.Debug("GetGlobalConfig: Releasing write lock via defer") | |
| 221 | + | globalConfigMu.Unlock() | |
| 222 | + | }() | |
| 112 | 223 | ||
| 113 | 224 | // Double-check in case another goroutine loaded the config while we were waiting | |
| 114 | 225 | if globalConfig != nil { | |
| @@ -116,12 +227,10 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | |||
| 116 | 227 | if buildConfig != nil { | |
| 117 | 228 | globalConfig.Build = *buildConfig | |
| 118 | 229 | } | |
| 119 | - | fmt.Println("Using existing global configuration (loaded by another goroutine)") | |
| 230 | + | logger.Debug("GetGlobalConfig: Using existing global configuration (loaded by another goroutine)") | |
| 120 | 231 | return globalConfig, nil | |
| 121 | 232 | } | |
| 122 | 233 | ||
| 123 | - | fmt.Println("Loading global configuration for the first time") | |
| 124 | - | ||
| 125 | 234 | // Initialize config with build info | |
| 126 | 235 | config := &Config{} | |
| 127 | 236 | if buildConfig != nil { | |
| @@ -130,74 +239,102 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | |||
| 130 | 239 | ||
| 131 | 240 | // Load the config once | |
| 132 | 241 | var err error | |
| 242 | + | logger.Debug("GetGlobalConfig: About to execute globalConfigOnce.Do") | |
| 133 | 243 | globalConfigOnce.Do(func() { | |
| 134 | - | config, err = config.LoadConfig() | |
| 244 | + | logger.Debug("Inside globalConfigOnce.Do, loading configuration...") | |
| 245 | + | var loadedConfig *Config | |
| 246 | + | loadedConfig, err = config.LoadConfig() | |
| 135 | 247 | if err == nil { | |
| 136 | - | globalConfig = config | |
| 137 | - | fmt.Println("Global configuration loaded successfully") | |
| 248 | + | logger.Debug("GetGlobalConfig: LoadConfig successful, setting globalConfig") | |
| 249 | + | globalConfig = loadedConfig | |
| 250 | + | logger.Info("Global configuration loaded successfully") | |
| 138 | 251 | } else { | |
| 139 | - | fmt.Printf("Error loading global configuration: %v\n", err) | |
| 252 | + | logger.Error("Error loading global configuration", "error", err) | |
| 140 | 253 | } | |
| 141 | 254 | }) | |
| 255 | + | logger.Debug("GetGlobalConfig: Completed globalConfigOnce.Do") | |
| 142 | 256 | ||
| 143 | - | return globalConfig, err | |
| 257 | + | // Return the global config if it was set, otherwise return the local config | |
| 258 | + | if globalConfig != nil { | |
| 259 | + | return globalConfig, err | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | return config, err | |
| 144 | 263 | } | |
| 145 | 264 | ||
| 146 | 265 | func getConfigDir() (string, error) { | |
| 266 | + | logger.Debug("getConfigDir: Starting getConfigDir function") | |
| 147 | 267 | var configDir string | |
| 148 | 268 | ||
| 269 | + | logger.Debug("getConfigDir: Checking if running in WSL") | |
| 149 | 270 | if isWSL() { | |
| 271 | + | logger.Debug("getConfigDir: Running in WSL") | |
| 150 | 272 | // Use XDG_CONFIG_HOME for WSL | |
| 151 | 273 | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { | |
| 274 | + | logger.Debug("getConfigDir: Using XDG_CONFIG_HOME environment variable", "value", xdgConfigHome) | |
| 152 | 275 | configDir = filepath.Join(xdgConfigHome, "Gordon") | |
| 153 | 276 | } else { | |
| 154 | 277 | // If XDG_CONFIG_HOME is not set, fall back to default locations | |
| 278 | + | logger.Debug("getConfigDir: XDG_CONFIG_HOME not set, getting user home directory") | |
| 155 | 279 | homeDir, err := os.UserHomeDir() | |
| 156 | 280 | if err != nil { | |
| 157 | 281 | // If we can't get the home directory, use a fallback | |
| 282 | + | logger.Debug("getConfigDir: Failed to get user home directory, using temp dir", "error", err) | |
| 158 | 283 | homeDir = os.TempDir() // Use the system's temp directory as a fallback | |
| 159 | - | fmt.Printf("Warning: Unable to determine home directory. Using temp directory: %s\n", homeDir) | |
| 284 | + | logger.Warn("Warning: Unable to determine home directory. Using temp directory", "dir", homeDir) | |
| 160 | 285 | } | |
| 161 | 286 | ||
| 162 | 287 | configDir = filepath.Join(homeDir, ".config", "Gordon") | |
| 163 | - | ||
| 288 | + | logger.Debug("getConfigDir: Set config directory based on home dir", "dir", configDir) | |
| 164 | 289 | } | |
| 165 | 290 | ||
| 291 | + | logger.Debug("getConfigDir: Returning WSL config directory", "dir", configDir) | |
| 166 | 292 | return configDir, nil | |
| 167 | 293 | } | |
| 168 | 294 | ||
| 295 | + | logger.Debug("getConfigDir: Checking if running in container") | |
| 169 | 296 | if docker.IsRunningInContainer() { | |
| 170 | - | return "/", nil | |
| 297 | + | logger.Debug("getConfigDir: Running in container, using current directory") | |
| 298 | + | return ".", nil | |
| 171 | 299 | } | |
| 172 | 300 | ||
| 173 | 301 | // Check for XDG_CONFIG_HOME first | |
| 302 | + | logger.Debug("getConfigDir: Checking XDG_CONFIG_HOME environment variable") | |
| 174 | 303 | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { | |
| 304 | + | logger.Debug("getConfigDir: Using XDG_CONFIG_HOME environment variable", "value", xdgConfigHome) | |
| 175 | 305 | configDir = filepath.Join(xdgConfigHome, "Gordon") | |
| 176 | 306 | } else { | |
| 177 | 307 | // If XDG_CONFIG_HOME is not set, fall back to default locations | |
| 308 | + | logger.Debug("getConfigDir: XDG_CONFIG_HOME not set, getting user home directory") | |
| 178 | 309 | homeDir, err := os.UserHomeDir() | |
| 179 | 310 | if err != nil { | |
| 180 | - | // If we can't get the home directory, use a fallback | |
| 181 | - | homeDir = os.TempDir() // Use the system's temp directory as a fallback | |
| 182 | - | fmt.Printf("Warning: Unable to determine home directory. Using temp directory: %s\n", homeDir) | |
| 311 | + | logger.Debug("getConfigDir: Failed to get user home directory", "error", err) | |
| 312 | + | return "", fmt.Errorf("could not determine user home directory: %w", err) | |
| 183 | 313 | } | |
| 184 | 314 | ||
| 185 | - | // Select the configuration directory based on the OS | |
| 315 | + | // For Windows, use the standard application data directory | |
| 186 | 316 | if runtime.GOOS == "windows" { | |
| 187 | - | configDir = filepath.Join(homeDir, "AppData", "Roaming", "Gordon") | |
| 317 | + | logger.Debug("getConfigDir: On Windows, using AppData") | |
| 318 | + | configDir = filepath.Join(homeDir, "AppData", "Local", "Gordon") | |
| 188 | 319 | } else { | |
| 320 | + | // For Unix systems, use the XDG Base Directory Specification | |
| 321 | + | logger.Debug("getConfigDir: On Unix system, using XDG pattern") | |
| 189 | 322 | configDir = filepath.Join(homeDir, ".config", "Gordon") | |
| 190 | 323 | } | |
| 191 | 324 | } | |
| 192 | 325 | ||
| 326 | + | logger.Debug("getConfigDir: Returning config directory", "dir", configDir) | |
| 193 | 327 | return configDir, nil | |
| 194 | 328 | } | |
| 195 | 329 | ||
| 196 | 330 | func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error { | |
| 331 | + | logger.Debug("readAndUnmarshalConfig: Starting to read and unmarshal config file", "path", filePath) | |
| 197 | 332 | err := parser.ParseYAMLFile(fs, filePath, config) | |
| 198 | 333 | if err != nil { | |
| 334 | + | logger.Debug("readAndUnmarshalConfig: Failed to parse YAML file", "error", err) | |
| 199 | 335 | return fmt.Errorf("error reading and unmarshaling configuration file: %w", err) | |
| 200 | 336 | } | |
| 337 | + | logger.Debug("readAndUnmarshalConfig: Successfully parsed YAML file") | |
| 201 | 338 | return nil | |
| 202 | 339 | } | |
| 203 | 340 | ||
| @@ -207,13 +344,13 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 207 | 344 | if val := os.Getenv("GORDON_STORAGE_DIR"); val != "" { | |
| 208 | 345 | config.General.StorageDir = val | |
| 209 | 346 | if printLogs { | |
| 210 | - | fmt.Printf("Using environment variable GORDON_STORAGE_DIR: %s\n", val) | |
| 347 | + | logger.Info("Using environment variable GORDON_STORAGE_DIR", "value", val) | |
| 211 | 348 | } | |
| 212 | 349 | } | |
| 213 | 350 | if val := os.Getenv("GORDON_TOKEN"); val != "" { | |
| 214 | 351 | config.General.Token = val | |
| 215 | 352 | if printLogs { | |
| 216 | - | fmt.Printf("Using environment variable GORDON_TOKEN\n") | |
| 353 | + | logger.Info("Using environment variable GORDON_TOKEN") | |
| 217 | 354 | } | |
| 218 | 355 | } | |
| 219 | 356 | ||
| @@ -221,31 +358,31 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 221 | 358 | if val := os.Getenv("GORDON_HTTP_PORT"); val != "" { | |
| 222 | 359 | config.Http.Port = val | |
| 223 | 360 | if printLogs { | |
| 224 | - | fmt.Printf("Using environment variable GORDON_HTTP_PORT: %s\n", val) | |
| 361 | + | logger.Info("Using environment variable GORDON_HTTP_PORT", "value", val) | |
| 225 | 362 | } | |
| 226 | 363 | } | |
| 227 | 364 | if val := os.Getenv("GORDON_HTTP_DOMAIN"); val != "" { | |
| 228 | 365 | config.Http.Domain = val | |
| 229 | 366 | if printLogs { | |
| 230 | - | fmt.Printf("Using environment variable GORDON_HTTP_DOMAIN: %s\n", val) | |
| 367 | + | logger.Info("Using environment variable GORDON_HTTP_DOMAIN", "value", val) | |
| 231 | 368 | } | |
| 232 | 369 | } | |
| 233 | 370 | if val := os.Getenv("GORDON_HTTP_SUBDOMAIN"); val != "" { | |
| 234 | 371 | config.Http.SubDomain = val | |
| 235 | 372 | if printLogs { | |
| 236 | - | fmt.Printf("Using environment variable GORDON_HTTP_SUBDOMAIN: %s\n", val) | |
| 373 | + | logger.Info("Using environment variable GORDON_HTTP_SUBDOMAIN", "value", val) | |
| 237 | 374 | } | |
| 238 | 375 | } | |
| 239 | 376 | if val := os.Getenv("GORDON_HTTP_BACKEND_URL"); val != "" { | |
| 240 | 377 | config.Http.BackendURL = val | |
| 241 | 378 | if printLogs { | |
| 242 | - | fmt.Printf("Using environment variable GORDON_HTTP_BACKEND_URL: %s\n", val) | |
| 379 | + | logger.Info("Using environment variable GORDON_HTTP_BACKEND_URL", "value", val) | |
| 243 | 380 | } | |
| 244 | 381 | } | |
| 245 | 382 | if val := os.Getenv("GORDON_HTTP_HTTPS"); val != "" { | |
| 246 | 383 | config.Http.Https = strings.ToLower(val) == "true" | |
| 247 | 384 | if printLogs { | |
| 248 | - | fmt.Printf("Using environment variable GORDON_HTTP_HTTPS: %t\n", config.Http.Https) | |
| 385 | + | logger.Info("Using environment variable GORDON_HTTP_HTTPS", "value", config.Http.Https) | |
| 249 | 386 | } | |
| 250 | 387 | } | |
| 251 | 388 | ||
| @@ -253,7 +390,7 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 253 | 390 | if val := os.Getenv("GORDON_ADMIN_PATH"); val != "" { | |
| 254 | 391 | config.Admin.Path = val | |
| 255 | 392 | if printLogs { | |
| 256 | - | fmt.Printf("Using environment variable GORDON_ADMIN_PATH: %s\n", val) | |
| 393 | + | logger.Info("Using environment variable GORDON_ADMIN_PATH", "value", val) | |
| 257 | 394 | } | |
| 258 | 395 | } | |
| 259 | 396 | ||
| @@ -261,41 +398,45 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 261 | 398 | if val := os.Getenv("GORDON_CONTAINER_SOCK"); val != "" { | |
| 262 | 399 | config.ContainerEngine.Sock = val | |
| 263 | 400 | if printLogs { | |
| 264 | - | fmt.Printf("Using environment variable GORDON_CONTAINER_SOCK: %s\n", val) | |
| 401 | + | logger.Info("Using environment variable GORDON_CONTAINER_SOCK", "value", val) | |
| 265 | 402 | } | |
| 266 | 403 | } | |
| 267 | - | if val := os.Getenv("GORDON_CONTAINER_PODMAN_SOCK"); val != "" { | |
| 404 | + | if val := os.Getenv("GORDON_CONTAINER_PODMANSOCK"); val != "" { | |
| 268 | 405 | config.ContainerEngine.PodmanSock = val | |
| 269 | 406 | if printLogs { | |
| 270 | - | fmt.Printf("Using environment variable GORDON_CONTAINER_PODMAN_SOCK: %s\n", val) | |
| 407 | + | logger.Info("Using environment variable GORDON_CONTAINER_PODMANSOCK", "value", val) | |
| 271 | 408 | } | |
| 272 | 409 | } | |
| 273 | 410 | if val := os.Getenv("GORDON_CONTAINER_PODMAN"); val != "" { | |
| 274 | 411 | config.ContainerEngine.Podman = strings.ToLower(val) == "true" | |
| 275 | 412 | if printLogs { | |
| 276 | - | fmt.Printf("Using environment variable GORDON_CONTAINER_PODMAN: %t\n", config.ContainerEngine.Podman) | |
| 413 | + | logger.Info("Using environment variable GORDON_CONTAINER_PODMAN", "value", config.ContainerEngine.Podman) | |
| 277 | 414 | } | |
| 278 | - | ||
| 279 | - | // If using Podman, and Sock is empty but PodmanSock is set, use PodmanSock for Sock field | |
| 280 | - | if config.ContainerEngine.Podman && config.ContainerEngine.Sock == "" && config.ContainerEngine.PodmanSock != "" { | |
| 281 | - | config.ContainerEngine.Sock = config.ContainerEngine.PodmanSock | |
| 415 | + | } else { | |
| 416 | + | // Auto-detect Podman if not specified in environment | |
| 417 | + | isPodman, podmanSocket := docker.DetectPodman() | |
| 418 | + | if isPodman { | |
| 419 | + | config.ContainerEngine.Podman = true | |
| 420 | + | config.ContainerEngine.PodmanSock = podmanSocket | |
| 282 | 421 | if printLogs { | |
| 283 | - | fmt.Printf("Setting ContainerEngine.Sock to PodmanSock value: %s\n", config.ContainerEngine.Sock) | |
| 422 | + | logger.Info("Automatically detected Podman installation", | |
| 423 | + | "using_podman", true, | |
| 424 | + | "socket", podmanSocket) | |
| 284 | 425 | } | |
| 285 | 426 | } | |
| 286 | 427 | } | |
| 287 | 428 | if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" { | |
| 288 | 429 | config.ContainerEngine.Network = val | |
| 289 | 430 | if printLogs { | |
| 290 | - | fmt.Printf("Using environment variable GORDON_CONTAINER_NETWORK: %s\n", val) | |
| 431 | + | logger.Info("Using environment variable GORDON_CONTAINER_NETWORK", "value", val) | |
| 291 | 432 | } | |
| 292 | 433 | } | |
| 293 | 434 | ||
| 294 | - | // If using Docker socket with podman-compose mounting | |
| 295 | - | if config.ContainerEngine.Sock == "" && fileExists("/var/run/docker.sock") { | |
| 296 | - | config.ContainerEngine.Sock = "/var/run/docker.sock" | |
| 435 | + | // Use Podman socket if Podman is enabled and no Docker socket is specified | |
| 436 | + | if config.ContainerEngine.Podman && config.ContainerEngine.Sock == "" && config.ContainerEngine.PodmanSock != "" { | |
| 437 | + | config.ContainerEngine.Sock = config.ContainerEngine.PodmanSock | |
| 297 | 438 | if printLogs { | |
| 298 | - | fmt.Printf("Docker socket found at /var/run/docker.sock, using it\n") | |
| 439 | + | logger.Info("Setting ContainerEngine.Sock to PodmanSock value", "value", config.ContainerEngine.Sock) | |
| 299 | 440 | } | |
| 300 | 441 | } | |
| 301 | 442 | ||
| @@ -303,52 +444,52 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 303 | 444 | if val := os.Getenv("GORDON_PROXY_PORT"); val != "" { | |
| 304 | 445 | config.ReverseProxy.Port = val | |
| 305 | 446 | if printLogs { | |
| 306 | - | fmt.Printf("Using environment variable GORDON_PROXY_PORT: %s\n", val) | |
| 447 | + | logger.Info("Using environment variable GORDON_PROXY_PORT", "value", val) | |
| 307 | 448 | } | |
| 308 | 449 | } | |
| 309 | 450 | if val := os.Getenv("GORDON_PROXY_HTTP_PORT"); val != "" { | |
| 310 | 451 | config.ReverseProxy.HttpPort = val | |
| 311 | 452 | if printLogs { | |
| 312 | - | fmt.Printf("Using environment variable GORDON_PROXY_HTTP_PORT: %s\n", val) | |
| 453 | + | logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val) | |
| 313 | 454 | } | |
| 314 | 455 | } | |
| 315 | 456 | if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" { | |
| 316 | 457 | config.ReverseProxy.CertDir = val | |
| 317 | 458 | if printLogs { | |
| 318 | - | fmt.Printf("Using environment variable GORDON_PROXY_CERT_DIR: %s\n", val) | |
| 459 | + | logger.Info("Using environment variable GORDON_PROXY_CERT_DIR", "value", val) | |
| 319 | 460 | } | |
| 320 | 461 | } | |
| 321 | 462 | if val := os.Getenv("GORDON_PROXY_AUTO_RENEW"); val != "" { | |
| 322 | 463 | config.ReverseProxy.AutoRenew = strings.ToLower(val) == "true" | |
| 323 | 464 | if printLogs { | |
| 324 | - | fmt.Printf("Using environment variable GORDON_PROXY_AUTO_RENEW: %t\n", config.ReverseProxy.AutoRenew) | |
| 465 | + | logger.Info("Using environment variable GORDON_PROXY_AUTO_RENEW", "value", config.ReverseProxy.AutoRenew) | |
| 325 | 466 | } | |
| 326 | 467 | } | |
| 327 | 468 | if val := os.Getenv("GORDON_PROXY_RENEW_BEFORE"); val != "" { | |
| 328 | 469 | if i, err := strconv.Atoi(val); err == nil { | |
| 329 | 470 | config.ReverseProxy.RenewBefore = i | |
| 330 | 471 | if printLogs { | |
| 331 | - | fmt.Printf("Using environment variable GORDON_PROXY_RENEW_BEFORE: %d\n", i) | |
| 472 | + | logger.Info("Using environment variable GORDON_PROXY_RENEW_BEFORE", "value", i) | |
| 332 | 473 | } | |
| 333 | 474 | } | |
| 334 | 475 | } | |
| 335 | 476 | if val := os.Getenv("GORDON_PROXY_LETSENCRYPT_MODE"); val != "" { | |
| 336 | 477 | config.ReverseProxy.LetsEncryptMode = val | |
| 337 | 478 | if printLogs { | |
| 338 | - | fmt.Printf("Using environment variable GORDON_PROXY_LETSENCRYPT_MODE: %s\n", val) | |
| 479 | + | logger.Info("Using environment variable GORDON_PROXY_LETSENCRYPT_MODE", "value", val) | |
| 339 | 480 | } | |
| 340 | 481 | } | |
| 341 | 482 | if val := os.Getenv("GORDON_PROXY_EMAIL"); val != "" { | |
| 342 | 483 | config.ReverseProxy.Email = val | |
| 343 | 484 | if printLogs { | |
| 344 | - | fmt.Printf("Using environment variable GORDON_PROXY_EMAIL: %s\n", val) | |
| 485 | + | logger.Info("Using environment variable GORDON_PROXY_EMAIL", "value", val) | |
| 345 | 486 | } | |
| 346 | 487 | } | |
| 347 | 488 | if val := os.Getenv("GORDON_PROXY_CACHE_SIZE"); val != "" { | |
| 348 | 489 | if i, err := strconv.Atoi(val); err == nil { | |
| 349 | 490 | config.ReverseProxy.CacheSize = i | |
| 350 | 491 | if printLogs { | |
| 351 | - | fmt.Printf("Using environment variable GORDON_PROXY_CACHE_SIZE: %d\n", i) | |
| 492 | + | logger.Info("Using environment variable GORDON_PROXY_CACHE_SIZE", "value", i) | |
| 352 | 493 | } | |
| 353 | 494 | } | |
| 354 | 495 | } | |
| @@ -356,61 +497,172 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 356 | 497 | if i, err := strconv.Atoi(val); err == nil { | |
| 357 | 498 | config.ReverseProxy.GracePeriod = i | |
| 358 | 499 | if printLogs { | |
| 359 | - | fmt.Printf("Using environment variable GORDON_PROXY_GRACE_PERIOD: %d\n", i) | |
| 500 | + | logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i) | |
| 360 | 501 | } | |
| 361 | 502 | } | |
| 362 | 503 | } | |
| 363 | 504 | } | |
| 364 | 505 | ||
| 365 | 506 | func (config *Config) LoadConfig() (*Config, error) { | |
| 366 | - | // Check if we already have a global config loaded | |
| 367 | - | globalConfigMu.RLock() | |
| 368 | - | if globalConfig != nil && config != globalConfig { | |
| 369 | - | // Copy the global config to this config instance | |
| 370 | - | *config = *globalConfig | |
| 371 | - | globalConfigMu.RUnlock() | |
| 372 | - | fmt.Println("Using existing global configuration instead of loading again") | |
| 507 | + | // If we're in a non-interactive environment, use simplified config | |
| 508 | + | nonInteractiveEnv := os.Getenv("GORDON_NONINTERACTIVE") | |
| 509 | + | isNonInteractive := nonInteractiveEnv == "true" || nonInteractiveEnv == "1" || nonInteractiveEnv == "yes" | |
| 510 | + | ||
| 511 | + | if isNonInteractive { | |
| 512 | + | logger.Info("Non-interactive mode detected, using simplified config without prompts") | |
| 513 | + | // Create base configuration without prompts | |
| 514 | + | homeDir, err := os.UserHomeDir() | |
| 515 | + | if err != nil { | |
| 516 | + | homeDir = "/tmp" | |
| 517 | + | logger.Warn("Could not get user home directory, using /tmp instead") | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | // Get default socket paths | |
| 521 | + | sock := "/var/run/docker.sock" | |
| 522 | + | podmansock := "/run/podman/podman.sock" | |
| 523 | + | ||
| 524 | + | // Auto-detect Podman | |
| 525 | + | isPodman, podmanSocket := docker.DetectPodman() | |
| 526 | + | if isPodman { | |
| 527 | + | podmansock = podmanSocket | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | *config = Config{ | |
| 531 | + | General: GeneralConfig{ | |
| 532 | + | StorageDir: homeDir + "/.gordon", | |
| 533 | + | }, | |
| 534 | + | Http: HttpConfig{ | |
| 535 | + | Port: "8080", | |
| 536 | + | Domain: "localhost", | |
| 537 | + | Https: false, | |
| 538 | + | }, | |
| 539 | + | Admin: AdminConfig{ | |
| 540 | + | Path: "/admin", | |
| 541 | + | }, | |
| 542 | + | ContainerEngine: ContainerEngineConfig{ | |
| 543 | + | Sock: sock, | |
| 544 | + | PodmanSock: podmansock, | |
| 545 | + | Podman: isPodman, // Use the auto-detected value | |
| 546 | + | Network: "gordon", | |
| 547 | + | }, | |
| 548 | + | ReverseProxy: ReverseProxyConfig{ | |
| 549 | + | Port: reverseProxyPort, | |
| 550 | + | HttpPort: httpPort, | |
| 551 | + | CertDir: certDir, | |
| 552 | + | AutoRenew: autoRenew, | |
| 553 | + | RenewBefore: renewBefore, | |
| 554 | + | LetsEncryptMode: letsEncryptMode, | |
| 555 | + | CacheSize: cacheSize, | |
| 556 | + | GracePeriod: gracePeriod, | |
| 557 | + | }, | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | // If in container, apply env vars | |
| 561 | + | if docker.IsRunningInContainer() { | |
| 562 | + | logger.Info("Running in container, overriding configuration with environment variables...") | |
| 563 | + | ||
| 564 | + | loadConfigFromEnv(config, true) | |
| 565 | + | } | |
| 566 | + | ||
| 373 | 567 | return config, nil | |
| 374 | 568 | } | |
| 375 | - | globalConfigMu.RUnlock() | |
| 376 | 569 | ||
| 570 | + | // Original LoadConfig implementation continues below for interactive mode | |
| 571 | + | logger.Debug("LoadConfig: Starting LoadConfig function") | |
| 572 | + | ||
| 573 | + | // Log current execution environment for debugging | |
| 574 | + | isInContainer := docker.IsRunningInContainer() | |
| 575 | + | cwd, _ := os.Getwd() | |
| 576 | + | logger.Debug("LoadConfig: Environment information", | |
| 577 | + | "isContainer", isInContainer, | |
| 578 | + | "currentDir", cwd, | |
| 579 | + | "ENV", os.Getenv("ENV"), | |
| 580 | + | "GORDON_NONINTERACTIVE", os.Getenv("GORDON_NONINTERACTIVE")) | |
| 581 | + | ||
| 582 | + | logger.Info("Loading config...") | |
| 583 | + | ||
| 584 | + | // Enable extra debugging for this function if requested | |
| 585 | + | debugLevel := os.Getenv("GORDON_CONFIG_LOG_LEVEL") | |
| 586 | + | if debugLevel == "trace" { | |
| 587 | + | logger.Debug("LoadConfig: TRACE mode enabled, will log detailed steps") | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | // Get the config directory | |
| 591 | + | logger.Debug("LoadConfig: About to call getConfigDir()") | |
| 377 | 592 | configDir, err := getConfigDir() | |
| 378 | 593 | if err != nil { | |
| 594 | + | logger.Error("LoadConfig: Failed to get config directory", "error", err) | |
| 379 | 595 | return nil, fmt.Errorf("error getting configuration directory: %w", err) | |
| 380 | 596 | } | |
| 381 | 597 | ||
| 598 | + | logger.Debug("LoadConfig: Got config directory", "dir", configDir) | |
| 599 | + | logger.Info("Using configuration directory", "dir", configDir) | |
| 600 | + | ||
| 382 | 601 | // Check for directly mounted config.yml in root when in container | |
| 383 | 602 | var configFilePath string | |
| 384 | - | if docker.IsRunningInContainer() && fileExists("/config.yml") { | |
| 385 | - | configFilePath = "/config.yml" | |
| 386 | - | fmt.Println("Found directly mounted config.yml in container root") | |
| 603 | + | logger.Debug("LoadConfig: Checking if running in container") | |
| 604 | + | if docker.IsRunningInContainer() && fileExists("./config.yml") { | |
| 605 | + | configFilePath = "./config.yml" | |
| 606 | + | logger.Info("Found directly mounted config.yml in container current directory") | |
| 387 | 607 | } else { | |
| 388 | 608 | configFilePath = filepath.Join(configDir, "config.yml") | |
| 609 | + | logger.Info("Using config file path", "path", configFilePath) | |
| 389 | 610 | } | |
| 390 | 611 | ||
| 391 | 612 | // Add an indicator if this is not the first time we're loading config | |
| 392 | 613 | if configLogsPrinted { | |
| 393 | - | fmt.Println("Reloading configuration (already loaded previously)...") | |
| 614 | + | logger.Info("Reloading configuration (already loaded previously)...") | |
| 394 | 615 | } | |
| 395 | 616 | ||
| 396 | 617 | configExists := true | |
| 618 | + | var defaultsApplied bool = false | |
| 619 | + | ||
| 620 | + | logger.Debug("LoadConfig: Checking if config file exists", "path", configFilePath) | |
| 397 | 621 | _, err = os.Stat(configFilePath) | |
| 398 | 622 | if errors.Is(err, fs.ErrNotExist) { | |
| 399 | 623 | configExists = false | |
| 400 | - | fmt.Printf("Config file not found, creating it at %s\n", configFilePath) | |
| 624 | + | logger.Info("Config file not found, creating it", "path", configFilePath) | |
| 401 | 625 | ||
| 402 | 626 | // Create config dir if it doesn't exist | |
| 627 | + | logger.Debug("LoadConfig: Creating config directory", "dir", configDir) | |
| 403 | 628 | err = os.MkdirAll(configDir, 0755) | |
| 404 | 629 | if err != nil { | |
| 630 | + | logger.Error("LoadConfig: Failed to create config directory", "error", err) | |
| 405 | 631 | return nil, fmt.Errorf("error creating configuration directory: %w", err) | |
| 406 | 632 | } | |
| 407 | 633 | ||
| 408 | 634 | // Create config file with the default values of ContainerEngineConfig | |
| 635 | + | logger.Debug("LoadConfig: Getting user home directory") | |
| 409 | 636 | homeDir, err := os.UserHomeDir() | |
| 410 | 637 | if err != nil { | |
| 638 | + | logger.Error("LoadConfig: Failed to get user home directory", "error", err) | |
| 411 | 639 | return nil, fmt.Errorf("could not get user home directory: %w", err) | |
| 412 | 640 | } | |
| 413 | 641 | ||
| 642 | + | // Check if running in container before creating the config | |
| 643 | + | logger.Debug("LoadConfig: Checking if running in container for podman setting") | |
| 644 | + | isInContainer := docker.IsRunningInContainer() | |
| 645 | + | logger.Info("Is running in container", "value", isInContainer) | |
| 646 | + | ||
| 647 | + | // Set default podman value based on environment | |
| 648 | + | defaultPodman := false | |
| 649 | + | // Check for non-interactive mode flag | |
| 650 | + | nonInteractive := os.Getenv("GORDON_NONINTERACTIVE") == "true" | |
| 651 | + | ||
| 652 | + | // Only prompt if not in a container and not in non-interactive mode | |
| 653 | + | if !isInContainer && !nonInteractive { | |
| 654 | + | logger.Info("Not in container, prompting for podman preference...") | |
| 655 | + | logger.Debug("LoadConfig: About to prompt for podman preference") | |
| 656 | + | defaultPodman = ReadUserInputNonBlocking("Are you using podman ? (y/n)", "n") == "y" | |
| 657 | + | logger.Debug("LoadConfig: User responded to podman prompt", "usingPodman", defaultPodman) | |
| 658 | + | } else if os.Getenv("GORDON_CONTAINER_PODMAN") == "true" { | |
| 659 | + | defaultPodman = true | |
| 660 | + | logger.Info("Podman container engine detected via environment variable") | |
| 661 | + | } else { | |
| 662 | + | logger.Info("Using Docker as the default container engine in container environment") | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | logger.Debug("LoadConfig: Creating default config structure") | |
| 414 | 666 | *config = Config{ | |
| 415 | 667 | General: GeneralConfig{ | |
| 416 | 668 | StorageDir: homeDir + "/.gordon", | |
| @@ -426,7 +678,7 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 426 | 678 | ContainerEngine: ContainerEngineConfig{ | |
| 427 | 679 | Sock: sock, | |
| 428 | 680 | PodmanSock: podmansock, | |
| 429 | - | Podman: ReadUserInput("Are you using podman ? (y/n)") == "y", | |
| 681 | + | Podman: defaultPodman, | |
| 430 | 682 | Network: "gordon", | |
| 431 | 683 | }, | |
| 432 | 684 | ReverseProxy: ReverseProxyConfig{ | |
| @@ -441,65 +693,76 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 441 | 693 | }, | |
| 442 | 694 | } | |
| 443 | 695 | ||
| 444 | - | // If running in a container, skip the prompt | |
| 445 | - | if docker.IsRunningInContainer() { | |
| 446 | - | config.ContainerEngine.Podman = false | |
| 447 | - | // Handle the Podman case if the GORDON_CONTAINER_PODMAN env var is set to true | |
| 448 | - | if os.Getenv("GORDON_CONTAINER_PODMAN") == "true" { | |
| 449 | - | config.ContainerEngine.Podman = true | |
| 450 | - | fmt.Println("Podman container engine detected via environment variable") | |
| 451 | - | } | |
| 452 | - | } | |
| 696 | + | // In this case, all defaults were applied since we created a new config | |
| 697 | + | defaultsApplied = true | |
| 453 | 698 | } else if err != nil { | |
| 699 | + | logger.Error("LoadConfig: Error checking if config file exists", "error", err) | |
| 454 | 700 | return nil, fmt.Errorf("error checking configuration file: %w", err) | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | // If config file exists, read it | |
| 458 | - | if configExists { | |
| 701 | + | } else { | |
| 702 | + | // If config file exists, read it | |
| 703 | + | logger.Info("Reading existing configuration file...") | |
| 704 | + | logger.Debug("LoadConfig: About to read and unmarshal existing config file") | |
| 459 | 705 | err = readAndUnmarshalConfig(os.DirFS(filepath.Dir(configFilePath)), filepath.Base(configFilePath), config) | |
| 460 | 706 | if err != nil { | |
| 707 | + | logger.Error("LoadConfig: Failed to read/unmarshal config file", "error", err) | |
| 461 | 708 | return nil, err | |
| 462 | 709 | } | |
| 710 | + | logger.Debug("LoadConfig: Successfully read and unmarshaled config file") | |
| 711 | + | ||
| 712 | + | // Apply default values to fields that were not specified in the config file | |
| 713 | + | logger.Info("Applying default values to missing configuration fields...") | |
| 714 | + | defaultsApplied = applyDefaultsToConfig(config) | |
| 463 | 715 | } | |
| 464 | 716 | ||
| 465 | 717 | // Only load environment variables once, after either creating a new config or reading an existing one | |
| 718 | + | logger.Debug("LoadConfig: Checking if running in container for env vars") | |
| 466 | 719 | if docker.IsRunningInContainer() { | |
| 467 | 720 | // Only print logs if we haven't printed them before | |
| 468 | 721 | shouldPrintLogs := !configLogsPrinted | |
| 469 | - | fmt.Println("Running in container, overriding configuration with environment variables...") | |
| 722 | + | logger.Info("Running in container, overriding configuration with environment variables...") | |
| 723 | + | logger.Debug("LoadConfig: About to load config from environment") | |
| 470 | 724 | loadConfigFromEnv(config, shouldPrintLogs) | |
| 471 | 725 | configLogsPrinted = true | |
| 726 | + | logger.Debug("LoadConfig: Finished loading config from environment") | |
| 472 | 727 | } | |
| 473 | 728 | ||
| 474 | 729 | // Load run env | |
| 730 | + | logger.Debug("LoadConfig: Setting run environment") | |
| 475 | 731 | config.Build.RunEnv = os.Getenv("RUN_ENV") | |
| 732 | + | logger.Info("Run environment", "env", config.Build.RunEnv) | |
| 476 | 733 | ||
| 477 | 734 | if config.Build.RunEnv == "" { | |
| 478 | 735 | config.Build.RunEnv = "prod" | |
| 736 | + | logger.Info("No RUN_ENV specified, defaulting to 'prod'") | |
| 479 | 737 | } | |
| 480 | 738 | ||
| 481 | - | // If we created a new config, save it | |
| 482 | - | if !configExists { | |
| 739 | + | // If we created a new config or applied defaults to an existing one, save it | |
| 740 | + | if !configExists || defaultsApplied { | |
| 741 | + | logger.Info("Saving configuration with default values...") | |
| 742 | + | logger.Debug("LoadConfig: About to save configuration with defaults") | |
| 483 | 743 | err = config.SaveConfig() | |
| 484 | 744 | if err != nil { | |
| 485 | - | return nil, fmt.Errorf("error saving new configuration: %w", err) | |
| 745 | + | logger.Error("LoadConfig: Failed to save configuration", "error", err) | |
| 746 | + | return nil, fmt.Errorf("error saving configuration: %w", err) | |
| 486 | 747 | } | |
| 748 | + | logger.Debug("LoadConfig: Successfully saved configuration with defaults") | |
| 487 | 749 | } | |
| 488 | 750 | ||
| 489 | 751 | // Debug output to verify config was loaded correctly | |
| 490 | 752 | // Only print this debug information if we're also printing the env var logs | |
| 491 | 753 | if !configLogsPrinted { | |
| 492 | - | fmt.Printf("Loaded configuration from %s\n", configFilePath) | |
| 493 | - | fmt.Printf("Container engine config - Docker socket: %s, Podman socket: %s, Using Podman: %t, Network: %s\n", | |
| 494 | - | config.ContainerEngine.Sock, | |
| 495 | - | config.ContainerEngine.PodmanSock, | |
| 496 | - | config.ContainerEngine.Podman, | |
| 497 | - | config.ContainerEngine.Network) | |
| 754 | + | logger.Info("Loaded configuration from", "path", configFilePath) | |
| 755 | + | logger.Info("Container engine config", | |
| 756 | + | "docker_socket", config.ContainerEngine.Sock, | |
| 757 | + | "podman_socket", config.ContainerEngine.PodmanSock, | |
| 758 | + | "using_podman", config.ContainerEngine.Podman, | |
| 759 | + | "network", config.ContainerEngine.Network) | |
| 498 | 760 | } else { | |
| 499 | 761 | // Just print a simplified message | |
| 500 | - | fmt.Printf("Loaded configuration from %s\n", configFilePath) | |
| 762 | + | logger.Info("Loaded configuration from", "path", configFilePath) | |
| 501 | 763 | } | |
| 502 | 764 | ||
| 765 | + | logger.Debug("LoadConfig: Completed LoadConfig function") | |
| 503 | 766 | return config, nil | |
| 504 | 767 | } | |
| 505 | 768 | ||
| @@ -517,7 +780,7 @@ func (config *Config) SaveConfig() error { | |||
| 517 | 780 | configFilePath = filepath.Join(configDir, "config.yml") | |
| 518 | 781 | } | |
| 519 | 782 | ||
| 520 | - | fmt.Printf("Saving configuration to %s\n", configFilePath) | |
| 783 | + | logger.Info("Saving configuration to", "path", configFilePath) | |
| 521 | 784 | ||
| 522 | 785 | err = parser.WriteYAMLFile(configFilePath, config) | |
| 523 | 786 | if err != nil { | |
| @@ -562,20 +825,27 @@ func (c *Config) SetToken(token string) { | |||
| 562 | 825 | } | |
| 563 | 826 | ||
| 564 | 827 | func isWSL() bool { | |
| 565 | - | // Check for /proc/version file | |
| 566 | - | if _, err := os.Stat("/proc/version"); err == nil { | |
| 567 | - | content, err := os.ReadFile("/proc/version") | |
| 568 | - | if err == nil && strings.Contains(strings.ToLower(string(content)), "microsoft") { | |
| 569 | - | return true | |
| 570 | - | } | |
| 828 | + | logger.Debug("isWSL: Checking if running in WSL") | |
| 829 | + | // Check if /proc/version exists | |
| 830 | + | _, err := os.Stat("/proc/version") | |
| 831 | + | if err != nil { | |
| 832 | + | logger.Debug("isWSL: /proc/version does not exist, not WSL", "error", err) | |
| 833 | + | return false | |
| 571 | 834 | } | |
| 572 | 835 | ||
| 573 | - | // Check for WSL-specific environment variable | |
| 574 | - | if os.Getenv("WSL_DISTRO_NAME") != "" { | |
| 575 | - | return true | |
| 836 | + | // Read /proc/version file | |
| 837 | + | logger.Debug("isWSL: Reading /proc/version file") | |
| 838 | + | data, err := os.ReadFile("/proc/version") | |
| 839 | + | if err != nil { | |
| 840 | + | logger.Debug("isWSL: Failed to read /proc/version", "error", err) | |
| 841 | + | return false | |
| 576 | 842 | } | |
| 577 | 843 | ||
| 578 | - | return false | |
| 844 | + | // Convert to string and check if it contains "Microsoft" or "WSL" | |
| 845 | + | version := string(data) | |
| 846 | + | isWsl := strings.Contains(strings.ToLower(version), "microsoft") || strings.Contains(strings.ToLower(version), "wsl") | |
| 847 | + | logger.Debug("isWSL: WSL detection result", "isWsl", isWsl) | |
| 848 | + | return isWsl | |
| 579 | 849 | } | |
| 580 | 850 | ||
| 581 | 851 | func (c *HttpConfig) Protocol() string { | |
| @@ -13,15 +13,31 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/bnema/gordon/pkg/docker" | 14 | "github.com/bnema/gordon/pkg/docker" | ||
| 15 | "github.com/bnema/gordon/pkg/parser" | 15 | "github.com/bnema/gordon/pkg/parser" | ||
| 16 | + | "github.com/charmbracelet/log" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | - | // Global singleton configuration instance | |||
| 19 | var ( | 19 | var ( | ||
| 20 | - | globalConfig *Config | 20 | + | logger = log.NewWithOptions(os.Stderr, log.Options{Level: log.InfoLevel}) |
| 21 | - | globalConfigOnce sync.Once | 21 | + | globalConfig *Config |
| 22 | - | globalConfigMu sync.RWMutex | 22 | + | globalConfigOnce sync.Once |
| 23 | + | globalConfigMu sync.RWMutex | |||
| 24 | + | configLogsPrinted bool | |||
| 23 | ) | 25 | ) | ||
| 24 | 26 | ||||
| 27 | + | // Initialize package-level logging configuration | |||
| 28 | + | func init() { | |||
| 29 | + | // Enable timestamp reporting and set format | |||
| 30 | + | log.SetReportTimestamp(true) | |||
| 31 | + | log.SetTimeFormat("15:04:05") | |||
| 32 | + | ||||
| 33 | + | // Check for debug environment variable | |||
| 34 | + | if os.Getenv("ENV") == "dev" { | |||
| 35 | + | // Set logger to debug level | |||
| 36 | + | logger.SetLevel(log.DebugLevel) | |||
| 37 | + | logger.Debug("Debug logging enabled") | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 25 | type Config struct { | 41 | type Config struct { | ||
| 26 | General GeneralConfig `yaml:"General"` | 42 | General GeneralConfig `yaml:"General"` | ||
| 27 | Http HttpConfig `yaml:"Http"` | 43 | Http HttpConfig `yaml:"Http"` | ||
| @@ -77,7 +93,7 @@ type ReverseProxyConfig struct { | |||||
| 77 | // Default values | 93 | // Default values | ||
| 78 | var ( | 94 | var ( | ||
| 79 | sock = "/var/run/docker.sock" | 95 | sock = "/var/run/docker.sock" | ||
| 80 | - | podmansock = "/run/user/1000/podman/podman.sock" | 96 | + | podmansock = "/run/podman/podman.sock" |
| 81 | reverseProxyPort = "443" // Changed to 443 for standard HTTPS | 97 | reverseProxyPort = "443" // Changed to 443 for standard HTTPS | ||
| 82 | httpPort = "80" // Standard HTTP port | 98 | httpPort = "80" // Standard HTTP port | ||
| 83 | certDir = "/certs" | 99 | certDir = "/certs" | ||
| @@ -88,11 +104,100 @@ var ( | |||||
| 88 | gracePeriod = 30 // seconds | 104 | gracePeriod = 30 // seconds | ||
| 89 | ) | 105 | ) | ||
| 90 | 106 | ||||
| 91 | - | // Track whether config logs have been printed already | 107 | + | // applyDefaultsToConfig applies default values to any fields that have zero values |
| 92 | - | var configLogsPrinted bool = false | 108 | + | // Returns true if any defaults were applied |
| 109 | + | func applyDefaultsToConfig(config *Config) bool { | |||
| 110 | + | defaultsApplied := false | |||
| 111 | + | ||||
| 112 | + | // Apply defaults to ReverseProxy config | |||
| 113 | + | if config.ReverseProxy.Port == "" { | |||
| 114 | + | config.ReverseProxy.Port = reverseProxyPort | |||
| 115 | + | logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort) | |||
| 116 | + | defaultsApplied = true | |||
| 117 | + | } | |||
| 118 | + | if config.ReverseProxy.HttpPort == "" { | |||
| 119 | + | config.ReverseProxy.HttpPort = httpPort | |||
| 120 | + | logger.Debug("Applied default value for ReverseProxy.HttpPort", "value", httpPort) | |||
| 121 | + | defaultsApplied = true | |||
| 122 | + | } | |||
| 123 | + | if config.ReverseProxy.CertDir == "" { | |||
| 124 | + | config.ReverseProxy.CertDir = certDir | |||
| 125 | + | logger.Debug("Applied default value for ReverseProxy.CertDir", "value", certDir) | |||
| 126 | + | defaultsApplied = true | |||
| 127 | + | } | |||
| 128 | + | if config.ReverseProxy.RenewBefore == 0 { | |||
| 129 | + | config.ReverseProxy.RenewBefore = renewBefore | |||
| 130 | + | logger.Debug("Applied default value for ReverseProxy.RenewBefore", "value", renewBefore) | |||
| 131 | + | defaultsApplied = true | |||
| 132 | + | } | |||
| 133 | + | if config.ReverseProxy.LetsEncryptMode == "" { | |||
| 134 | + | config.ReverseProxy.LetsEncryptMode = letsEncryptMode | |||
| 135 | + | logger.Debug("Applied default value for ReverseProxy.LetsEncryptMode", "value", letsEncryptMode) | |||
| 136 | + | defaultsApplied = true | |||
| 137 | + | } | |||
| 138 | + | if config.ReverseProxy.CacheSize == 0 { | |||
| 139 | + | config.ReverseProxy.CacheSize = cacheSize | |||
| 140 | + | logger.Debug("Applied default value for ReverseProxy.CacheSize", "value", cacheSize) | |||
| 141 | + | defaultsApplied = true | |||
| 142 | + | } | |||
| 143 | + | if config.ReverseProxy.GracePeriod == 0 { | |||
| 144 | + | config.ReverseProxy.GracePeriod = gracePeriod | |||
| 145 | + | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | |||
| 146 | + | defaultsApplied = true | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Handle autoRenew | |||
| 150 | + | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | |||
| 151 | + | if fileExists(configFilePath) { | |||
| 152 | + | yamlContent, err := os.ReadFile(configFilePath) | |||
| 153 | + | if err == nil { | |||
| 154 | + | // Check if "autoRenew" is explicitly mentioned in the config file | |||
| 155 | + | if !strings.Contains(string(yamlContent), "autoRenew:") { | |||
| 156 | + | config.ReverseProxy.AutoRenew = autoRenew | |||
| 157 | + | logger.Debug("Applied default value for ReverseProxy.AutoRenew", "value", autoRenew) | |||
| 158 | + | defaultsApplied = true | |||
| 159 | + | } else { | |||
| 160 | + | logger.Debug("Keeping explicit value for ReverseProxy.AutoRenew", "value", config.ReverseProxy.AutoRenew) | |||
| 161 | + | } | |||
| 162 | + | } else { | |||
| 163 | + | // If we can't read the file for some reason, apply the default | |||
| 164 | + | if !config.ReverseProxy.AutoRenew { | |||
| 165 | + | config.ReverseProxy.AutoRenew = autoRenew | |||
| 166 | + | logger.Debug("Applied default value for ReverseProxy.AutoRenew", "value", autoRenew) | |||
| 167 | + | defaultsApplied = true | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | } else { | |||
| 171 | + | // If config file doesn't exist yet, set the default | |||
| 172 | + | config.ReverseProxy.AutoRenew = autoRenew | |||
| 173 | + | logger.Debug("Applied default value for ReverseProxy.AutoRenew", "value", autoRenew) | |||
| 174 | + | defaultsApplied = true | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | return defaultsApplied | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // getConfigDirMustExist returns the config directory and creates it if it doesn't exist | |||
| 181 | + | // This is a helper function to avoid error handling in applyDefaultsToConfig | |||
| 182 | + | func getConfigDirMustExist() string { | |||
| 183 | + | configDir, err := getConfigDir() | |||
| 184 | + | if err != nil { | |||
| 185 | + | // Fallback to a reasonable default if there's an error | |||
| 186 | + | homeDir, err := os.UserHomeDir() | |||
| 187 | + | if err != nil { | |||
| 188 | + | return "/tmp/.gordon" | |||
| 189 | + | } | |||
| 190 | + | return filepath.Join(homeDir, ".gordon") | |||
| 191 | + | } | |||
| 192 | + | return configDir | |||
| 193 | + | } | |||
| 93 | 194 | ||||
| 94 | // GetGlobalConfig returns the singleton config instance, loading it if necessary | 195 | // GetGlobalConfig returns the singleton config instance, loading it if necessary | ||
| 95 | func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | 196 | func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | ||
| 197 | + | logger.Debug("GetGlobalConfig: Starting with buildConfig", "buildConfig", buildConfig != nil) | |||
| 198 | + | ||||
| 199 | + | // Try read lock first | |||
| 200 | + | logger.Debug("GetGlobalConfig: Acquiring read lock") | |||
| 96 | globalConfigMu.RLock() | 201 | globalConfigMu.RLock() | ||
| 97 | if globalConfig != nil { | 202 | if globalConfig != nil { | ||
| 98 | // If buildConfig is provided, update the Build field | 203 | // If buildConfig is provided, update the Build field | ||
| @@ -100,15 +205,21 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | |||||
| 100 | globalConfig.Build = *buildConfig | 205 | globalConfig.Build = *buildConfig | ||
| 101 | } | 206 | } | ||
| 102 | config := globalConfig | 207 | config := globalConfig | ||
| 208 | + | logger.Debug("GetGlobalConfig: Config exists, releasing read lock") | |||
| 103 | globalConfigMu.RUnlock() | 209 | globalConfigMu.RUnlock() | ||
| 104 | - | fmt.Println("Using existing global configuration (already loaded)") | 210 | + | logger.Debug("GetGlobalConfig: Using existing global configuration (already loaded)") |
| 105 | return config, nil | 211 | return config, nil | ||
| 106 | } | 212 | } | ||
| 213 | + | logger.Debug("GetGlobalConfig: Config not found, releasing read lock") | |||
| 107 | globalConfigMu.RUnlock() | 214 | globalConfigMu.RUnlock() | ||
| 108 | 215 | ||||
| 109 | - | // Need to load the config | 216 | + | // Need to load the config - acquire write lock |
| 217 | + | logger.Debug("GetGlobalConfig: No existing config, acquiring write lock") | |||
| 110 | globalConfigMu.Lock() | 218 | globalConfigMu.Lock() | ||
| 111 | - | defer globalConfigMu.Unlock() | 219 | + | defer func() { |
| 220 | + | logger.Debug("GetGlobalConfig: Releasing write lock via defer") | |||
| 221 | + | globalConfigMu.Unlock() | |||
| 222 | + | }() | |||
| 112 | 223 | ||||
| 113 | // Double-check in case another goroutine loaded the config while we were waiting | 224 | // Double-check in case another goroutine loaded the config while we were waiting | ||
| 114 | if globalConfig != nil { | 225 | if globalConfig != nil { | ||
| @@ -116,12 +227,10 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | |||||
| 116 | if buildConfig != nil { | 227 | if buildConfig != nil { | ||
| 117 | globalConfig.Build = *buildConfig | 228 | globalConfig.Build = *buildConfig | ||
| 118 | } | 229 | } | ||
| 119 | - | fmt.Println("Using existing global configuration (loaded by another goroutine)") | 230 | + | logger.Debug("GetGlobalConfig: Using existing global configuration (loaded by another goroutine)") |
| 120 | return globalConfig, nil | 231 | return globalConfig, nil | ||
| 121 | } | 232 | } | ||
| 122 | 233 | ||||
| 123 | - | fmt.Println("Loading global configuration for the first time") | |||
| 124 | - | ||||
| 125 | // Initialize config with build info | 234 | // Initialize config with build info | ||
| 126 | config := &Config{} | 235 | config := &Config{} | ||
| 127 | if buildConfig != nil { | 236 | if buildConfig != nil { | ||
| @@ -130,74 +239,102 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | |||||
| 130 | 239 | ||||
| 131 | // Load the config once | 240 | // Load the config once | ||
| 132 | var err error | 241 | var err error | ||
| 242 | + | logger.Debug("GetGlobalConfig: About to execute globalConfigOnce.Do") | |||
| 133 | globalConfigOnce.Do(func() { | 243 | globalConfigOnce.Do(func() { | ||
| 134 | - | config, err = config.LoadConfig() | 244 | + | logger.Debug("Inside globalConfigOnce.Do, loading configuration...") |
| 245 | + | var loadedConfig *Config | |||
| 246 | + | loadedConfig, err = config.LoadConfig() | |||
| 135 | if err == nil { | 247 | if err == nil { | ||
| 136 | - | globalConfig = config | 248 | + | logger.Debug("GetGlobalConfig: LoadConfig successful, setting globalConfig") |
| 137 | - | fmt.Println("Global configuration loaded successfully") | 249 | + | globalConfig = loadedConfig |
| 250 | + | logger.Info("Global configuration loaded successfully") | |||
| 138 | } else { | 251 | } else { | ||
| 139 | - | fmt.Printf("Error loading global configuration: %v\n", err) | 252 | + | logger.Error("Error loading global configuration", "error", err) |
| 140 | } | 253 | } | ||
| 141 | }) | 254 | }) | ||
| 255 | + | logger.Debug("GetGlobalConfig: Completed globalConfigOnce.Do") | |||
| 142 | 256 | ||||
| 143 | - | return globalConfig, err | 257 | + | // Return the global config if it was set, otherwise return the local config |
| 258 | + | if globalConfig != nil { | |||
| 259 | + | return globalConfig, err | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | return config, err | |||
| 144 | } | 263 | } | ||
| 145 | 264 | ||||
| 146 | func getConfigDir() (string, error) { | 265 | func getConfigDir() (string, error) { | ||
| 266 | + | logger.Debug("getConfigDir: Starting getConfigDir function") | |||
| 147 | var configDir string | 267 | var configDir string | ||
| 148 | 268 | ||||
| 269 | + | logger.Debug("getConfigDir: Checking if running in WSL") | |||
| 149 | if isWSL() { | 270 | if isWSL() { | ||
| 271 | + | logger.Debug("getConfigDir: Running in WSL") | |||
| 150 | // Use XDG_CONFIG_HOME for WSL | 272 | // Use XDG_CONFIG_HOME for WSL | ||
| 151 | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { | 273 | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { | ||
| 274 | + | logger.Debug("getConfigDir: Using XDG_CONFIG_HOME environment variable", "value", xdgConfigHome) | |||
| 152 | configDir = filepath.Join(xdgConfigHome, "Gordon") | 275 | configDir = filepath.Join(xdgConfigHome, "Gordon") | ||
| 153 | } else { | 276 | } else { | ||
| 154 | // If XDG_CONFIG_HOME is not set, fall back to default locations | 277 | // If XDG_CONFIG_HOME is not set, fall back to default locations | ||
| 278 | + | logger.Debug("getConfigDir: XDG_CONFIG_HOME not set, getting user home directory") | |||
| 155 | homeDir, err := os.UserHomeDir() | 279 | homeDir, err := os.UserHomeDir() | ||
| 156 | if err != nil { | 280 | if err != nil { | ||
| 157 | // If we can't get the home directory, use a fallback | 281 | // If we can't get the home directory, use a fallback | ||
| 282 | + | logger.Debug("getConfigDir: Failed to get user home directory, using temp dir", "error", err) | |||
| 158 | homeDir = os.TempDir() // Use the system's temp directory as a fallback | 283 | homeDir = os.TempDir() // Use the system's temp directory as a fallback | ||
| 159 | - | fmt.Printf("Warning: Unable to determine home directory. Using temp directory: %s\n", homeDir) | 284 | + | logger.Warn("Warning: Unable to determine home directory. Using temp directory", "dir", homeDir) |
| 160 | } | 285 | } | ||
| 161 | 286 | ||||
| 162 | configDir = filepath.Join(homeDir, ".config", "Gordon") | 287 | configDir = filepath.Join(homeDir, ".config", "Gordon") | ||
| 163 | - | 288 | + | logger.Debug("getConfigDir: Set config directory based on home dir", "dir", configDir) | |
| 164 | } | 289 | } | ||
| 165 | 290 | ||||
| 291 | + | logger.Debug("getConfigDir: Returning WSL config directory", "dir", configDir) | |||
| 166 | return configDir, nil | 292 | return configDir, nil | ||
| 167 | } | 293 | } | ||
| 168 | 294 | ||||
| 295 | + | logger.Debug("getConfigDir: Checking if running in container") | |||
| 169 | if docker.IsRunningInContainer() { | 296 | if docker.IsRunningInContainer() { | ||
| 170 | - | return "/", nil | 297 | + | logger.Debug("getConfigDir: Running in container, using current directory") |
| 298 | + | return ".", nil | |||
| 171 | } | 299 | } | ||
| 172 | 300 | ||||
| 173 | // Check for XDG_CONFIG_HOME first | 301 | // Check for XDG_CONFIG_HOME first | ||
| 302 | + | logger.Debug("getConfigDir: Checking XDG_CONFIG_HOME environment variable") | |||
| 174 | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { | 303 | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { | ||
| 304 | + | logger.Debug("getConfigDir: Using XDG_CONFIG_HOME environment variable", "value", xdgConfigHome) | |||
| 175 | configDir = filepath.Join(xdgConfigHome, "Gordon") | 305 | configDir = filepath.Join(xdgConfigHome, "Gordon") | ||
| 176 | } else { | 306 | } else { | ||
| 177 | // If XDG_CONFIG_HOME is not set, fall back to default locations | 307 | // If XDG_CONFIG_HOME is not set, fall back to default locations | ||
| 308 | + | logger.Debug("getConfigDir: XDG_CONFIG_HOME not set, getting user home directory") | |||
| 178 | homeDir, err := os.UserHomeDir() | 309 | homeDir, err := os.UserHomeDir() | ||
| 179 | if err != nil { | 310 | if err != nil { | ||
| 180 | - | // If we can't get the home directory, use a fallback | 311 | + | logger.Debug("getConfigDir: Failed to get user home directory", "error", err) |
| 181 | - | homeDir = os.TempDir() // Use the system's temp directory as a fallback | 312 | + | return "", fmt.Errorf("could not determine user home directory: %w", err) |
| 182 | - | fmt.Printf("Warning: Unable to determine home directory. Using temp directory: %s\n", homeDir) | |||
| 183 | } | 313 | } | ||
| 184 | 314 | ||||
| 185 | - | // Select the configuration directory based on the OS | 315 | + | // For Windows, use the standard application data directory |
| 186 | if runtime.GOOS == "windows" { | 316 | if runtime.GOOS == "windows" { | ||
| 187 | - | configDir = filepath.Join(homeDir, "AppData", "Roaming", "Gordon") | 317 | + | logger.Debug("getConfigDir: On Windows, using AppData") |
| 318 | + | configDir = filepath.Join(homeDir, "AppData", "Local", "Gordon") | |||
| 188 | } else { | 319 | } else { | ||
| 320 | + | // For Unix systems, use the XDG Base Directory Specification | |||
| 321 | + | logger.Debug("getConfigDir: On Unix system, using XDG pattern") | |||
| 189 | configDir = filepath.Join(homeDir, ".config", "Gordon") | 322 | configDir = filepath.Join(homeDir, ".config", "Gordon") | ||
| 190 | } | 323 | } | ||
| 191 | } | 324 | } | ||
| 192 | 325 | ||||
| 326 | + | logger.Debug("getConfigDir: Returning config directory", "dir", configDir) | |||
| 193 | return configDir, nil | 327 | return configDir, nil | ||
| 194 | } | 328 | } | ||
| 195 | 329 | ||||
| 196 | func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error { | 330 | func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error { | ||
| 331 | + | logger.Debug("readAndUnmarshalConfig: Starting to read and unmarshal config file", "path", filePath) | |||
| 197 | err := parser.ParseYAMLFile(fs, filePath, config) | 332 | err := parser.ParseYAMLFile(fs, filePath, config) | ||
| 198 | if err != nil { | 333 | if err != nil { | ||
| 334 | + | logger.Debug("readAndUnmarshalConfig: Failed to parse YAML file", "error", err) | |||
| 199 | return fmt.Errorf("error reading and unmarshaling configuration file: %w", err) | 335 | return fmt.Errorf("error reading and unmarshaling configuration file: %w", err) | ||
| 200 | } | 336 | } | ||
| 337 | + | logger.Debug("readAndUnmarshalConfig: Successfully parsed YAML file") | |||
| 201 | return nil | 338 | return nil | ||
| 202 | } | 339 | } | ||
| 203 | 340 | ||||
| @@ -207,13 +344,13 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 207 | if val := os.Getenv("GORDON_STORAGE_DIR"); val != "" { | 344 | if val := os.Getenv("GORDON_STORAGE_DIR"); val != "" { | ||
| 208 | config.General.StorageDir = val | 345 | config.General.StorageDir = val | ||
| 209 | if printLogs { | 346 | if printLogs { | ||
| 210 | - | fmt.Printf("Using environment variable GORDON_STORAGE_DIR: %s\n", val) | 347 | + | logger.Info("Using environment variable GORDON_STORAGE_DIR", "value", val) |
| 211 | } | 348 | } | ||
| 212 | } | 349 | } | ||
| 213 | if val := os.Getenv("GORDON_TOKEN"); val != "" { | 350 | if val := os.Getenv("GORDON_TOKEN"); val != "" { | ||
| 214 | config.General.Token = val | 351 | config.General.Token = val | ||
| 215 | if printLogs { | 352 | if printLogs { | ||
| 216 | - | fmt.Printf("Using environment variable GORDON_TOKEN\n") | 353 | + | logger.Info("Using environment variable GORDON_TOKEN") |
| 217 | } | 354 | } | ||
| 218 | } | 355 | } | ||
| 219 | 356 | ||||
| @@ -221,31 +358,31 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 221 | if val := os.Getenv("GORDON_HTTP_PORT"); val != "" { | 358 | if val := os.Getenv("GORDON_HTTP_PORT"); val != "" { | ||
| 222 | config.Http.Port = val | 359 | config.Http.Port = val | ||
| 223 | if printLogs { | 360 | if printLogs { | ||
| 224 | - | fmt.Printf("Using environment variable GORDON_HTTP_PORT: %s\n", val) | 361 | + | logger.Info("Using environment variable GORDON_HTTP_PORT", "value", val) |
| 225 | } | 362 | } | ||
| 226 | } | 363 | } | ||
| 227 | if val := os.Getenv("GORDON_HTTP_DOMAIN"); val != "" { | 364 | if val := os.Getenv("GORDON_HTTP_DOMAIN"); val != "" { | ||
| 228 | config.Http.Domain = val | 365 | config.Http.Domain = val | ||
| 229 | if printLogs { | 366 | if printLogs { | ||
| 230 | - | fmt.Printf("Using environment variable GORDON_HTTP_DOMAIN: %s\n", val) | 367 | + | logger.Info("Using environment variable GORDON_HTTP_DOMAIN", "value", val) |
| 231 | } | 368 | } | ||
| 232 | } | 369 | } | ||
| 233 | if val := os.Getenv("GORDON_HTTP_SUBDOMAIN"); val != "" { | 370 | if val := os.Getenv("GORDON_HTTP_SUBDOMAIN"); val != "" { | ||
| 234 | config.Http.SubDomain = val | 371 | config.Http.SubDomain = val | ||
| 235 | if printLogs { | 372 | if printLogs { | ||
| 236 | - | fmt.Printf("Using environment variable GORDON_HTTP_SUBDOMAIN: %s\n", val) | 373 | + | logger.Info("Using environment variable GORDON_HTTP_SUBDOMAIN", "value", val) |
| 237 | } | 374 | } | ||
| 238 | } | 375 | } | ||
| 239 | if val := os.Getenv("GORDON_HTTP_BACKEND_URL"); val != "" { | 376 | if val := os.Getenv("GORDON_HTTP_BACKEND_URL"); val != "" { | ||
| 240 | config.Http.BackendURL = val | 377 | config.Http.BackendURL = val | ||
| 241 | if printLogs { | 378 | if printLogs { | ||
| 242 | - | fmt.Printf("Using environment variable GORDON_HTTP_BACKEND_URL: %s\n", val) | 379 | + | logger.Info("Using environment variable GORDON_HTTP_BACKEND_URL", "value", val) |
| 243 | } | 380 | } | ||
| 244 | } | 381 | } | ||
| 245 | if val := os.Getenv("GORDON_HTTP_HTTPS"); val != "" { | 382 | if val := os.Getenv("GORDON_HTTP_HTTPS"); val != "" { | ||
| 246 | config.Http.Https = strings.ToLower(val) == "true" | 383 | config.Http.Https = strings.ToLower(val) == "true" | ||
| 247 | if printLogs { | 384 | if printLogs { | ||
| 248 | - | fmt.Printf("Using environment variable GORDON_HTTP_HTTPS: %t\n", config.Http.Https) | 385 | + | logger.Info("Using environment variable GORDON_HTTP_HTTPS", "value", config.Http.Https) |
| 249 | } | 386 | } | ||
| 250 | } | 387 | } | ||
| 251 | 388 | ||||
| @@ -253,7 +390,7 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 253 | if val := os.Getenv("GORDON_ADMIN_PATH"); val != "" { | 390 | if val := os.Getenv("GORDON_ADMIN_PATH"); val != "" { | ||
| 254 | config.Admin.Path = val | 391 | config.Admin.Path = val | ||
| 255 | if printLogs { | 392 | if printLogs { | ||
| 256 | - | fmt.Printf("Using environment variable GORDON_ADMIN_PATH: %s\n", val) | 393 | + | logger.Info("Using environment variable GORDON_ADMIN_PATH", "value", val) |
| 257 | } | 394 | } | ||
| 258 | } | 395 | } | ||
| 259 | 396 | ||||
| @@ -261,41 +398,45 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 261 | if val := os.Getenv("GORDON_CONTAINER_SOCK"); val != "" { | 398 | if val := os.Getenv("GORDON_CONTAINER_SOCK"); val != "" { | ||
| 262 | config.ContainerEngine.Sock = val | 399 | config.ContainerEngine.Sock = val | ||
| 263 | if printLogs { | 400 | if printLogs { | ||
| 264 | - | fmt.Printf("Using environment variable GORDON_CONTAINER_SOCK: %s\n", val) | 401 | + | logger.Info("Using environment variable GORDON_CONTAINER_SOCK", "value", val) |
| 265 | } | 402 | } | ||
| 266 | } | 403 | } | ||
| 267 | - | if val := os.Getenv("GORDON_CONTAINER_PODMAN_SOCK"); val != "" { | 404 | + | if val := os.Getenv("GORDON_CONTAINER_PODMANSOCK"); val != "" { |
| 268 | config.ContainerEngine.PodmanSock = val | 405 | config.ContainerEngine.PodmanSock = val | ||
| 269 | if printLogs { | 406 | if printLogs { | ||
| 270 | - | fmt.Printf("Using environment variable GORDON_CONTAINER_PODMAN_SOCK: %s\n", val) | 407 | + | logger.Info("Using environment variable GORDON_CONTAINER_PODMANSOCK", "value", val) |
| 271 | } | 408 | } | ||
| 272 | } | 409 | } | ||
| 273 | if val := os.Getenv("GORDON_CONTAINER_PODMAN"); val != "" { | 410 | if val := os.Getenv("GORDON_CONTAINER_PODMAN"); val != "" { | ||
| 274 | config.ContainerEngine.Podman = strings.ToLower(val) == "true" | 411 | config.ContainerEngine.Podman = strings.ToLower(val) == "true" | ||
| 275 | if printLogs { | 412 | if printLogs { | ||
| 276 | - | fmt.Printf("Using environment variable GORDON_CONTAINER_PODMAN: %t\n", config.ContainerEngine.Podman) | 413 | + | logger.Info("Using environment variable GORDON_CONTAINER_PODMAN", "value", config.ContainerEngine.Podman) |
| 277 | } | 414 | } | ||
| 278 | - | 415 | + | } else { | |
| 279 | - | // If using Podman, and Sock is empty but PodmanSock is set, use PodmanSock for Sock field | 416 | + | // Auto-detect Podman if not specified in environment |
| 280 | - | if config.ContainerEngine.Podman && config.ContainerEngine.Sock == "" && config.ContainerEngine.PodmanSock != "" { | 417 | + | isPodman, podmanSocket := docker.DetectPodman() |
| 281 | - | config.ContainerEngine.Sock = config.ContainerEngine.PodmanSock | 418 | + | if isPodman { |
| 419 | + | config.ContainerEngine.Podman = true | |||
| 420 | + | config.ContainerEngine.PodmanSock = podmanSocket | |||
| 282 | if printLogs { | 421 | if printLogs { | ||
| 283 | - | fmt.Printf("Setting ContainerEngine.Sock to PodmanSock value: %s\n", config.ContainerEngine.Sock) | 422 | + | logger.Info("Automatically detected Podman installation", |
| 423 | + | "using_podman", true, | |||
| 424 | + | "socket", podmanSocket) | |||
| 284 | } | 425 | } | ||
| 285 | } | 426 | } | ||
| 286 | } | 427 | } | ||
| 287 | if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" { | 428 | if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" { | ||
| 288 | config.ContainerEngine.Network = val | 429 | config.ContainerEngine.Network = val | ||
| 289 | if printLogs { | 430 | if printLogs { | ||
| 290 | - | fmt.Printf("Using environment variable GORDON_CONTAINER_NETWORK: %s\n", val) | 431 | + | logger.Info("Using environment variable GORDON_CONTAINER_NETWORK", "value", val) |
| 291 | } | 432 | } | ||
| 292 | } | 433 | } | ||
| 293 | 434 | ||||
| 294 | - | // If using Docker socket with podman-compose mounting | 435 | + | // Use Podman socket if Podman is enabled and no Docker socket is specified |
| 295 | - | if config.ContainerEngine.Sock == "" && fileExists("/var/run/docker.sock") { | 436 | + | if config.ContainerEngine.Podman && config.ContainerEngine.Sock == "" && config.ContainerEngine.PodmanSock != "" { |
| 296 | - | config.ContainerEngine.Sock = "/var/run/docker.sock" | 437 | + | config.ContainerEngine.Sock = config.ContainerEngine.PodmanSock |
| 297 | if printLogs { | 438 | if printLogs { | ||
| 298 | - | fmt.Printf("Docker socket found at /var/run/docker.sock, using it\n") | 439 | + | logger.Info("Setting ContainerEngine.Sock to PodmanSock value", "value", config.ContainerEngine.Sock) |
| 299 | } | 440 | } | ||
| 300 | } | 441 | } | ||
| 301 | 442 | ||||
| @@ -303,52 +444,52 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 303 | if val := os.Getenv("GORDON_PROXY_PORT"); val != "" { | 444 | if val := os.Getenv("GORDON_PROXY_PORT"); val != "" { | ||
| 304 | config.ReverseProxy.Port = val | 445 | config.ReverseProxy.Port = val | ||
| 305 | if printLogs { | 446 | if printLogs { | ||
| 306 | - | fmt.Printf("Using environment variable GORDON_PROXY_PORT: %s\n", val) | 447 | + | logger.Info("Using environment variable GORDON_PROXY_PORT", "value", val) |
| 307 | } | 448 | } | ||
| 308 | } | 449 | } | ||
| 309 | if val := os.Getenv("GORDON_PROXY_HTTP_PORT"); val != "" { | 450 | if val := os.Getenv("GORDON_PROXY_HTTP_PORT"); val != "" { | ||
| 310 | config.ReverseProxy.HttpPort = val | 451 | config.ReverseProxy.HttpPort = val | ||
| 311 | if printLogs { | 452 | if printLogs { | ||
| 312 | - | fmt.Printf("Using environment variable GORDON_PROXY_HTTP_PORT: %s\n", val) | 453 | + | logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val) |
| 313 | } | 454 | } | ||
| 314 | } | 455 | } | ||
| 315 | if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" { | 456 | if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" { | ||
| 316 | config.ReverseProxy.CertDir = val | 457 | config.ReverseProxy.CertDir = val | ||
| 317 | if printLogs { | 458 | if printLogs { | ||
| 318 | - | fmt.Printf("Using environment variable GORDON_PROXY_CERT_DIR: %s\n", val) | 459 | + | logger.Info("Using environment variable GORDON_PROXY_CERT_DIR", "value", val) |
| 319 | } | 460 | } | ||
| 320 | } | 461 | } | ||
| 321 | if val := os.Getenv("GORDON_PROXY_AUTO_RENEW"); val != "" { | 462 | if val := os.Getenv("GORDON_PROXY_AUTO_RENEW"); val != "" { | ||
| 322 | config.ReverseProxy.AutoRenew = strings.ToLower(val) == "true" | 463 | config.ReverseProxy.AutoRenew = strings.ToLower(val) == "true" | ||
| 323 | if printLogs { | 464 | if printLogs { | ||
| 324 | - | fmt.Printf("Using environment variable GORDON_PROXY_AUTO_RENEW: %t\n", config.ReverseProxy.AutoRenew) | 465 | + | logger.Info("Using environment variable GORDON_PROXY_AUTO_RENEW", "value", config.ReverseProxy.AutoRenew) |
| 325 | } | 466 | } | ||
| 326 | } | 467 | } | ||
| 327 | if val := os.Getenv("GORDON_PROXY_RENEW_BEFORE"); val != "" { | 468 | if val := os.Getenv("GORDON_PROXY_RENEW_BEFORE"); val != "" { | ||
| 328 | if i, err := strconv.Atoi(val); err == nil { | 469 | if i, err := strconv.Atoi(val); err == nil { | ||
| 329 | config.ReverseProxy.RenewBefore = i | 470 | config.ReverseProxy.RenewBefore = i | ||
| 330 | if printLogs { | 471 | if printLogs { | ||
| 331 | - | fmt.Printf("Using environment variable GORDON_PROXY_RENEW_BEFORE: %d\n", i) | 472 | + | logger.Info("Using environment variable GORDON_PROXY_RENEW_BEFORE", "value", i) |
| 332 | } | 473 | } | ||
| 333 | } | 474 | } | ||
| 334 | } | 475 | } | ||
| 335 | if val := os.Getenv("GORDON_PROXY_LETSENCRYPT_MODE"); val != "" { | 476 | if val := os.Getenv("GORDON_PROXY_LETSENCRYPT_MODE"); val != "" { | ||
| 336 | config.ReverseProxy.LetsEncryptMode = val | 477 | config.ReverseProxy.LetsEncryptMode = val | ||
| 337 | if printLogs { | 478 | if printLogs { | ||
| 338 | - | fmt.Printf("Using environment variable GORDON_PROXY_LETSENCRYPT_MODE: %s\n", val) | 479 | + | logger.Info("Using environment variable GORDON_PROXY_LETSENCRYPT_MODE", "value", val) |
| 339 | } | 480 | } | ||
| 340 | } | 481 | } | ||
| 341 | if val := os.Getenv("GORDON_PROXY_EMAIL"); val != "" { | 482 | if val := os.Getenv("GORDON_PROXY_EMAIL"); val != "" { | ||
| 342 | config.ReverseProxy.Email = val | 483 | config.ReverseProxy.Email = val | ||
| 343 | if printLogs { | 484 | if printLogs { | ||
| 344 | - | fmt.Printf("Using environment variable GORDON_PROXY_EMAIL: %s\n", val) | 485 | + | logger.Info("Using environment variable GORDON_PROXY_EMAIL", "value", val) |
| 345 | } | 486 | } | ||
| 346 | } | 487 | } | ||
| 347 | if val := os.Getenv("GORDON_PROXY_CACHE_SIZE"); val != "" { | 488 | if val := os.Getenv("GORDON_PROXY_CACHE_SIZE"); val != "" { | ||
| 348 | if i, err := strconv.Atoi(val); err == nil { | 489 | if i, err := strconv.Atoi(val); err == nil { | ||
| 349 | config.ReverseProxy.CacheSize = i | 490 | config.ReverseProxy.CacheSize = i | ||
| 350 | if printLogs { | 491 | if printLogs { | ||
| 351 | - | fmt.Printf("Using environment variable GORDON_PROXY_CACHE_SIZE: %d\n", i) | 492 | + | logger.Info("Using environment variable GORDON_PROXY_CACHE_SIZE", "value", i) |
| 352 | } | 493 | } | ||
| 353 | } | 494 | } | ||
| 354 | } | 495 | } | ||
| @@ -356,61 +497,172 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 356 | if i, err := strconv.Atoi(val); err == nil { | 497 | if i, err := strconv.Atoi(val); err == nil { | ||
| 357 | config.ReverseProxy.GracePeriod = i | 498 | config.ReverseProxy.GracePeriod = i | ||
| 358 | if printLogs { | 499 | if printLogs { | ||
| 359 | - | fmt.Printf("Using environment variable GORDON_PROXY_GRACE_PERIOD: %d\n", i) | 500 | + | logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i) |
| 360 | } | 501 | } | ||
| 361 | } | 502 | } | ||
| 362 | } | 503 | } | ||
| 363 | } | 504 | } | ||
| 364 | 505 | ||||
| 365 | func (config *Config) LoadConfig() (*Config, error) { | 506 | func (config *Config) LoadConfig() (*Config, error) { | ||
| 366 | - | // Check if we already have a global config loaded | 507 | + | // If we're in a non-interactive environment, use simplified config |
| 367 | - | globalConfigMu.RLock() | 508 | + | nonInteractiveEnv := os.Getenv("GORDON_NONINTERACTIVE") |
| 368 | - | if globalConfig != nil && config != globalConfig { | 509 | + | isNonInteractive := nonInteractiveEnv == "true" || nonInteractiveEnv == "1" || nonInteractiveEnv == "yes" |
| 369 | - | // Copy the global config to this config instance | 510 | + | |
| 370 | - | *config = *globalConfig | 511 | + | if isNonInteractive { |
| 371 | - | globalConfigMu.RUnlock() | 512 | + | logger.Info("Non-interactive mode detected, using simplified config without prompts") |
| 372 | - | fmt.Println("Using existing global configuration instead of loading again") | 513 | + | // Create base configuration without prompts |
| 514 | + | homeDir, err := os.UserHomeDir() | |||
| 515 | + | if err != nil { | |||
| 516 | + | homeDir = "/tmp" | |||
| 517 | + | logger.Warn("Could not get user home directory, using /tmp instead") | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | // Get default socket paths | |||
| 521 | + | sock := "/var/run/docker.sock" | |||
| 522 | + | podmansock := "/run/podman/podman.sock" | |||
| 523 | + | ||||
| 524 | + | // Auto-detect Podman | |||
| 525 | + | isPodman, podmanSocket := docker.DetectPodman() | |||
| 526 | + | if isPodman { | |||
| 527 | + | podmansock = podmanSocket | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | *config = Config{ | |||
| 531 | + | General: GeneralConfig{ | |||
| 532 | + | StorageDir: homeDir + "/.gordon", | |||
| 533 | + | }, | |||
| 534 | + | Http: HttpConfig{ | |||
| 535 | + | Port: "8080", | |||
| 536 | + | Domain: "localhost", | |||
| 537 | + | Https: false, | |||
| 538 | + | }, | |||
| 539 | + | Admin: AdminConfig{ | |||
| 540 | + | Path: "/admin", | |||
| 541 | + | }, | |||
| 542 | + | ContainerEngine: ContainerEngineConfig{ | |||
| 543 | + | Sock: sock, | |||
| 544 | + | PodmanSock: podmansock, | |||
| 545 | + | Podman: isPodman, // Use the auto-detected value | |||
| 546 | + | Network: "gordon", | |||
| 547 | + | }, | |||
| 548 | + | ReverseProxy: ReverseProxyConfig{ | |||
| 549 | + | Port: reverseProxyPort, | |||
| 550 | + | HttpPort: httpPort, | |||
| 551 | + | CertDir: certDir, | |||
| 552 | + | AutoRenew: autoRenew, | |||
| 553 | + | RenewBefore: renewBefore, | |||
| 554 | + | LetsEncryptMode: letsEncryptMode, | |||
| 555 | + | CacheSize: cacheSize, | |||
| 556 | + | GracePeriod: gracePeriod, | |||
| 557 | + | }, | |||
| 558 | + | } | |||
| 559 | + | ||||
| 560 | + | // If in container, apply env vars | |||
| 561 | + | if docker.IsRunningInContainer() { | |||
| 562 | + | logger.Info("Running in container, overriding configuration with environment variables...") | |||
| 563 | + | ||||
| 564 | + | loadConfigFromEnv(config, true) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 373 | return config, nil | 567 | return config, nil | ||
| 374 | } | 568 | } | ||
| 375 | - | globalConfigMu.RUnlock() | |||
| 376 | 569 | ||||
| 570 | + | // Original LoadConfig implementation continues below for interactive mode | |||
| 571 | + | logger.Debug("LoadConfig: Starting LoadConfig function") | |||
| 572 | + | ||||
| 573 | + | // Log current execution environment for debugging | |||
| 574 | + | isInContainer := docker.IsRunningInContainer() | |||
| 575 | + | cwd, _ := os.Getwd() | |||
| 576 | + | logger.Debug("LoadConfig: Environment information", | |||
| 577 | + | "isContainer", isInContainer, | |||
| 578 | + | "currentDir", cwd, | |||
| 579 | + | "ENV", os.Getenv("ENV"), | |||
| 580 | + | "GORDON_NONINTERACTIVE", os.Getenv("GORDON_NONINTERACTIVE")) | |||
| 581 | + | ||||
| 582 | + | logger.Info("Loading config...") | |||
| 583 | + | ||||
| 584 | + | // Enable extra debugging for this function if requested | |||
| 585 | + | debugLevel := os.Getenv("GORDON_CONFIG_LOG_LEVEL") | |||
| 586 | + | if debugLevel == "trace" { | |||
| 587 | + | logger.Debug("LoadConfig: TRACE mode enabled, will log detailed steps") | |||
| 588 | + | } | |||
| 589 | + | ||||
| 590 | + | // Get the config directory | |||
| 591 | + | logger.Debug("LoadConfig: About to call getConfigDir()") | |||
| 377 | configDir, err := getConfigDir() | 592 | configDir, err := getConfigDir() | ||
| 378 | if err != nil { | 593 | if err != nil { | ||
| 594 | + | logger.Error("LoadConfig: Failed to get config directory", "error", err) | |||
| 379 | return nil, fmt.Errorf("error getting configuration directory: %w", err) | 595 | return nil, fmt.Errorf("error getting configuration directory: %w", err) | ||
| 380 | } | 596 | } | ||
| 381 | 597 | ||||
| 598 | + | logger.Debug("LoadConfig: Got config directory", "dir", configDir) | |||
| 599 | + | logger.Info("Using configuration directory", "dir", configDir) | |||
| 600 | + | ||||
| 382 | // Check for directly mounted config.yml in root when in container | 601 | // Check for directly mounted config.yml in root when in container | ||
| 383 | var configFilePath string | 602 | var configFilePath string | ||
| 384 | - | if docker.IsRunningInContainer() && fileExists("/config.yml") { | 603 | + | logger.Debug("LoadConfig: Checking if running in container") |
| 385 | - | configFilePath = "/config.yml" | 604 | + | if docker.IsRunningInContainer() && fileExists("./config.yml") { |
| 386 | - | fmt.Println("Found directly mounted config.yml in container root") | 605 | + | configFilePath = "./config.yml" |
| 606 | + | logger.Info("Found directly mounted config.yml in container current directory") | |||
| 387 | } else { | 607 | } else { | ||
| 388 | configFilePath = filepath.Join(configDir, "config.yml") | 608 | configFilePath = filepath.Join(configDir, "config.yml") | ||
| 609 | + | logger.Info("Using config file path", "path", configFilePath) | |||
| 389 | } | 610 | } | ||
| 390 | 611 | ||||
| 391 | // Add an indicator if this is not the first time we're loading config | 612 | // Add an indicator if this is not the first time we're loading config | ||
| 392 | if configLogsPrinted { | 613 | if configLogsPrinted { | ||
| 393 | - | fmt.Println("Reloading configuration (already loaded previously)...") | 614 | + | logger.Info("Reloading configuration (already loaded previously)...") |
| 394 | } | 615 | } | ||
| 395 | 616 | ||||
| 396 | configExists := true | 617 | configExists := true | ||
| 618 | + | var defaultsApplied bool = false | |||
| 619 | + | ||||
| 620 | + | logger.Debug("LoadConfig: Checking if config file exists", "path", configFilePath) | |||
| 397 | _, err = os.Stat(configFilePath) | 621 | _, err = os.Stat(configFilePath) | ||
| 398 | if errors.Is(err, fs.ErrNotExist) { | 622 | if errors.Is(err, fs.ErrNotExist) { | ||
| 399 | configExists = false | 623 | configExists = false | ||
| 400 | - | fmt.Printf("Config file not found, creating it at %s\n", configFilePath) | 624 | + | logger.Info("Config file not found, creating it", "path", configFilePath) |
| 401 | 625 | ||||
| 402 | // Create config dir if it doesn't exist | 626 | // Create config dir if it doesn't exist | ||
| 627 | + | logger.Debug("LoadConfig: Creating config directory", "dir", configDir) | |||
| 403 | err = os.MkdirAll(configDir, 0755) | 628 | err = os.MkdirAll(configDir, 0755) | ||
| 404 | if err != nil { | 629 | if err != nil { | ||
| 630 | + | logger.Error("LoadConfig: Failed to create config directory", "error", err) | |||
| 405 | return nil, fmt.Errorf("error creating configuration directory: %w", err) | 631 | return nil, fmt.Errorf("error creating configuration directory: %w", err) | ||
| 406 | } | 632 | } | ||
| 407 | 633 | ||||
| 408 | // Create config file with the default values of ContainerEngineConfig | 634 | // Create config file with the default values of ContainerEngineConfig | ||
| 635 | + | logger.Debug("LoadConfig: Getting user home directory") | |||
| 409 | homeDir, err := os.UserHomeDir() | 636 | homeDir, err := os.UserHomeDir() | ||
| 410 | if err != nil { | 637 | if err != nil { | ||
| 638 | + | logger.Error("LoadConfig: Failed to get user home directory", "error", err) | |||
| 411 | return nil, fmt.Errorf("could not get user home directory: %w", err) | 639 | return nil, fmt.Errorf("could not get user home directory: %w", err) | ||
| 412 | } | 640 | } | ||
| 413 | 641 | ||||
| 642 | + | // Check if running in container before creating the config | |||
| 643 | + | logger.Debug("LoadConfig: Checking if running in container for podman setting") | |||
| 644 | + | isInContainer := docker.IsRunningInContainer() | |||
| 645 | + | logger.Info("Is running in container", "value", isInContainer) | |||
| 646 | + | ||||
| 647 | + | // Set default podman value based on environment | |||
| 648 | + | defaultPodman := false | |||
| 649 | + | // Check for non-interactive mode flag | |||
| 650 | + | nonInteractive := os.Getenv("GORDON_NONINTERACTIVE") == "true" | |||
| 651 | + | ||||
| 652 | + | // Only prompt if not in a container and not in non-interactive mode | |||
| 653 | + | if !isInContainer && !nonInteractive { | |||
| 654 | + | logger.Info("Not in container, prompting for podman preference...") | |||
| 655 | + | logger.Debug("LoadConfig: About to prompt for podman preference") | |||
| 656 | + | defaultPodman = ReadUserInputNonBlocking("Are you using podman ? (y/n)", "n") == "y" | |||
| 657 | + | logger.Debug("LoadConfig: User responded to podman prompt", "usingPodman", defaultPodman) | |||
| 658 | + | } else if os.Getenv("GORDON_CONTAINER_PODMAN") == "true" { | |||
| 659 | + | defaultPodman = true | |||
| 660 | + | logger.Info("Podman container engine detected via environment variable") | |||
| 661 | + | } else { | |||
| 662 | + | logger.Info("Using Docker as the default container engine in container environment") | |||
| 663 | + | } | |||
| 664 | + | ||||
| 665 | + | logger.Debug("LoadConfig: Creating default config structure") | |||
| 414 | *config = Config{ | 666 | *config = Config{ | ||
| 415 | General: GeneralConfig{ | 667 | General: GeneralConfig{ | ||
| 416 | StorageDir: homeDir + "/.gordon", | 668 | StorageDir: homeDir + "/.gordon", | ||
| @@ -426,7 +678,7 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 426 | ContainerEngine: ContainerEngineConfig{ | 678 | ContainerEngine: ContainerEngineConfig{ | ||
| 427 | Sock: sock, | 679 | Sock: sock, | ||
| 428 | PodmanSock: podmansock, | 680 | PodmanSock: podmansock, | ||
| 429 | - | Podman: ReadUserInput("Are you using podman ? (y/n)") == "y", | 681 | + | Podman: defaultPodman, |
| 430 | Network: "gordon", | 682 | Network: "gordon", | ||
| 431 | }, | 683 | }, | ||
| 432 | ReverseProxy: ReverseProxyConfig{ | 684 | ReverseProxy: ReverseProxyConfig{ | ||
| @@ -441,65 +693,76 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 441 | }, | 693 | }, | ||
| 442 | } | 694 | } | ||
| 443 | 695 | ||||
| 444 | - | // If running in a container, skip the prompt | 696 | + | // In this case, all defaults were applied since we created a new config |
| 445 | - | if docker.IsRunningInContainer() { | 697 | + | defaultsApplied = true |
| 446 | - | config.ContainerEngine.Podman = false | |||
| 447 | - | // Handle the Podman case if the GORDON_CONTAINER_PODMAN env var is set to true | |||
| 448 | - | if os.Getenv("GORDON_CONTAINER_PODMAN") == "true" { | |||
| 449 | - | config.ContainerEngine.Podman = true | |||
| 450 | - | fmt.Println("Podman container engine detected via environment variable") | |||
| 451 | - | } | |||
| 452 | - | } | |||
| 453 | } else if err != nil { | 698 | } else if err != nil { | ||
| 699 | + | logger.Error("LoadConfig: Error checking if config file exists", "error", err) | |||
| 454 | return nil, fmt.Errorf("error checking configuration file: %w", err) | 700 | return nil, fmt.Errorf("error checking configuration file: %w", err) | ||
| 455 | - | } | 701 | + | } else { |
| 456 | - | 702 | + | // If config file exists, read it | |
| 457 | - | // If config file exists, read it | 703 | + | logger.Info("Reading existing configuration file...") |
| 458 | - | if configExists { | 704 | + | logger.Debug("LoadConfig: About to read and unmarshal existing config file") |
| 459 | err = readAndUnmarshalConfig(os.DirFS(filepath.Dir(configFilePath)), filepath.Base(configFilePath), config) | 705 | err = readAndUnmarshalConfig(os.DirFS(filepath.Dir(configFilePath)), filepath.Base(configFilePath), config) | ||
| 460 | if err != nil { | 706 | if err != nil { | ||
| 707 | + | logger.Error("LoadConfig: Failed to read/unmarshal config file", "error", err) | |||
| 461 | return nil, err | 708 | return nil, err | ||
| 462 | } | 709 | } | ||
| 710 | + | logger.Debug("LoadConfig: Successfully read and unmarshaled config file") | |||
| 711 | + | ||||
| 712 | + | // Apply default values to fields that were not specified in the config file | |||
| 713 | + | logger.Info("Applying default values to missing configuration fields...") | |||
| 714 | + | defaultsApplied = applyDefaultsToConfig(config) | |||
| 463 | } | 715 | } | ||
| 464 | 716 | ||||
| 465 | // Only load environment variables once, after either creating a new config or reading an existing one | 717 | // Only load environment variables once, after either creating a new config or reading an existing one | ||
| 718 | + | logger.Debug("LoadConfig: Checking if running in container for env vars") | |||
| 466 | if docker.IsRunningInContainer() { | 719 | if docker.IsRunningInContainer() { | ||
| 467 | // Only print logs if we haven't printed them before | 720 | // Only print logs if we haven't printed them before | ||
| 468 | shouldPrintLogs := !configLogsPrinted | 721 | shouldPrintLogs := !configLogsPrinted | ||
| 469 | - | fmt.Println("Running in container, overriding configuration with environment variables...") | 722 | + | logger.Info("Running in container, overriding configuration with environment variables...") |
| 723 | + | logger.Debug("LoadConfig: About to load config from environment") | |||
| 470 | loadConfigFromEnv(config, shouldPrintLogs) | 724 | loadConfigFromEnv(config, shouldPrintLogs) | ||
| 471 | configLogsPrinted = true | 725 | configLogsPrinted = true | ||
| 726 | + | logger.Debug("LoadConfig: Finished loading config from environment") | |||
| 472 | } | 727 | } | ||
| 473 | 728 | ||||
| 474 | // Load run env | 729 | // Load run env | ||
| 730 | + | logger.Debug("LoadConfig: Setting run environment") | |||
| 475 | config.Build.RunEnv = os.Getenv("RUN_ENV") | 731 | config.Build.RunEnv = os.Getenv("RUN_ENV") | ||
| 732 | + | logger.Info("Run environment", "env", config.Build.RunEnv) | |||
| 476 | 733 | ||||
| 477 | if config.Build.RunEnv == "" { | 734 | if config.Build.RunEnv == "" { | ||
| 478 | config.Build.RunEnv = "prod" | 735 | config.Build.RunEnv = "prod" | ||
| 736 | + | logger.Info("No RUN_ENV specified, defaulting to 'prod'") | |||
| 479 | } | 737 | } | ||
| 480 | 738 | ||||
| 481 | - | // If we created a new config, save it | 739 | + | // If we created a new config or applied defaults to an existing one, save it |
| 482 | - | if !configExists { | 740 | + | if !configExists || defaultsApplied { |
| 741 | + | logger.Info("Saving configuration with default values...") | |||
| 742 | + | logger.Debug("LoadConfig: About to save configuration with defaults") | |||
| 483 | err = config.SaveConfig() | 743 | err = config.SaveConfig() | ||
| 484 | if err != nil { | 744 | if err != nil { | ||
| 485 | - | return nil, fmt.Errorf("error saving new configuration: %w", err) | 745 | + | logger.Error("LoadConfig: Failed to save configuration", "error", err) |
| 746 | + | return nil, fmt.Errorf("error saving configuration: %w", err) | |||
| 486 | } | 747 | } | ||
| 748 | + | logger.Debug("LoadConfig: Successfully saved configuration with defaults") | |||
| 487 | } | 749 | } | ||
| 488 | 750 | ||||
| 489 | // Debug output to verify config was loaded correctly | 751 | // Debug output to verify config was loaded correctly | ||
| 490 | // Only print this debug information if we're also printing the env var logs | 752 | // Only print this debug information if we're also printing the env var logs | ||
| 491 | if !configLogsPrinted { | 753 | if !configLogsPrinted { | ||
| 492 | - | fmt.Printf("Loaded configuration from %s\n", configFilePath) | 754 | + | logger.Info("Loaded configuration from", "path", configFilePath) |
| 493 | - | fmt.Printf("Container engine config - Docker socket: %s, Podman socket: %s, Using Podman: %t, Network: %s\n", | 755 | + | logger.Info("Container engine config", |
| 494 | - | config.ContainerEngine.Sock, | 756 | + | "docker_socket", config.ContainerEngine.Sock, |
| 495 | - | config.ContainerEngine.PodmanSock, | 757 | + | "podman_socket", config.ContainerEngine.PodmanSock, |
| 496 | - | config.ContainerEngine.Podman, | 758 | + | "using_podman", config.ContainerEngine.Podman, |
| 497 | - | config.ContainerEngine.Network) | 759 | + | "network", config.ContainerEngine.Network) |
| 498 | } else { | 760 | } else { | ||
| 499 | // Just print a simplified message | 761 | // Just print a simplified message | ||
| 500 | - | fmt.Printf("Loaded configuration from %s\n", configFilePath) | 762 | + | logger.Info("Loaded configuration from", "path", configFilePath) |
| 501 | } | 763 | } | ||
| 502 | 764 | ||||
| 765 | + | logger.Debug("LoadConfig: Completed LoadConfig function") | |||
| 503 | return config, nil | 766 | return config, nil | ||
| 504 | } | 767 | } | ||
| 505 | 768 | ||||
| @@ -517,7 +780,7 @@ func (config *Config) SaveConfig() error { | |||||
| 517 | configFilePath = filepath.Join(configDir, "config.yml") | 780 | configFilePath = filepath.Join(configDir, "config.yml") | ||
| 518 | } | 781 | } | ||
| 519 | 782 | ||||
| 520 | - | fmt.Printf("Saving configuration to %s\n", configFilePath) | 783 | + | logger.Info("Saving configuration to", "path", configFilePath) |
| 521 | 784 | ||||
| 522 | err = parser.WriteYAMLFile(configFilePath, config) | 785 | err = parser.WriteYAMLFile(configFilePath, config) | ||
| 523 | if err != nil { | 786 | if err != nil { | ||
| @@ -562,20 +825,27 @@ func (c *Config) SetToken(token string) { | |||||
| 562 | } | 825 | } | ||
| 563 | 826 | ||||
| 564 | func isWSL() bool { | 827 | func isWSL() bool { | ||
| 565 | - | // Check for /proc/version file | 828 | + | logger.Debug("isWSL: Checking if running in WSL") |
| 566 | - | if _, err := os.Stat("/proc/version"); err == nil { | 829 | + | // Check if /proc/version exists |
| 567 | - | content, err := os.ReadFile("/proc/version") | 830 | + | _, err := os.Stat("/proc/version") |
| 568 | - | if err == nil && strings.Contains(strings.ToLower(string(content)), "microsoft") { | 831 | + | if err != nil { |
| 569 | - | return true | 832 | + | logger.Debug("isWSL: /proc/version does not exist, not WSL", "error", err) |
| 570 | - | } | 833 | + | return false |
| 571 | } | 834 | } | ||
| 572 | 835 | ||||
| 573 | - | // Check for WSL-specific environment variable | 836 | + | // Read /proc/version file |
| 574 | - | if os.Getenv("WSL_DISTRO_NAME") != "" { | 837 | + | logger.Debug("isWSL: Reading /proc/version file") |
| 575 | - | return true | 838 | + | data, err := os.ReadFile("/proc/version") |
| 839 | + | if err != nil { | |||
| 840 | + | logger.Debug("isWSL: Failed to read /proc/version", "error", err) | |||
| 841 | + | return false | |||
| 576 | } | 842 | } | ||
| 577 | 843 | ||||
| 578 | - | return false | 844 | + | // Convert to string and check if it contains "Microsoft" or "WSL" |
| 845 | + | version := string(data) | |||
| 846 | + | isWsl := strings.Contains(strings.ToLower(version), "microsoft") || strings.Contains(strings.ToLower(version), "wsl") | |||
| 847 | + | logger.Debug("isWSL: WSL detection result", "isWsl", isWsl) | |||
| 848 | + | return isWsl | |||
| 579 | } | 849 | } | ||
| 580 | 850 | ||||
| 581 | func (c *HttpConfig) Protocol() string { | 851 | func (c *HttpConfig) Protocol() string { | ||
M internal/common/inputs.go +44 -5
| @@ -3,28 +3,36 @@ package common | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | 5 | "fmt" | |
| 6 | - | "log" | |
| 7 | 6 | "os" | |
| 8 | 7 | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/charmbracelet/log" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // ReadUserInput reads a string input from the user with a prompt. | |
| 12 | 13 | // It handles multi-word inputs and properly checks for errors. | |
| 13 | 14 | func ReadUserInput(prompt string) string { | |
| 14 | - | fmt.Print(prompt + " ") | |
| 15 | + | log.Debug("ReadUserInput: Prompting user for input", "prompt", prompt) | |
| 16 | + | // Make the prompt more visible with newlines and formatting | |
| 17 | + | fmt.Printf("\n>>> %s: ", prompt) | |
| 18 | + | // Flush to ensure the prompt is displayed | |
| 19 | + | os.Stdout.Sync() | |
| 15 | 20 | ||
| 16 | 21 | // Use bufio.NewReader for better input handling | |
| 17 | 22 | reader := bufio.NewReader(os.Stdin) | |
| 23 | + | log.Debug("ReadUserInput: Created reader, waiting for input...") | |
| 18 | 24 | ||
| 19 | 25 | // ReadString reads until newline, handling spaces in input | |
| 20 | 26 | input, err := reader.ReadString('\n') | |
| 21 | 27 | if err != nil { | |
| 22 | - | log.Printf("Error reading input: %v", err) | |
| 28 | + | log.Error("ReadUserInput: Error reading input", "error", err) | |
| 23 | 29 | return "" | |
| 24 | 30 | } | |
| 25 | 31 | ||
| 26 | 32 | // Trim spaces and newline characters | |
| 27 | - | return strings.TrimSpace(input) | |
| 33 | + | result := strings.TrimSpace(input) | |
| 34 | + | log.Debug("ReadUserInput: Received input", "value", result) | |
| 35 | + | return result | |
| 28 | 36 | } | |
| 29 | 37 | ||
| 30 | 38 | // ReadUserInputWithValidation reads user input and validates it using a provided function. | |
| @@ -42,11 +50,13 @@ func ReadUserInputWithValidation(prompt string, validate func(string) bool) stri | |||
| 42 | 50 | // ReadUserInputWithDefault reads user input with a default value if input is empty. | |
| 43 | 51 | func ReadUserInputWithDefault(prompt string, defaultValue string) string { | |
| 44 | 52 | fmt.Printf("%s (default: %s) ", prompt, defaultValue) | |
| 53 | + | // Flush to ensure the prompt is displayed | |
| 54 | + | os.Stdout.Sync() | |
| 45 | 55 | ||
| 46 | 56 | reader := bufio.NewReader(os.Stdin) | |
| 47 | 57 | input, err := reader.ReadString('\n') | |
| 48 | 58 | if err != nil { | |
| 49 | - | log.Printf("Error reading input: %v", err) | |
| 59 | + | log.Error("Error reading input", "error", err) | |
| 50 | 60 | return defaultValue | |
| 51 | 61 | } | |
| 52 | 62 | ||
| @@ -70,3 +80,32 @@ func ReadConfirmation(prompt string) bool { | |||
| 70 | 80 | input = strings.ToLower(input) | |
| 71 | 81 | return input == "y" || input == "yes" | |
| 72 | 82 | } | |
| 83 | + | ||
| 84 | + | // ReadUserInputNonBlocking attempts to read user input but doesn't block in non-interactive environments. | |
| 85 | + | // Instead, it returns the provided default value when not in an interactive terminal. | |
| 86 | + | func ReadUserInputNonBlocking(prompt string, defaultValue string) string { | |
| 87 | + | // Check if we're in an interactive terminal | |
| 88 | + | stat, err := os.Stdin.Stat() | |
| 89 | + | if err != nil { | |
| 90 | + | log.Error("ReadUserInputNonBlocking: Error checking terminal status", "error", err) | |
| 91 | + | log.Info("Defaulting to non-interactive mode due to error", "default", defaultValue) | |
| 92 | + | return defaultValue | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | isTerminal := (stat.Mode() & os.ModeCharDevice) != 0 | |
| 96 | + | log.Debug("ReadUserInputNonBlocking: Terminal status check", | |
| 97 | + | "isInteractive", isTerminal, | |
| 98 | + | "fileMode", stat.Mode().String()) | |
| 99 | + | ||
| 100 | + | if isTerminal { | |
| 101 | + | // In interactive terminal, use normal prompt | |
| 102 | + | log.Debug("ReadUserInputNonBlocking: Using interactive prompt") | |
| 103 | + | return ReadUserInput(prompt) | |
| 104 | + | } else { | |
| 105 | + | // Not in interactive terminal, log and return default | |
| 106 | + | log.Info("Non-interactive environment, using default for prompt", | |
| 107 | + | "prompt", prompt, | |
| 108 | + | "default", defaultValue) | |
| 109 | + | return defaultValue | |
| 110 | + | } | |
| 111 | + | } | |
| @@ -3,28 +3,36 @@ package common | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "log" | |||
| 7 | "os" | 6 | "os" | ||
| 8 | "strings" | 7 | "strings" | ||
| 8 | + | ||||
| 9 | + | "github.com/charmbracelet/log" | |||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | // ReadUserInput reads a string input from the user with a prompt. | 12 | // ReadUserInput reads a string input from the user with a prompt. | ||
| 12 | // It handles multi-word inputs and properly checks for errors. | 13 | // It handles multi-word inputs and properly checks for errors. | ||
| 13 | func ReadUserInput(prompt string) string { | 14 | func ReadUserInput(prompt string) string { | ||
| 14 | - | fmt.Print(prompt + " ") | 15 | + | log.Debug("ReadUserInput: Prompting user for input", "prompt", prompt) |
| 16 | + | // Make the prompt more visible with newlines and formatting | |||
| 17 | + | fmt.Printf("\n>>> %s: ", prompt) | |||
| 18 | + | // Flush to ensure the prompt is displayed | |||
| 19 | + | os.Stdout.Sync() | |||
| 15 | 20 | ||||
| 16 | // Use bufio.NewReader for better input handling | 21 | // Use bufio.NewReader for better input handling | ||
| 17 | reader := bufio.NewReader(os.Stdin) | 22 | reader := bufio.NewReader(os.Stdin) | ||
| 23 | + | log.Debug("ReadUserInput: Created reader, waiting for input...") | |||
| 18 | 24 | ||||
| 19 | // ReadString reads until newline, handling spaces in input | 25 | // ReadString reads until newline, handling spaces in input | ||
| 20 | input, err := reader.ReadString('\n') | 26 | input, err := reader.ReadString('\n') | ||
| 21 | if err != nil { | 27 | if err != nil { | ||
| 22 | - | log.Printf("Error reading input: %v", err) | 28 | + | log.Error("ReadUserInput: Error reading input", "error", err) |
| 23 | return "" | 29 | return "" | ||
| 24 | } | 30 | } | ||
| 25 | 31 | ||||
| 26 | // Trim spaces and newline characters | 32 | // Trim spaces and newline characters | ||
| 27 | - | return strings.TrimSpace(input) | 33 | + | result := strings.TrimSpace(input) |
| 34 | + | log.Debug("ReadUserInput: Received input", "value", result) | |||
| 35 | + | return result | |||
| 28 | } | 36 | } | ||
| 29 | 37 | ||||
| 30 | // ReadUserInputWithValidation reads user input and validates it using a provided function. | 38 | // ReadUserInputWithValidation reads user input and validates it using a provided function. | ||
| @@ -42,11 +50,13 @@ func ReadUserInputWithValidation(prompt string, validate func(string) bool) stri | |||||
| 42 | // ReadUserInputWithDefault reads user input with a default value if input is empty. | 50 | // ReadUserInputWithDefault reads user input with a default value if input is empty. | ||
| 43 | func ReadUserInputWithDefault(prompt string, defaultValue string) string { | 51 | func ReadUserInputWithDefault(prompt string, defaultValue string) string { | ||
| 44 | fmt.Printf("%s (default: %s) ", prompt, defaultValue) | 52 | fmt.Printf("%s (default: %s) ", prompt, defaultValue) | ||
| 53 | + | // Flush to ensure the prompt is displayed | |||
| 54 | + | os.Stdout.Sync() | |||
| 45 | 55 | ||||
| 46 | reader := bufio.NewReader(os.Stdin) | 56 | reader := bufio.NewReader(os.Stdin) | ||
| 47 | input, err := reader.ReadString('\n') | 57 | input, err := reader.ReadString('\n') | ||
| 48 | if err != nil { | 58 | if err != nil { | ||
| 49 | - | log.Printf("Error reading input: %v", err) | 59 | + | log.Error("Error reading input", "error", err) |
| 50 | return defaultValue | 60 | return defaultValue | ||
| 51 | } | 61 | } | ||
| 52 | 62 | ||||
| @@ -70,3 +80,32 @@ func ReadConfirmation(prompt string) bool { | |||||
| 70 | input = strings.ToLower(input) | 80 | input = strings.ToLower(input) | ||
| 71 | return input == "y" || input == "yes" | 81 | return input == "y" || input == "yes" | ||
| 72 | } | 82 | } | ||
| 83 | + | ||||
| 84 | + | // ReadUserInputNonBlocking attempts to read user input but doesn't block in non-interactive environments. | |||
| 85 | + | // Instead, it returns the provided default value when not in an interactive terminal. | |||
| 86 | + | func ReadUserInputNonBlocking(prompt string, defaultValue string) string { | |||
| 87 | + | // Check if we're in an interactive terminal | |||
| 88 | + | stat, err := os.Stdin.Stat() | |||
| 89 | + | if err != nil { | |||
| 90 | + | log.Error("ReadUserInputNonBlocking: Error checking terminal status", "error", err) | |||
| 91 | + | log.Info("Defaulting to non-interactive mode due to error", "default", defaultValue) | |||
| 92 | + | return defaultValue | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | isTerminal := (stat.Mode() & os.ModeCharDevice) != 0 | |||
| 96 | + | log.Debug("ReadUserInputNonBlocking: Terminal status check", | |||
| 97 | + | "isInteractive", isTerminal, | |||
| 98 | + | "fileMode", stat.Mode().String()) | |||
| 99 | + | ||||
| 100 | + | if isTerminal { | |||
| 101 | + | // In interactive terminal, use normal prompt | |||
| 102 | + | log.Debug("ReadUserInputNonBlocking: Using interactive prompt") | |||
| 103 | + | return ReadUserInput(prompt) | |||
| 104 | + | } else { | |||
| 105 | + | // Not in interactive terminal, log and return default | |||
| 106 | + | log.Info("Non-interactive environment, using default for prompt", | |||
| 107 | + | "prompt", prompt, | |||
| 108 | + | "default", defaultValue) | |||
| 109 | + | return defaultValue | |||
| 110 | + | } | |||
| 111 | + | } | |||
M internal/httpserve/handlers/login.go +33 -0
| @@ -37,11 +37,44 @@ func CompareGordonToken(c echo.Context, a *server.App) error { | |||
| 37 | 37 | return nil | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | + | // HandleTokenSubmission handles the token form submission | |
| 41 | + | func HandleTokenSubmission(c echo.Context, a *server.App) error { | |
| 42 | + | // Get the token from the form | |
| 43 | + | formToken := c.FormValue("token") | |
| 44 | + | if formToken == "" { | |
| 45 | + | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=empty_token") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Store the token in the urlToken variable for compatibility with existing code | |
| 49 | + | urlToken = formToken | |
| 50 | + | ||
| 51 | + | // Validate the token | |
| 52 | + | configToken, err := a.Config.GetToken() | |
| 53 | + | if err != nil { | |
| 54 | + | log.Debug("Error getting token from config:", err) | |
| 55 | + | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=server_error") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | if urlToken != configToken { | |
| 59 | + | log.Debug("Invalid token provided:", urlToken) | |
| 60 | + | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=invalid_token") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Redirect to GitHub OAuth | |
| 64 | + | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login/oauth/github") | |
| 65 | + | } | |
| 66 | + | ||
| 40 | 67 | // RenderLoginPage renders the login.html template | |
| 41 | 68 | func RenderLoginPage(c echo.Context, a *server.App) error { | |
| 69 | + | // Check for token in query param for backward compatibility | |
| 42 | 70 | urlToken = c.QueryParam("token") | |
| 71 | + | ||
| 72 | + | // Check for error message | |
| 73 | + | errorMsg := c.QueryParam("error") | |
| 74 | + | ||
| 43 | 75 | data := map[string]interface{}{ | |
| 44 | 76 | "Title": "Login", | |
| 77 | + | "Error": errorMsg, | |
| 45 | 78 | } | |
| 46 | 79 | ||
| 47 | 80 | // Navigate inside the fs.FS to get the template | |
| @@ -37,11 +37,44 @@ func CompareGordonToken(c echo.Context, a *server.App) error { | |||||
| 37 | return nil | 37 | return nil | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | + | // HandleTokenSubmission handles the token form submission | |||
| 41 | + | func HandleTokenSubmission(c echo.Context, a *server.App) error { | |||
| 42 | + | // Get the token from the form | |||
| 43 | + | formToken := c.FormValue("token") | |||
| 44 | + | if formToken == "" { | |||
| 45 | + | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=empty_token") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Store the token in the urlToken variable for compatibility with existing code | |||
| 49 | + | urlToken = formToken | |||
| 50 | + | ||||
| 51 | + | // Validate the token | |||
| 52 | + | configToken, err := a.Config.GetToken() | |||
| 53 | + | if err != nil { | |||
| 54 | + | log.Debug("Error getting token from config:", err) | |||
| 55 | + | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=server_error") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | if urlToken != configToken { | |||
| 59 | + | log.Debug("Invalid token provided:", urlToken) | |||
| 60 | + | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=invalid_token") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Redirect to GitHub OAuth | |||
| 64 | + | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login/oauth/github") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 40 | // RenderLoginPage renders the login.html template | 67 | // RenderLoginPage renders the login.html template | ||
| 41 | func RenderLoginPage(c echo.Context, a *server.App) error { | 68 | func RenderLoginPage(c echo.Context, a *server.App) error { | ||
| 69 | + | // Check for token in query param for backward compatibility | |||
| 42 | urlToken = c.QueryParam("token") | 70 | urlToken = c.QueryParam("token") | ||
| 71 | + | ||||
| 72 | + | // Check for error message | |||
| 73 | + | errorMsg := c.QueryParam("error") | |||
| 74 | + | ||||
| 43 | data := map[string]interface{}{ | 75 | data := map[string]interface{}{ | ||
| 44 | "Title": "Login", | 76 | "Title": "Login", | ||
| 77 | + | "Error": errorMsg, | |||
| 45 | } | 78 | } | ||
| 46 | 79 | ||||
| 47 | // Navigate inside the fs.FS to get the template | 80 | // Navigate inside the fs.FS to get the template | ||
M internal/httpserve/router.go +16 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "github.com/bnema/gordon/internal/httpserve/handlers" | |
| 5 | 5 | "github.com/bnema/gordon/internal/httpserve/middleware" | |
| 6 | 6 | "github.com/bnema/gordon/internal/server" | |
| 7 | + | "github.com/charmbracelet/log" | |
| 7 | 8 | _ "github.com/joho/godotenv/autoload" | |
| 8 | 9 | "github.com/labstack/echo/v4" | |
| 9 | 10 | echomid "github.com/labstack/echo/v4/middleware" | |
| @@ -11,7 +12,8 @@ import ( | |||
| 11 | 12 | ||
| 12 | 13 | // RegisterRoutes registers all routes and middlewares | |
| 13 | 14 | func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |
| 14 | - | // e.Use(echomid.Logger()) too verbose | |
| 15 | + | // Add default Echo logger middleware | |
| 16 | + | e.Use(echomid.Logger()) | |
| 15 | 17 | e.Use(echomid.Recover()) | |
| 16 | 18 | // Initiate the session middleware | |
| 17 | 19 | e.Use(middleware.InitSessionMiddleware(a)) | |
| @@ -102,6 +104,9 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | |||
| 102 | 104 | e.GET(adminPath+"/login", func(c echo.Context) error { | |
| 103 | 105 | return handlers.RenderLoginPage(c, a) | |
| 104 | 106 | }) | |
| 107 | + | e.POST(adminPath+"/login/submit-token", func(c echo.Context) error { | |
| 108 | + | return handlers.HandleTokenSubmission(c, a) | |
| 109 | + | }) | |
| 105 | 110 | e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error { | |
| 106 | 111 | return handlers.StartOAuthGithub(c, a) | |
| 107 | 112 | }) | |
| @@ -127,6 +132,16 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | |||
| 127 | 132 | adminGroup.GET("/cc/:ID", func(c echo.Context) error { | |
| 128 | 133 | return handlers.CreateContainerFullGET(c, a) | |
| 129 | 134 | }, middleware.RequireLogin(a)) | |
| 135 | + | ||
| 136 | + | // Add a ping endpoint for health checks and connection testing | |
| 137 | + | // This endpoint doesn't require authentication since it's used by the proxy for connection testing | |
| 138 | + | adminGroup.GET("/ping", func(c echo.Context) error { | |
| 139 | + | log.Debug("Ping endpoint hit from:", "ip", c.RealIP()) | |
| 140 | + | return c.JSON(200, map[string]string{ | |
| 141 | + | "status": "ok", | |
| 142 | + | "message": "Gordon admin server is running", | |
| 143 | + | }) | |
| 144 | + | }) | |
| 130 | 145 | } | |
| 131 | 146 | ||
| 132 | 147 | // bindHTMXEndpoints binds all HTMX endpoints | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "github.com/bnema/gordon/internal/httpserve/handlers" | 4 | "github.com/bnema/gordon/internal/httpserve/handlers" | ||
| 5 | "github.com/bnema/gordon/internal/httpserve/middleware" | 5 | "github.com/bnema/gordon/internal/httpserve/middleware" | ||
| 6 | "github.com/bnema/gordon/internal/server" | 6 | "github.com/bnema/gordon/internal/server" | ||
| 7 | + | "github.com/charmbracelet/log" | |||
| 7 | _ "github.com/joho/godotenv/autoload" | 8 | _ "github.com/joho/godotenv/autoload" | ||
| 8 | "github.com/labstack/echo/v4" | 9 | "github.com/labstack/echo/v4" | ||
| 9 | echomid "github.com/labstack/echo/v4/middleware" | 10 | echomid "github.com/labstack/echo/v4/middleware" | ||
| @@ -11,7 +12,8 @@ import ( | |||||
| 11 | 12 | ||||
| 12 | // RegisterRoutes registers all routes and middlewares | 13 | // RegisterRoutes registers all routes and middlewares | ||
| 13 | func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | 14 | func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | ||
| 14 | - | // e.Use(echomid.Logger()) too verbose | 15 | + | // Add default Echo logger middleware |
| 16 | + | e.Use(echomid.Logger()) | |||
| 15 | e.Use(echomid.Recover()) | 17 | e.Use(echomid.Recover()) | ||
| 16 | // Initiate the session middleware | 18 | // Initiate the session middleware | ||
| 17 | e.Use(middleware.InitSessionMiddleware(a)) | 19 | e.Use(middleware.InitSessionMiddleware(a)) | ||
| @@ -102,6 +104,9 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | |||||
| 102 | e.GET(adminPath+"/login", func(c echo.Context) error { | 104 | e.GET(adminPath+"/login", func(c echo.Context) error { | ||
| 103 | return handlers.RenderLoginPage(c, a) | 105 | return handlers.RenderLoginPage(c, a) | ||
| 104 | }) | 106 | }) | ||
| 107 | + | e.POST(adminPath+"/login/submit-token", func(c echo.Context) error { | |||
| 108 | + | return handlers.HandleTokenSubmission(c, a) | |||
| 109 | + | }) | |||
| 105 | e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error { | 110 | e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error { | ||
| 106 | return handlers.StartOAuthGithub(c, a) | 111 | return handlers.StartOAuthGithub(c, a) | ||
| 107 | }) | 112 | }) | ||
| @@ -127,6 +132,16 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | |||||
| 127 | adminGroup.GET("/cc/:ID", func(c echo.Context) error { | 132 | adminGroup.GET("/cc/:ID", func(c echo.Context) error { | ||
| 128 | return handlers.CreateContainerFullGET(c, a) | 133 | return handlers.CreateContainerFullGET(c, a) | ||
| 129 | }, middleware.RequireLogin(a)) | 134 | }, middleware.RequireLogin(a)) | ||
| 135 | + | ||||
| 136 | + | // Add a ping endpoint for health checks and connection testing | |||
| 137 | + | // This endpoint doesn't require authentication since it's used by the proxy for connection testing | |||
| 138 | + | adminGroup.GET("/ping", func(c echo.Context) error { | |||
| 139 | + | log.Debug("Ping endpoint hit from:", "ip", c.RealIP()) | |||
| 140 | + | return c.JSON(200, map[string]string{ | |||
| 141 | + | "status": "ok", | |||
| 142 | + | "message": "Gordon admin server is running", | |||
| 143 | + | }) | |||
| 144 | + | }) | |||
| 130 | } | 145 | } | ||
| 131 | 146 | ||||
| 132 | // bindHTMXEndpoints binds all HTMX endpoints | 147 | // bindHTMXEndpoints binds all HTMX endpoints | ||
M internal/proxy/proxy.go +734 -163
| @@ -2,13 +2,22 @@ package proxy | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/ecdsa" | |
| 6 | + | "crypto/elliptic" | |
| 7 | + | "crypto/rand" | |
| 5 | 8 | "crypto/tls" | |
| 9 | + | "crypto/x509" | |
| 10 | + | "crypto/x509/pkix" | |
| 6 | 11 | "database/sql" | |
| 12 | + | "encoding/pem" | |
| 7 | 13 | "fmt" | |
| 14 | + | "math/big" | |
| 15 | + | "net" | |
| 8 | 16 | "net/http" | |
| 9 | 17 | "net/http/httputil" | |
| 10 | 18 | "net/url" | |
| 11 | 19 | "os" | |
| 20 | + | "regexp" | |
| 12 | 21 | "strings" | |
| 13 | 22 | "sync" | |
| 14 | 23 | "time" | |
| @@ -20,6 +29,7 @@ import ( | |||
| 20 | 29 | "github.com/labstack/echo/v4/middleware" | |
| 21 | 30 | "golang.org/x/crypto/acme" | |
| 22 | 31 | "golang.org/x/crypto/acme/autocert" | |
| 32 | + | "golang.org/x/time/rate" | |
| 23 | 33 | ) | |
| 24 | 34 | ||
| 25 | 35 | // Proxy represents the reverse proxy server | |
| @@ -31,6 +41,7 @@ type Proxy struct { | |||
| 31 | 41 | routes map[string]*ProxyRouteInfo | |
| 32 | 42 | mu sync.RWMutex | |
| 33 | 43 | certManager *autocert.Manager | |
| 44 | + | fallbackCert *tls.Certificate // Fallback self-signed certificate | |
| 34 | 45 | serverStarted bool | |
| 35 | 46 | blacklist *BlacklistConfig | |
| 36 | 47 | ||
| @@ -139,32 +150,50 @@ func (p *Proxy) setupMiddleware() { | |||
| 139 | 150 | // Create blacklist middleware for HTTPS | |
| 140 | 151 | p.httpsServer.Use(p.createBlacklistMiddleware()) | |
| 141 | 152 | ||
| 142 | - | // Add custom logger that skips blocked IPs | |
| 143 | - | p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |
| 144 | - | Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}`, | |
| 145 | - | Skipper: func(c echo.Context) bool { | |
| 146 | - | // Skip logging if the request is marked as blacklisted | |
| 147 | - | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok && reqCtx.blacklisted { | |
| 148 | - | return true | |
| 149 | - | } | |
| 150 | - | return false | |
| 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") | |
| 151 | 168 | }, | |
| 152 | 169 | })) | |
| 153 | 170 | ||
| 171 | + | // Add default Echo logger middleware | |
| 172 | + | p.httpsServer.Use(middleware.Logger()) | |
| 173 | + | ||
| 154 | 174 | // Create blacklist middleware for HTTP | |
| 155 | 175 | p.httpServer.Use(p.createBlacklistMiddleware()) | |
| 156 | 176 | ||
| 157 | - | // Add custom logger that skips blocked IPs | |
| 158 | - | p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |
| 159 | - | Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}`, | |
| 160 | - | Skipper: func(c echo.Context) bool { | |
| 161 | - | // Skip logging if the request is marked as blacklisted | |
| 162 | - | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok && reqCtx.blacklisted { | |
| 163 | - | return true | |
| 164 | - | } | |
| 165 | - | return false | |
| 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") | |
| 166 | 192 | }, | |
| 167 | 193 | })) | |
| 194 | + | ||
| 195 | + | // Add default Echo logger middleware for HTTP server | |
| 196 | + | p.httpServer.Use(middleware.Logger()) | |
| 168 | 197 | } | |
| 169 | 198 | ||
| 170 | 199 | // createBlacklistMiddleware returns a middleware function that checks IPs against the blacklist | |
| @@ -174,6 +203,24 @@ func (p *Proxy) createBlacklistMiddleware() echo.MiddlewareFunc { | |||
| 174 | 203 | // Get client IP | |
| 175 | 204 | clientIP := c.RealIP() | |
| 176 | 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 | + | ||
| 177 | 224 | // Check if IP is blacklisted (if blacklist exists) | |
| 178 | 225 | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | |
| 179 | 226 | // Set context for other middleware | |
| @@ -201,6 +248,13 @@ func (p *Proxy) setupCertManager() { | |||
| 201 | 248 | dir = p.app.GetConfig().General.StorageDir + "/certs" | |
| 202 | 249 | } | |
| 203 | 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 | + | ||
| 204 | 258 | // Set up the certificate manager | |
| 205 | 259 | certManager := &autocert.Manager{ | |
| 206 | 260 | Prompt: autocert.AcceptTOS, | |
| @@ -217,13 +271,26 @@ func (p *Proxy) setupCertManager() { | |||
| 217 | 271 | certManager.Client = &acme.Client{ | |
| 218 | 272 | DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory", | |
| 219 | 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) | |
| 220 | 283 | } | |
| 221 | 284 | ||
| 222 | 285 | // Set HostPolicy to allow the admin domain | |
| 223 | 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 | |
| 224 | 291 | certManager.HostPolicy = func(_ context.Context, host string) error { | |
| 225 | - | // Always allow the admin domain | |
| 226 | - | if host == adminDomain { | |
| 292 | + | // Always allow the admin domain and root domain | |
| 293 | + | if host == adminDomain || host == rootDomain { | |
| 227 | 294 | return nil | |
| 228 | 295 | } | |
| 229 | 296 | ||
| @@ -235,7 +302,36 @@ func (p *Proxy) setupCertManager() { | |||
| 235 | 302 | return nil | |
| 236 | 303 | } | |
| 237 | 304 | ||
| 238 | - | return fmt.Errorf("host %q not configured in gordon", host) | |
| 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 | |
| 239 | 335 | } | |
| 240 | 336 | ||
| 241 | 337 | p.certManager = certManager | |
| @@ -247,103 +343,343 @@ func (p *Proxy) setupCertManager() { | |||
| 247 | 343 | go p.requestAdminCertificate() | |
| 248 | 344 | } | |
| 249 | 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 | + | ||
| 250 | 413 | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | |
| 251 | 414 | // for the Gordon admin interface | |
| 252 | 415 | func (p *Proxy) requestAdminCertificate() { | |
| 253 | 416 | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 417 | + | if adminDomain == "" { | |
| 418 | + | return | |
| 419 | + | } | |
| 254 | 420 | ||
| 255 | - | log.Info("Initiating Let's Encrypt certificate request for admin domain", | |
| 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", | |
| 256 | 444 | "domain", adminDomain, | |
| 257 | - | "email", p.config.Email, | |
| 258 | - | "mode", p.config.LetsEncryptMode, | |
| 259 | - | "cert_dir", p.config.CertDir) | |
| 445 | + | "ips", ipStrings) | |
| 260 | 446 | ||
| 261 | - | // Set up an HTTP client that can be used to make a request to trigger certificate generation | |
| 262 | - | client := &http.Client{ | |
| 263 | - | Transport: &http.Transport{ | |
| 264 | - | TLSClientConfig: &tls.Config{ | |
| 265 | - | InsecureSkipVerify: true, // We expect cert errors since we're creating the cert | |
| 266 | - | }, | |
| 267 | - | }, | |
| 268 | - | Timeout: 30 * time.Second, | |
| 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 | |
| 269 | 453 | } | |
| 270 | 454 | ||
| 271 | - | // We need to make an HTTPS request to our domain to trigger the certificate generation | |
| 272 | - | // The first attempt will generate a certificate through Let's Encrypt | |
| 273 | - | url := fmt.Sprintf("https://%s", adminDomain) | |
| 455 | + | // Check if environment is production | |
| 456 | + | mode := "staging" | |
| 457 | + | email := p.config.Email | |
| 274 | 458 | ||
| 275 | - | log.Debug("Sending HTTPS request to trigger certificate creation", | |
| 276 | - | "url", url) | |
| 459 | + | if !p.app.IsDevEnvironment() && email != "" { | |
| 460 | + | mode = "production" | |
| 461 | + | } | |
| 277 | 462 | ||
| 278 | - | // Make the request | |
| 279 | - | resp, err := client.Get(url) | |
| 280 | - | if err != nil { | |
| 281 | - | log.Debug("Certificate request triggered with expected TLS error (this is normal)", | |
| 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", | |
| 282 | 507 | "domain", adminDomain, | |
| 283 | - | "error", err.Error()) | |
| 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 | + | } | |
| 284 | 522 | ||
| 285 | - | // Check if the error message contains indications that DNS might not be configured | |
| 286 | - | if strings.Contains(err.Error(), "no such host") || | |
| 287 | - | strings.Contains(err.Error(), "dial tcp") || | |
| 288 | - | strings.Contains(err.Error(), "lookup") { | |
| 289 | - | log.Error("⚠️ DNS resolution failed for admin domain. Make sure the domain points to this server's IP address", | |
| 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", | |
| 290 | 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) | |
| 291 | 542 | } | |
| 543 | + | } else if certResult != nil { | |
| 544 | + | log.Info("Successfully obtained Let's Encrypt certificate", | |
| 545 | + | "domain", adminDomain) | |
| 292 | 546 | } else { | |
| 293 | - | resp.Body.Close() | |
| 294 | - | log.Info("Initial HTTPS request completed (unexpected)", | |
| 547 | + | log.Error("Unexpected error: received nil certificate but no error", | |
| 295 | 548 | "domain", adminDomain, | |
| 296 | - | "status", resp.StatusCode) | |
| 549 | + | "timeout", "2 minutes") | |
| 550 | + | // Implement retry logic with backoff | |
| 551 | + | go p.retryCertificateRequest(adminDomain, 3, 10*time.Second) | |
| 297 | 552 | } | |
| 553 | + | } | |
| 298 | 554 | ||
| 299 | - | // Wait a bit for the certificate to be obtained | |
| 300 | - | log.Debug("Waiting for certificate generation process to complete") | |
| 301 | - | for i := 1; i <= 12; i++ { // Check for ~60 seconds (12 * 5s) | |
| 302 | - | time.Sleep(5 * time.Second) | |
| 555 | + | // retryCertificateRequest attempts to request a certificate with exponential backoff | |
| 556 | + | func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) { | |
| 557 | + | backoff := initialBackoff | |
| 303 | 558 | ||
| 304 | - | // Try to get the certificate from the cache | |
| 305 | - | hello := &tls.ClientHelloInfo{ServerName: adminDomain} | |
| 306 | - | cert, err := p.certManager.GetCertificate(hello) | |
| 559 | + | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 560 | + | // Wait for backoff period | |
| 561 | + | log.Info("Retrying Let's Encrypt certificate request", | |
| 562 | + | "domain", domain, | |
| 563 | + | "attempt", attempt, | |
| 564 | + | "max_retries", maxRetries, | |
| 565 | + | "wait_time", backoff) | |
| 566 | + | time.Sleep(backoff) | |
| 567 | + | ||
| 568 | + | // Before each retry, check if the HTTP challenge endpoint is accessible | |
| 569 | + | if attempt > 1 { | |
| 570 | + | // Try connecting to our own HTTP server on port 80 | |
| 571 | + | // to verify its availability for Let's Encrypt validation | |
| 572 | + | client := &http.Client{ | |
| 573 | + | Timeout: 5 * time.Second, | |
| 574 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |
| 575 | + | // Don't follow redirects for this test | |
| 576 | + | return http.ErrUseLastResponse | |
| 577 | + | }, | |
| 578 | + | } | |
| 307 | 579 | ||
| 308 | - | if err == nil && cert != nil && cert.Leaf != nil { | |
| 309 | - | log.Info("✅ Successfully obtained certificate for admin domain", | |
| 310 | - | "domain", adminDomain, | |
| 311 | - | "cert_expiry", cert.Leaf.NotAfter.Format("2006-01-02 15:04:05"), | |
| 312 | - | "issuer", cert.Leaf.Issuer.CommonName, | |
| 313 | - | "elapsed_time", fmt.Sprintf("%ds", i*5)) | |
| 314 | - | return | |
| 580 | + | // Test the ACME challenge path with a fake token | |
| 581 | + | testURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test-token", domain) | |
| 582 | + | resp, err := client.Get(testURL) | |
| 583 | + | ||
| 584 | + | if err != nil { | |
| 585 | + | log.Warn("HTTP challenge endpoint might not be accessible", | |
| 586 | + | "domain", domain, | |
| 587 | + | "url", testURL, | |
| 588 | + | "error", err, | |
| 589 | + | "solution", "Ensure port 80 is accessible and not blocked by firewall") | |
| 590 | + | } else { | |
| 591 | + | resp.Body.Close() | |
| 592 | + | log.Info("HTTP challenge endpoint is accessible", | |
| 593 | + | "domain", domain, | |
| 594 | + | "url", testURL, | |
| 595 | + | "status", resp.StatusCode) | |
| 596 | + | } | |
| 315 | 597 | } | |
| 316 | 598 | ||
| 317 | - | log.Debug("Certificate not ready yet, continuing to wait", | |
| 318 | - | "attempt", i, | |
| 319 | - | "elapsed_time", fmt.Sprintf("%ds", i*5)) | |
| 320 | - | } | |
| 599 | + | // Create context with timeout for this attempt | |
| 600 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) | |
| 601 | + | ||
| 602 | + | // Request certificate | |
| 603 | + | err := func(ctx context.Context) error { | |
| 604 | + | defer cancel() | |
| 605 | + | cert, certErr := p.certManager.GetCertificate(&tls.ClientHelloInfo{ | |
| 606 | + | ServerName: domain, | |
| 607 | + | }) | |
| 608 | + | if cert == nil && certErr == nil { | |
| 609 | + | return fmt.Errorf("certificate is nil but no error returned") | |
| 610 | + | } | |
| 611 | + | return certErr | |
| 612 | + | }(ctx) | |
| 321 | 613 | ||
| 322 | - | // Final check | |
| 323 | - | hello := &tls.ClientHelloInfo{ServerName: adminDomain} | |
| 324 | - | cert, err := p.certManager.GetCertificate(hello) | |
| 614 | + | // Check result | |
| 615 | + | if err == nil { | |
| 616 | + | log.Info("Successfully obtained Let's Encrypt certificate on retry", | |
| 617 | + | "domain", domain, | |
| 618 | + | "attempt", attempt) | |
| 619 | + | return | |
| 620 | + | } | |
| 325 | 621 | ||
| 326 | - | if err != nil { | |
| 327 | - | log.Error("Failed to request certificate for admin domain after multiple attempts", | |
| 328 | - | "domain", adminDomain, | |
| 622 | + | log.Error("Let's Encrypt certificate request retry failed", | |
| 623 | + | "domain", domain, | |
| 624 | + | "attempt", attempt, | |
| 329 | 625 | "error", err) | |
| 330 | 626 | ||
| 331 | - | // Let's provide clear guidance about what might be wrong | |
| 332 | - | log.Error("⚠️ Certificate generation troubleshooting guide:") | |
| 333 | - | log.Error("1. Ensure DNS is correctly configured with the domain pointing to this server's IP") | |
| 334 | - | log.Error("2. Make sure port 80 is open and accessible from the internet for HTTP-01 challenge verification") | |
| 335 | - | log.Error("3. Check for rate limits from Let's Encrypt (https://letsencrypt.org/docs/rate-limits/)") | |
| 336 | - | log.Error("4. Verify that the domain belongs to you and is properly registered") | |
| 337 | - | } else if cert != nil && cert.Leaf != nil { | |
| 338 | - | log.Info("✅ Successfully obtained certificate for admin domain", | |
| 339 | - | "domain", adminDomain, | |
| 340 | - | "cert_expiry", cert.Leaf.NotAfter.Format("2006-01-02 15:04:05"), | |
| 341 | - | "issuer", cert.Leaf.Issuer.CommonName) | |
| 342 | - | } else { | |
| 343 | - | log.Warn("Certificate request in progress for admin domain - may take longer to complete", | |
| 344 | - | "domain", adminDomain) | |
| 345 | - | log.Info("You may need to restart the server once your DNS propagation is complete") | |
| 627 | + | // Provide more detailed diagnostics based on the error | |
| 628 | + | if strings.Contains(strings.ToLower(err.Error()), "connection refused") || | |
| 629 | + | strings.Contains(strings.ToLower(err.Error()), "timeout") { | |
| 630 | + | log.Error("Let's Encrypt connection failed - this typically indicates:", | |
| 631 | + | "issue_1", "Port 80 is not accessible from the internet", | |
| 632 | + | "issue_2", "Firewall is blocking inbound connections", | |
| 633 | + | "issue_3", "DNS records not properly propagated", | |
| 634 | + | "solution", "Check firewall settings and DNS configuration") | |
| 635 | + | } else if strings.Contains(strings.ToLower(err.Error()), "unauthorized") { | |
| 636 | + | log.Error("Let's Encrypt authorization failed - this typically indicates:", | |
| 637 | + | "issue", "Domain ownership validation failed", | |
| 638 | + | "solution", "Ensure the server is publicly accessible on port 80") | |
| 639 | + | } else if strings.Contains(strings.ToLower(err.Error()), "ratelimited") { | |
| 640 | + | // Extract the retry-after date if present | |
| 641 | + | retryAfterStr := "see error message for details" | |
| 642 | + | if strings.Contains(err.Error(), "retry after") { | |
| 643 | + | parts := strings.Split(err.Error(), "retry after") | |
| 644 | + | if len(parts) > 1 { | |
| 645 | + | retryAfterStr = strings.TrimSpace(parts[1]) | |
| 646 | + | if idx := strings.Index(retryAfterStr, ":"); idx > 0 { | |
| 647 | + | retryAfterStr = retryAfterStr[:idx] | |
| 648 | + | } | |
| 649 | + | } | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | log.Error("Let's Encrypt rate limit reached - cannot issue more certificates for this domain yet", | |
| 653 | + | "domain", domain, | |
| 654 | + | "retry_after", retryAfterStr, | |
| 655 | + | "solution", "Wait until the rate limit expires, then restart the server") | |
| 656 | + | ||
| 657 | + | // No point in retrying on rate limit errors | |
| 658 | + | return | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | // Increase backoff for next attempt (exponential backoff) | |
| 662 | + | backoff *= 2 | |
| 346 | 663 | } | |
| 664 | + | ||
| 665 | + | log.Error("All Let's Encrypt certificate request retries failed", | |
| 666 | + | "domain", domain, | |
| 667 | + | "max_retries", maxRetries, | |
| 668 | + | "fallback", "Using self-signed certificate") | |
| 669 | + | ||
| 670 | + | // At this point, all retries have failed, so we'll rely on the fallback self-signed certificate | |
| 671 | + | // But let's log this prominently for debugging | |
| 672 | + | log.Error("⚠️ HTTPS is using a self-signed certificate which browsers will warn about", | |
| 673 | + | "domain", domain, | |
| 674 | + | "reason", "Let's Encrypt certificate issuance failed", | |
| 675 | + | "solution", "Check network settings and Let's Encrypt status") | |
| 676 | + | ||
| 677 | + | // Suggest checking the common issues | |
| 678 | + | log.Error("Common Let's Encrypt issues to check:", | |
| 679 | + | "check_1", "Ensure ports 80 and 443 are open on your firewall", | |
| 680 | + | "check_2", "Verify DNS records point to your server IP", | |
| 681 | + | "check_3", "Make sure no other services are running on ports 80/443", | |
| 682 | + | "check_4", "Check if Let's Encrypt service is having issues (https://letsencrypt.status.io/)") | |
| 347 | 683 | } | |
| 348 | 684 | ||
| 349 | 685 | // Start loads the routes from the database and starts the proxy server | |
| @@ -377,7 +713,7 @@ func (p *Proxy) Start() error { | |||
| 377 | 713 | ||
| 378 | 714 | // In a container environment, we need to use the host container IP | |
| 379 | 715 | // instead of 127.0.0.1 because each container has its own localhost | |
| 380 | - | containerIP := "host.containers.internal" // Modern Docker/Podman default hostname for host | |
| 716 | + | containerIP := "localhost" // Default to localhost for most reliable connectivity | |
| 381 | 717 | ||
| 382 | 718 | // Fall back options for container IP | |
| 383 | 719 | if os.Getenv("GORDON_ADMIN_HOST") != "" { | |
| @@ -392,14 +728,9 @@ func (p *Proxy) Start() error { | |||
| 392 | 728 | "hostname", containerIP) | |
| 393 | 729 | } | |
| 394 | 730 | ||
| 395 | - | // Try to detect the optimal Gordon admin host by testing connections | |
| 396 | - | testedIP := p.testAdminConnection(containerIP, p.app.GetConfig().Http.Port) | |
| 397 | - | if testedIP != "" && testedIP != containerIP { | |
| 398 | - | log.Info("Auto-detected working connection to Gordon admin", | |
| 399 | - | "host", testedIP, | |
| 400 | - | "original", containerIP) | |
| 401 | - | containerIP = testedIP | |
| 402 | - | } | |
| 731 | + | // Don't test connections yet - the server isn't running | |
| 732 | + | // We'll add the admin route with the default host for now | |
| 733 | + | // and test connections later with TestAdminConnectionLater | |
| 403 | 734 | ||
| 404 | 735 | p.mu.Lock() | |
| 405 | 736 | // Only add if it doesn't already exist | |
| @@ -417,6 +748,25 @@ func (p *Proxy) Start() error { | |||
| 417 | 748 | "domain", adminDomain, | |
| 418 | 749 | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | |
| 419 | 750 | } | |
| 751 | + | ||
| 752 | + | // Add support for the root domain, redirecting to admin subdomain | |
| 753 | + | rootDomain := p.app.GetConfig().Http.Domain | |
| 754 | + | if rootDomain != "" && rootDomain != adminDomain { | |
| 755 | + | if _, exists := p.routes[rootDomain]; !exists { | |
| 756 | + | p.routes[rootDomain] = &ProxyRouteInfo{ | |
| 757 | + | Domain: rootDomain, | |
| 758 | + | ContainerIP: containerIP, | |
| 759 | + | ContainerPort: p.app.GetConfig().Http.Port, | |
| 760 | + | ContainerID: "gordon-server", | |
| 761 | + | Protocol: "http", // Gordon server uses HTTP internally | |
| 762 | + | Path: "/", | |
| 763 | + | Active: true, | |
| 764 | + | } | |
| 765 | + | log.Info("Added route for root domain with redirect to admin subdomain", | |
| 766 | + | "root_domain", rootDomain, | |
| 767 | + | "admin_domain", adminDomain) | |
| 768 | + | } | |
| 769 | + | } | |
| 420 | 770 | p.mu.Unlock() | |
| 421 | 771 | ||
| 422 | 772 | // Configure the proxy routes | |
| @@ -441,6 +791,20 @@ func (p *Proxy) Start() error { | |||
| 441 | 791 | host = strings.Split(host, ":")[0] | |
| 442 | 792 | } | |
| 443 | 793 | ||
| 794 | + | // Special handling for root domain - redirect to admin subdomain | |
| 795 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 796 | + | rootDomain := p.app.GetConfig().Http.Domain | |
| 797 | + | ||
| 798 | + | if host == rootDomain && rootDomain != adminDomain { | |
| 799 | + | // Redirect to HTTPS admin subdomain | |
| 800 | + | redirectURL := fmt.Sprintf("https://%s%s", adminDomain, c.Request().RequestURI) | |
| 801 | + | log.Debug("Redirecting HTTP root domain request to HTTPS admin subdomain", | |
| 802 | + | "from", host, | |
| 803 | + | "to", adminDomain, | |
| 804 | + | "redirect_url", redirectURL) | |
| 805 | + | return c.Redirect(http.StatusPermanentRedirect, redirectURL) | |
| 806 | + | } | |
| 807 | + | ||
| 444 | 808 | // Redirect to HTTPS | |
| 445 | 809 | return c.Redirect(http.StatusMovedPermanently, | |
| 446 | 810 | fmt.Sprintf("https://%s%s", host, c.Request().RequestURI)) | |
| @@ -451,11 +815,42 @@ func (p *Proxy) Start() error { | |||
| 451 | 815 | Addr: ":" + p.config.Port, | |
| 452 | 816 | Handler: p.httpsServer, | |
| 453 | 817 | TLSConfig: &tls.Config{ | |
| 454 | - | GetCertificate: p.certManager.GetCertificate, | |
| 455 | - | MinVersion: tls.VersionTLS12, | |
| 818 | + | GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |
| 819 | + | // Enhanced logging to debug SNI issues | |
| 820 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 821 | + | ||
| 822 | + | // If SNI is missing, use the admin domain | |
| 823 | + | if hello.ServerName == "" { | |
| 824 | + | log.Debug("TLS handshake without SNI, using default admin domain", | |
| 825 | + | "default_domain", adminDomain) | |
| 826 | + | hello.ServerName = adminDomain | |
| 827 | + | } else { | |
| 828 | + | log.Debug("TLS handshake with SNI", | |
| 829 | + | "server_name", hello.ServerName) | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | // Try to get the certificate from the autocert manager | |
| 833 | + | cert, err := p.certManager.GetCertificate(hello) | |
| 834 | + | ||
| 835 | + | // If we can't get a certificate and we have a fallback, use it for the admin domain | |
| 836 | + | if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil { | |
| 837 | + | log.Debug("Using fallback certificate for admin domain", | |
| 838 | + | "domain", adminDomain, | |
| 839 | + | "error", err) | |
| 840 | + | return p.fallbackCert, nil | |
| 841 | + | } | |
| 842 | + | ||
| 843 | + | return cert, err | |
| 844 | + | }, | |
| 845 | + | MinVersion: tls.VersionTLS12, | |
| 846 | + | // Add server name to use when client doesn't send SNI | |
| 847 | + | ServerName: p.app.GetConfig().Http.FullDomain(), | |
| 848 | + | // Add support for TLS-ALPN-01 challenges | |
| 849 | + | NextProtos: []string{acme.ALPNProto}, | |
| 456 | 850 | }, | |
| 457 | - | ReadTimeout: 5 * time.Minute, | |
| 458 | - | WriteTimeout: 5 * time.Minute, | |
| 851 | + | // Add timeouts to prevent hanging connections | |
| 852 | + | ReadTimeout: 30 * time.Second, | |
| 853 | + | WriteTimeout: 30 * time.Second, | |
| 459 | 854 | IdleTimeout: 120 * time.Second, | |
| 460 | 855 | } | |
| 461 | 856 | ||
| @@ -463,8 +858,8 @@ func (p *Proxy) Start() error { | |||
| 463 | 858 | httpServer := &http.Server{ | |
| 464 | 859 | Addr: ":" + p.config.HttpPort, | |
| 465 | 860 | Handler: p.httpServer, | |
| 466 | - | ReadTimeout: 5 * time.Minute, | |
| 467 | - | WriteTimeout: 5 * time.Minute, | |
| 861 | + | ReadTimeout: 30 * time.Second, | |
| 862 | + | WriteTimeout: 30 * time.Second, | |
| 468 | 863 | IdleTimeout: 120 * time.Second, | |
| 469 | 864 | } | |
| 470 | 865 | ||
| @@ -492,62 +887,80 @@ func (p *Proxy) Start() error { | |||
| 492 | 887 | // testAdminConnection attempts to find a working connection to the Gordon admin | |
| 493 | 888 | // by testing different hostnames and IPs | |
| 494 | 889 | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |
| 495 | - | log.Debug("Testing connections to Gordon admin server") | |
| 890 | + | log.Info("Testing connections to Gordon admin server") | |
| 496 | 891 | ||
| 497 | 892 | // Create a client with a short timeout to quickly test connections | |
| 498 | 893 | client := &http.Client{ | |
| 499 | - | Timeout: 1 * time.Second, | |
| 894 | + | Timeout: 2 * time.Second, | |
| 500 | 895 | } | |
| 501 | 896 | ||
| 502 | - | // Define addresses to try (from most likely to least likely) | |
| 503 | - | addresses := []string{ | |
| 504 | - | defaultHost, // Try the provided default first | |
| 505 | - | "localhost", // Local connections | |
| 506 | - | "127.0.0.1", // Localhost IP | |
| 507 | - | "host.docker.internal", // Docker host | |
| 508 | - | "host.containers.internal", // Podman/Docker host | |
| 509 | - | "172.17.0.1", // Common Docker gateway | |
| 510 | - | os.Getenv("HOSTNAME"), // Container's own hostname | |
| 511 | - | "gordon", // Service name | |
| 897 | + | // List of potential hosts to try, in order of preference | |
| 898 | + | hostsToTry := []string{ | |
| 899 | + | "localhost", | |
| 900 | + | "127.0.0.1", // Always try localhost first | |
| 901 | + | defaultHost, // The current host value | |
| 902 | + | "host.docker.internal", // Docker Desktop default | |
| 903 | + | "host.containers.internal", // Modern Docker/Podman default | |
| 512 | 904 | } | |
| 513 | 905 | ||
| 514 | - | // Remove duplicates and empty entries | |
| 515 | - | uniqueAddresses := make([]string, 0, len(addresses)) | |
| 516 | - | seen := make(map[string]bool) | |
| 906 | + | // Add hostname if available | |
| 907 | + | if hostname := os.Getenv("HOSTNAME"); hostname != "" { | |
| 908 | + | hostsToTry = append(hostsToTry, hostname) | |
| 909 | + | } | |
| 517 | 910 | ||
| 518 | - | for _, addr := range addresses { | |
| 519 | - | if addr == "" || seen[addr] { | |
| 520 | - | continue | |
| 911 | + | // If GORDON_ADMIN_HOST is defined, make it highest priority after localhost | |
| 912 | + | if adminHost := os.Getenv("GORDON_ADMIN_HOST"); adminHost != "" && adminHost != defaultHost { | |
| 913 | + | // Add after localhost | |
| 914 | + | hostsToTry = append(hostsToTry[:1], append([]string{adminHost}, hostsToTry[1:]...)...) | |
| 915 | + | } | |
| 916 | + | ||
| 917 | + | // Deduplicate | |
| 918 | + | seen := make(map[string]bool) | |
| 919 | + | var uniqueHosts []string | |
| 920 | + | for _, host := range hostsToTry { | |
| 921 | + | if !seen[host] && host != "" { | |
| 922 | + | seen[host] = true | |
| 923 | + | uniqueHosts = append(uniqueHosts, host) | |
| 521 | 924 | } | |
| 522 | - | seen[addr] = true | |
| 523 | - | uniqueAddresses = append(uniqueAddresses, addr) | |
| 524 | 925 | } | |
| 926 | + | hostsToTry = uniqueHosts | |
| 525 | 927 | ||
| 526 | - | // Test each address | |
| 527 | - | for _, addr := range uniqueAddresses { | |
| 528 | - | url := fmt.Sprintf("http://%s:%s/admin/ping", addr, port) | |
| 529 | - | log.Debug("Testing connection to Gordon admin", "url", url) | |
| 928 | + | log.Debug("Will test the following hosts for admin connections", | |
| 929 | + | "hosts", strings.Join(hostsToTry, ", ")) | |
| 930 | + | ||
| 931 | + | // Test each host | |
| 932 | + | for _, host := range hostsToTry { | |
| 933 | + | testPath := fmt.Sprintf("http://%s:%s%s/ping", host, port, p.app.GetConfig().Admin.Path) | |
| 934 | + | log.Info("Testing connection to Gordon admin", "host", host, "url", testPath) | |
| 935 | + | ||
| 936 | + | startTime := time.Now() | |
| 937 | + | resp, err := client.Get(testPath) | |
| 938 | + | duration := time.Since(startTime) | |
| 530 | 939 | ||
| 531 | - | resp, err := client.Get(url) | |
| 532 | 940 | if err == nil { | |
| 941 | + | statusCode := resp.StatusCode | |
| 533 | 942 | resp.Body.Close() | |
| 534 | - | if resp.StatusCode < 500 { | |
| 535 | - | log.Debug("Successfully connected to Gordon admin", | |
| 536 | - | "host", addr, | |
| 537 | - | "status", resp.StatusCode) | |
| 538 | - | return addr | |
| 539 | - | } | |
| 540 | - | log.Debug("Received error status from Gordon admin", | |
| 541 | - | "host", addr, | |
| 542 | - | "status", resp.StatusCode) | |
| 943 | + | ||
| 944 | + | log.Info("Successfully connected to Gordon admin", | |
| 945 | + | "host", host, | |
| 946 | + | "status", statusCode, | |
| 947 | + | "duration", duration) | |
| 948 | + | ||
| 949 | + | // Consider any connection successful, even non-2xx responses | |
| 950 | + | // This is important since the route might be valid even if the specific endpoint | |
| 951 | + | // returns a different status (e.g. 404 if /ping doesn't exist) | |
| 952 | + | return host | |
| 543 | 953 | } else { | |
| 544 | 954 | log.Debug("Failed to connect to Gordon admin", | |
| 545 | - | "host", addr, | |
| 546 | - | "error", err.Error()) | |
| 955 | + | "host", host, | |
| 956 | + | "error", err.Error(), | |
| 957 | + | "duration", duration) | |
| 547 | 958 | } | |
| 548 | 959 | } | |
| 549 | 960 | ||
| 550 | - | // If none worked, return the default | |
| 961 | + | // If no connections worked, default to the original | |
| 962 | + | log.Warn("Could not establish connection to Gordon admin on any tested host", | |
| 963 | + | "fallback", defaultHost) | |
| 551 | 964 | return defaultHost | |
| 552 | 965 | } | |
| 553 | 966 | ||
| @@ -669,29 +1082,51 @@ func (p *Proxy) configureRoutes() { | |||
| 669 | 1082 | "path", c.Request().URL.Path, | |
| 670 | 1083 | "method", c.Request().Method) | |
| 671 | 1084 | ||
| 1085 | + | // Special handling for root domain - redirect to admin subdomain | |
| 1086 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 1087 | + | rootDomain := p.app.GetConfig().Http.Domain | |
| 1088 | + | ||
| 1089 | + | if host == rootDomain && rootDomain != adminDomain { | |
| 1090 | + | redirectURL := fmt.Sprintf("https://%s%s", adminDomain, c.Request().URL.Path) | |
| 1091 | + | log.Debug("Redirecting root domain request to admin subdomain", | |
| 1092 | + | "from", host, | |
| 1093 | + | "to", adminDomain, | |
| 1094 | + | "redirect_url", redirectURL) | |
| 1095 | + | return c.Redirect(http.StatusPermanentRedirect, redirectURL) | |
| 1096 | + | } | |
| 1097 | + | ||
| 672 | 1098 | // Find the route for this host | |
| 673 | 1099 | route, ok := p.routes[host] | |
| 674 | 1100 | if !ok { | |
| 1101 | + | // Check if the host is an IP address - silently handle without logging warnings | |
| 1102 | + | if net.ParseIP(host) != nil { | |
| 1103 | + | // For IP-based requests, just return a 404 without logging warnings | |
| 1104 | + | return c.String(http.StatusNotFound, "Domain not found") | |
| 1105 | + | } | |
| 1106 | + | ||
| 675 | 1107 | // Create list of available domains for debugging | |
| 676 | 1108 | availableDomains := make([]string, 0, len(p.routes)) | |
| 677 | 1109 | for d := range p.routes { | |
| 678 | 1110 | availableDomains = append(availableDomains, d) | |
| 679 | 1111 | } | |
| 680 | 1112 | ||
| 681 | - | log.Warn("Domain not found in routes", | |
| 682 | - | "domain", host, | |
| 1113 | + | // Log warning for non-IP hosts that aren't configured | |
| 1114 | + | log.Warn("Request with unknown host", | |
| 1115 | + | "requested_host", host, | |
| 1116 | + | "client_ip", c.RealIP(), | |
| 683 | 1117 | "available_domains", strings.Join(availableDomains, ", ")) | |
| 684 | 1118 | return c.String(http.StatusNotFound, "Domain not found") | |
| 685 | 1119 | } | |
| 686 | 1120 | ||
| 687 | 1121 | // Check if the route is active | |
| 688 | 1122 | if !route.Active { | |
| 689 | - | log.Warn("Route is not active", "domain", host) | |
| 1123 | + | log.Warn("Route is not active", | |
| 1124 | + | "domain", host, | |
| 1125 | + | "client_ip", c.RealIP()) | |
| 690 | 1126 | return c.String(http.StatusServiceUnavailable, "Route is not active") | |
| 691 | 1127 | } | |
| 692 | 1128 | ||
| 693 | 1129 | // Special handling for the admin domain | |
| 694 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 695 | 1130 | if host == adminDomain { | |
| 696 | 1131 | log.Debug("Proxying request to admin domain", | |
| 697 | 1132 | "domain", host, | |
| @@ -890,23 +1325,6 @@ func generateUUID() string { | |||
| 890 | 1325 | return fmt.Sprintf("%d", time.Now().UnixNano()) | |
| 891 | 1326 | } | |
| 892 | 1327 | ||
| 893 | - | // joinPaths joins two paths ensuring there's only one slash between them | |
| 894 | - | func joinPaths(a, b string) string { | |
| 895 | - | if a == "" { | |
| 896 | - | return b | |
| 897 | - | } | |
| 898 | - | if b == "" { | |
| 899 | - | return a | |
| 900 | - | } | |
| 901 | - | if a[len(a)-1] == '/' { | |
| 902 | - | a = a[:len(a)-1] | |
| 903 | - | } | |
| 904 | - | if b[0] == '/' { | |
| 905 | - | b = b[1:] | |
| 906 | - | } | |
| 907 | - | return a + "/" + b | |
| 908 | - | } | |
| 909 | - | ||
| 910 | 1328 | // GetRoutes returns a copy of the routes map | |
| 911 | 1329 | func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | |
| 912 | 1330 | p.mu.RLock() | |
| @@ -976,3 +1394,156 @@ func (p *Proxy) logBlockedIP(clientIP, path, userAgent string) { | |||
| 976 | 1394 | log.Debug("Request marked as blacklisted for logging skip", "ip", clientIP) | |
| 977 | 1395 | } | |
| 978 | 1396 | } | |
| 1397 | + | ||
| 1398 | + | // generateFallbackCertificates creates a self-signed certificate for use when no certificate is available. | |
| 1399 | + | // This prevents initial handshake failures while waiting for Let's Encrypt certificates. | |
| 1400 | + | func generateFallbackCertificates(domains []string) (*tls.Certificate, error) { | |
| 1401 | + | // Generate a private key | |
| 1402 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 1403 | + | if err != nil { | |
| 1404 | + | return nil, err | |
| 1405 | + | } | |
| 1406 | + | ||
| 1407 | + | // Create a certificate template | |
| 1408 | + | template := x509.Certificate{ | |
| 1409 | + | SerialNumber: big.NewInt(1), | |
| 1410 | + | Subject: pkix.Name{ | |
| 1411 | + | Organization: []string{"Gordon Server Temporary Certificate"}, | |
| 1412 | + | CommonName: domains[0], | |
| 1413 | + | }, | |
| 1414 | + | NotBefore: time.Now(), | |
| 1415 | + | NotAfter: time.Now().Add(24 * time.Hour), // Valid for 24 hours | |
| 1416 | + | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, | |
| 1417 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |
| 1418 | + | BasicConstraintsValid: true, | |
| 1419 | + | DNSNames: domains, | |
| 1420 | + | } | |
| 1421 | + | ||
| 1422 | + | // Create the certificate | |
| 1423 | + | certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) | |
| 1424 | + | if err != nil { | |
| 1425 | + | return nil, err | |
| 1426 | + | } | |
| 1427 | + | ||
| 1428 | + | // Convert to PEM format | |
| 1429 | + | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |
| 1430 | + | ||
| 1431 | + | // Convert private key to PKCS8 format | |
| 1432 | + | keyBytes, err := x509.MarshalPKCS8PrivateKey(key) | |
| 1433 | + | if err != nil { | |
| 1434 | + | return nil, err | |
| 1435 | + | } | |
| 1436 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) | |
| 1437 | + | ||
| 1438 | + | // Parse the certificate | |
| 1439 | + | cert, err := tls.X509KeyPair(certPEM, keyPEM) | |
| 1440 | + | if err != nil { | |
| 1441 | + | return nil, err | |
| 1442 | + | } | |
| 1443 | + | ||
| 1444 | + | return &cert, nil | |
| 1445 | + | } | |
| 1446 | + | ||
| 1447 | + | // TestAdminConnectionLater tests connections to the admin server after startup | |
| 1448 | + | func (p *Proxy) TestAdminConnectionLater() { | |
| 1449 | + | log.Debug("Starting deferred admin connection testing") | |
| 1450 | + | ||
| 1451 | + | // Wait a moment for the server to fully start | |
| 1452 | + | time.Sleep(2 * time.Second) | |
| 1453 | + | ||
| 1454 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 1455 | + | ||
| 1456 | + | // Get the current route | |
| 1457 | + | p.mu.RLock() | |
| 1458 | + | currentRoute, exists := p.routes[adminDomain] | |
| 1459 | + | p.mu.RUnlock() | |
| 1460 | + | ||
| 1461 | + | if !exists { | |
| 1462 | + | log.Warn("Admin route not found when testing connections") | |
| 1463 | + | return | |
| 1464 | + | } | |
| 1465 | + | ||
| 1466 | + | log.Debug("Testing admin connections after server startup", | |
| 1467 | + | "domain", adminDomain, | |
| 1468 | + | "current_ip", currentRoute.ContainerIP) | |
| 1469 | + | ||
| 1470 | + | // Try to detect the optimal Gordon admin host by testing connections | |
| 1471 | + | testedIP := p.testAdminConnection(currentRoute.ContainerIP, p.app.GetConfig().Http.Port) | |
| 1472 | + | if testedIP != "" && testedIP != currentRoute.ContainerIP { | |
| 1473 | + | log.Info("Auto-detected working connection to Gordon admin", | |
| 1474 | + | "host", testedIP, | |
| 1475 | + | "original", currentRoute.ContainerIP) | |
| 1476 | + | ||
| 1477 | + | // Update the route with the working IP | |
| 1478 | + | p.mu.Lock() | |
| 1479 | + | if route, exists := p.routes[adminDomain]; exists { | |
| 1480 | + | route.ContainerIP = testedIP | |
| 1481 | + | log.Info("Updated admin route with working connection", | |
| 1482 | + | "domain", adminDomain, | |
| 1483 | + | "target", fmt.Sprintf("http://%s:%s", testedIP, p.app.GetConfig().Http.Port)) | |
| 1484 | + | } | |
| 1485 | + | p.mu.Unlock() | |
| 1486 | + | } | |
| 1487 | + | ||
| 1488 | + | // Now that we have a working connection, check if ports 80 and 443 are accessible | |
| 1489 | + | p.checkExternalPortAccess() | |
| 1490 | + | } | |
| 1491 | + | ||
| 1492 | + | // Helper function to check if ports 80 and 443 are accessible | |
| 1493 | + | func (p *Proxy) checkExternalPortAccess() { | |
| 1494 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 1495 | + | if adminDomain == "" { | |
| 1496 | + | return | |
| 1497 | + | } | |
| 1498 | + | ||
| 1499 | + | // Try to connect to our own HTTP server on port 80 | |
| 1500 | + | client := &http.Client{ | |
| 1501 | + | Timeout: 5 * time.Second, | |
| 1502 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |
| 1503 | + | // Don't follow redirects for this test | |
| 1504 | + | return http.ErrUseLastResponse | |
| 1505 | + | }, | |
| 1506 | + | } | |
| 1507 | + | ||
| 1508 | + | // Testing port 80 (HTTP) | |
| 1509 | + | httpURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test-token", adminDomain) | |
| 1510 | + | _, httpErr := client.Get(httpURL) | |
| 1511 | + | ||
| 1512 | + | if httpErr != nil { | |
| 1513 | + | log.Warn("External HTTP port 80 might not be accessible", | |
| 1514 | + | "domain", adminDomain, | |
| 1515 | + | "error", httpErr.Error(), | |
| 1516 | + | "solution", "Ensure port 80 is open in your firewall and not blocked by ISP") | |
| 1517 | + | } else { | |
| 1518 | + | log.Info("External HTTP port 80 is accessible - good for Let's Encrypt validation", | |
| 1519 | + | "domain", adminDomain) | |
| 1520 | + | } | |
| 1521 | + | ||
| 1522 | + | // Testing port 443 (HTTPS) | |
| 1523 | + | // Use a custom transport with InsecureSkipVerify since we might have a self-signed cert | |
| 1524 | + | tr := &http.Transport{ | |
| 1525 | + | TLSClientConfig: &tls.Config{ | |
| 1526 | + | InsecureSkipVerify: true, | |
| 1527 | + | }, | |
| 1528 | + | } | |
| 1529 | + | httpsClient := &http.Client{ | |
| 1530 | + | Timeout: 5 * time.Second, | |
| 1531 | + | Transport: tr, | |
| 1532 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |
| 1533 | + | return http.ErrUseLastResponse | |
| 1534 | + | }, | |
| 1535 | + | } | |
| 1536 | + | ||
| 1537 | + | httpsURL := fmt.Sprintf("https://%s/", adminDomain) | |
| 1538 | + | _, httpsErr := httpsClient.Get(httpsURL) | |
| 1539 | + | ||
| 1540 | + | if httpsErr != nil { | |
| 1541 | + | log.Warn("External HTTPS port 443 might not be accessible", | |
| 1542 | + | "domain", adminDomain, | |
| 1543 | + | "error", httpsErr.Error(), | |
| 1544 | + | "solution", "Ensure port 443 is open in your firewall and not blocked by ISP") | |
| 1545 | + | } else { | |
| 1546 | + | log.Info("External HTTPS port 443 is accessible", | |
| 1547 | + | "domain", adminDomain) | |
| 1548 | + | } | |
| 1549 | + | } | |
| @@ -2,13 +2,22 @@ package proxy | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "crypto/ecdsa" | |||
| 6 | + | "crypto/elliptic" | |||
| 7 | + | "crypto/rand" | |||
| 5 | "crypto/tls" | 8 | "crypto/tls" | ||
| 9 | + | "crypto/x509" | |||
| 10 | + | "crypto/x509/pkix" | |||
| 6 | "database/sql" | 11 | "database/sql" | ||
| 12 | + | "encoding/pem" | |||
| 7 | "fmt" | 13 | "fmt" | ||
| 14 | + | "math/big" | |||
| 15 | + | "net" | |||
| 8 | "net/http" | 16 | "net/http" | ||
| 9 | "net/http/httputil" | 17 | "net/http/httputil" | ||
| 10 | "net/url" | 18 | "net/url" | ||
| 11 | "os" | 19 | "os" | ||
| 20 | + | "regexp" | |||
| 12 | "strings" | 21 | "strings" | ||
| 13 | "sync" | 22 | "sync" | ||
| 14 | "time" | 23 | "time" | ||
| @@ -20,6 +29,7 @@ import ( | |||||
| 20 | "github.com/labstack/echo/v4/middleware" | 29 | "github.com/labstack/echo/v4/middleware" | ||
| 21 | "golang.org/x/crypto/acme" | 30 | "golang.org/x/crypto/acme" | ||
| 22 | "golang.org/x/crypto/acme/autocert" | 31 | "golang.org/x/crypto/acme/autocert" | ||
| 32 | + | "golang.org/x/time/rate" | |||
| 23 | ) | 33 | ) | ||
| 24 | 34 | ||||
| 25 | // Proxy represents the reverse proxy server | 35 | // Proxy represents the reverse proxy server | ||
| @@ -31,6 +41,7 @@ type Proxy struct { | |||||
| 31 | routes map[string]*ProxyRouteInfo | 41 | routes map[string]*ProxyRouteInfo | ||
| 32 | mu sync.RWMutex | 42 | mu sync.RWMutex | ||
| 33 | certManager *autocert.Manager | 43 | certManager *autocert.Manager | ||
| 44 | + | fallbackCert *tls.Certificate // Fallback self-signed certificate | |||
| 34 | serverStarted bool | 45 | serverStarted bool | ||
| 35 | blacklist *BlacklistConfig | 46 | blacklist *BlacklistConfig | ||
| 36 | 47 | ||||
| @@ -139,32 +150,50 @@ func (p *Proxy) setupMiddleware() { | |||||
| 139 | // Create blacklist middleware for HTTPS | 150 | // Create blacklist middleware for HTTPS | ||
| 140 | p.httpsServer.Use(p.createBlacklistMiddleware()) | 151 | p.httpsServer.Use(p.createBlacklistMiddleware()) | ||
| 141 | 152 | ||||
| 142 | - | // Add custom logger that skips blocked IPs | 153 | + | // Add rate limiter middleware to prevent spam attacks |
| 143 | - | p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | 154 | + | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ |
| 144 | - | Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}`, | 155 | + | Store: middleware.NewRateLimiterMemoryStoreWithConfig( |
| 145 | - | Skipper: func(c echo.Context) bool { | 156 | + | middleware.RateLimiterMemoryStoreConfig{ |
| 146 | - | // Skip logging if the request is marked as blacklisted | 157 | + | Rate: rate.Limit(10), // 10 requests per second |
| 147 | - | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok && reqCtx.blacklisted { | 158 | + | Burst: 30, // Burst of 30 requests |
| 148 | - | return true | 159 | + | ExpiresIn: 3 * time.Minute, // Store expiration |
| 149 | - | } | 160 | + | }, |
| 150 | - | return false | 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") | |||
| 151 | }, | 168 | }, | ||
| 152 | })) | 169 | })) | ||
| 153 | 170 | ||||
| 171 | + | // Add default Echo logger middleware | |||
| 172 | + | p.httpsServer.Use(middleware.Logger()) | |||
| 173 | + | ||||
| 154 | // Create blacklist middleware for HTTP | 174 | // Create blacklist middleware for HTTP | ||
| 155 | p.httpServer.Use(p.createBlacklistMiddleware()) | 175 | p.httpServer.Use(p.createBlacklistMiddleware()) | ||
| 156 | 176 | ||||
| 157 | - | // Add custom logger that skips blocked IPs | 177 | + | // Add rate limiter middleware to prevent spam attacks for HTTP server |
| 158 | - | p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | 178 | + | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ |
| 159 | - | Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}`, | 179 | + | Store: middleware.NewRateLimiterMemoryStoreWithConfig( |
| 160 | - | Skipper: func(c echo.Context) bool { | 180 | + | middleware.RateLimiterMemoryStoreConfig{ |
| 161 | - | // Skip logging if the request is marked as blacklisted | 181 | + | Rate: rate.Limit(10), // 10 requests per second |
| 162 | - | if reqCtx, ok := c.Get("reqContext").(*requestContext); ok && reqCtx.blacklisted { | 182 | + | Burst: 30, // Burst of 30 requests |
| 163 | - | return true | 183 | + | ExpiresIn: 3 * time.Minute, // Store expiration |
| 164 | - | } | 184 | + | }, |
| 165 | - | return false | 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") | |||
| 166 | }, | 192 | }, | ||
| 167 | })) | 193 | })) | ||
| 194 | + | ||||
| 195 | + | // Add default Echo logger middleware for HTTP server | |||
| 196 | + | p.httpServer.Use(middleware.Logger()) | |||
| 168 | } | 197 | } | ||
| 169 | 198 | ||||
| 170 | // createBlacklistMiddleware returns a middleware function that checks IPs against the blacklist | 199 | // createBlacklistMiddleware returns a middleware function that checks IPs against the blacklist | ||
| @@ -174,6 +203,24 @@ func (p *Proxy) createBlacklistMiddleware() echo.MiddlewareFunc { | |||||
| 174 | // Get client IP | 203 | // Get client IP | ||
| 175 | clientIP := c.RealIP() | 204 | clientIP := c.RealIP() | ||
| 176 | 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 | + | ||||
| 177 | // Check if IP is blacklisted (if blacklist exists) | 224 | // Check if IP is blacklisted (if blacklist exists) | ||
| 178 | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | 225 | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | ||
| 179 | // Set context for other middleware | 226 | // Set context for other middleware | ||
| @@ -201,6 +248,13 @@ func (p *Proxy) setupCertManager() { | |||||
| 201 | dir = p.app.GetConfig().General.StorageDir + "/certs" | 248 | dir = p.app.GetConfig().General.StorageDir + "/certs" | ||
| 202 | } | 249 | } | ||
| 203 | 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 | + | ||||
| 204 | // Set up the certificate manager | 258 | // Set up the certificate manager | ||
| 205 | certManager := &autocert.Manager{ | 259 | certManager := &autocert.Manager{ | ||
| 206 | Prompt: autocert.AcceptTOS, | 260 | Prompt: autocert.AcceptTOS, | ||
| @@ -217,13 +271,26 @@ func (p *Proxy) setupCertManager() { | |||||
| 217 | certManager.Client = &acme.Client{ | 271 | certManager.Client = &acme.Client{ | ||
| 218 | DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory", | 272 | DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory", | ||
| 219 | } | 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) | |||
| 220 | } | 283 | } | ||
| 221 | 284 | ||||
| 222 | // Set HostPolicy to allow the admin domain | 285 | // Set HostPolicy to allow the admin domain | ||
| 223 | adminDomain := p.app.GetConfig().Http.FullDomain() | 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 | |||
| 224 | certManager.HostPolicy = func(_ context.Context, host string) error { | 291 | certManager.HostPolicy = func(_ context.Context, host string) error { | ||
| 225 | - | // Always allow the admin domain | 292 | + | // Always allow the admin domain and root domain |
| 226 | - | if host == adminDomain { | 293 | + | if host == adminDomain || host == rootDomain { |
| 227 | return nil | 294 | return nil | ||
| 228 | } | 295 | } | ||
| 229 | 296 | ||||
| @@ -235,7 +302,36 @@ func (p *Proxy) setupCertManager() { | |||||
| 235 | return nil | 302 | return nil | ||
| 236 | } | 303 | } | ||
| 237 | 304 | ||||
| 238 | - | return fmt.Errorf("host %q not configured in gordon", host) | 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 | |||
| 239 | } | 335 | } | ||
| 240 | 336 | ||||
| 241 | p.certManager = certManager | 337 | p.certManager = certManager | ||
| @@ -247,103 +343,343 @@ func (p *Proxy) setupCertManager() { | |||||
| 247 | go p.requestAdminCertificate() | 343 | go p.requestAdminCertificate() | ||
| 248 | } | 344 | } | ||
| 249 | 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 | + | ||||
| 250 | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | 413 | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | ||
| 251 | // for the Gordon admin interface | 414 | // for the Gordon admin interface | ||
| 252 | func (p *Proxy) requestAdminCertificate() { | 415 | func (p *Proxy) requestAdminCertificate() { | ||
| 253 | adminDomain := p.app.GetConfig().Http.FullDomain() | 416 | adminDomain := p.app.GetConfig().Http.FullDomain() | ||
| 417 | + | if adminDomain == "" { | |||
| 418 | + | return | |||
| 419 | + | } | |||
| 254 | 420 | ||||
| 255 | - | log.Info("Initiating Let's Encrypt certificate request for admin domain", | 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", | |||
| 256 | "domain", adminDomain, | 444 | "domain", adminDomain, | ||
| 257 | - | "email", p.config.Email, | 445 | + | "ips", ipStrings) |
| 258 | - | "mode", p.config.LetsEncryptMode, | |||
| 259 | - | "cert_dir", p.config.CertDir) | |||
| 260 | 446 | ||||
| 261 | - | // Set up an HTTP client that can be used to make a request to trigger certificate generation | 447 | + | // Check if we already have a valid certificate in the cache |
| 262 | - | client := &http.Client{ | 448 | + | if p.checkCertificateInCache(adminDomain) { |
| 263 | - | Transport: &http.Transport{ | 449 | + | log.Info("Using existing certificate from cache", |
| 264 | - | TLSClientConfig: &tls.Config{ | 450 | + | "domain", adminDomain, |
| 265 | - | InsecureSkipVerify: true, // We expect cert errors since we're creating the cert | 451 | + | "action", "skipping Let's Encrypt request to avoid rate limits") |
| 266 | - | }, | 452 | + | return |
| 267 | - | }, | |||
| 268 | - | Timeout: 30 * time.Second, | |||
| 269 | } | 453 | } | ||
| 270 | 454 | ||||
| 271 | - | // We need to make an HTTPS request to our domain to trigger the certificate generation | 455 | + | // Check if environment is production |
| 272 | - | // The first attempt will generate a certificate through Let's Encrypt | 456 | + | mode := "staging" |
| 273 | - | url := fmt.Sprintf("https://%s", adminDomain) | 457 | + | email := p.config.Email |
| 274 | 458 | ||||
| 275 | - | log.Debug("Sending HTTPS request to trigger certificate creation", | 459 | + | if !p.app.IsDevEnvironment() && email != "" { |
| 276 | - | "url", url) | 460 | + | mode = "production" |
| 461 | + | } | |||
| 277 | 462 | ||||
| 278 | - | // Make the request | 463 | + | // Log the certificate request intent |
| 279 | - | resp, err := client.Get(url) | 464 | + | log.Info("Initiating Let's Encrypt certificate request for admin domain", |
| 280 | - | if err != nil { | 465 | + | "domain", adminDomain, |
| 281 | - | log.Debug("Certificate request triggered with expected TLS error (this is normal)", | 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", | |||
| 282 | "domain", adminDomain, | 507 | "domain", adminDomain, | ||
| 283 | - | "error", err.Error()) | 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 | + | } | |||
| 284 | 522 | ||||
| 285 | - | // Check if the error message contains indications that DNS might not be configured | 523 | + | log.Error("Let's Encrypt rate limit reached", |
| 286 | - | if strings.Contains(err.Error(), "no such host") || | 524 | + | "domain", adminDomain, |
| 287 | - | strings.Contains(err.Error(), "dial tcp") || | 525 | + | "error", certErr, |
| 288 | - | strings.Contains(err.Error(), "lookup") { | 526 | + | "retry_after", retryAfterStr, |
| 289 | - | log.Error("⚠️ DNS resolution failed for admin domain. Make sure the domain points to this server's IP address", | 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", | |||
| 290 | "domain", adminDomain) | 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) | |||
| 291 | } | 542 | } | ||
| 543 | + | } else if certResult != nil { | |||
| 544 | + | log.Info("Successfully obtained Let's Encrypt certificate", | |||
| 545 | + | "domain", adminDomain) | |||
| 292 | } else { | 546 | } else { | ||
| 293 | - | resp.Body.Close() | 547 | + | log.Error("Unexpected error: received nil certificate but no error", |
| 294 | - | log.Info("Initial HTTPS request completed (unexpected)", | |||
| 295 | "domain", adminDomain, | 548 | "domain", adminDomain, | ||
| 296 | - | "status", resp.StatusCode) | 549 | + | "timeout", "2 minutes") |
| 550 | + | // Implement retry logic with backoff | |||
| 551 | + | go p.retryCertificateRequest(adminDomain, 3, 10*time.Second) | |||
| 297 | } | 552 | } | ||
| 553 | + | } | |||
| 298 | 554 | ||||
| 299 | - | // Wait a bit for the certificate to be obtained | 555 | + | // retryCertificateRequest attempts to request a certificate with exponential backoff |
| 300 | - | log.Debug("Waiting for certificate generation process to complete") | 556 | + | func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) { |
| 301 | - | for i := 1; i <= 12; i++ { // Check for ~60 seconds (12 * 5s) | 557 | + | backoff := initialBackoff |
| 302 | - | time.Sleep(5 * time.Second) | |||
| 303 | 558 | ||||
| 304 | - | // Try to get the certificate from the cache | 559 | + | for attempt := 1; attempt <= maxRetries; attempt++ { |
| 305 | - | hello := &tls.ClientHelloInfo{ServerName: adminDomain} | 560 | + | // Wait for backoff period |
| 306 | - | cert, err := p.certManager.GetCertificate(hello) | 561 | + | log.Info("Retrying Let's Encrypt certificate request", |
| 562 | + | "domain", domain, | |||
| 563 | + | "attempt", attempt, | |||
| 564 | + | "max_retries", maxRetries, | |||
| 565 | + | "wait_time", backoff) | |||
| 566 | + | time.Sleep(backoff) | |||
| 567 | + | ||||
| 568 | + | // Before each retry, check if the HTTP challenge endpoint is accessible | |||
| 569 | + | if attempt > 1 { | |||
| 570 | + | // Try connecting to our own HTTP server on port 80 | |||
| 571 | + | // to verify its availability for Let's Encrypt validation | |||
| 572 | + | client := &http.Client{ | |||
| 573 | + | Timeout: 5 * time.Second, | |||
| 574 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 575 | + | // Don't follow redirects for this test | |||
| 576 | + | return http.ErrUseLastResponse | |||
| 577 | + | }, | |||
| 578 | + | } | |||
| 307 | 579 | ||||
| 308 | - | if err == nil && cert != nil && cert.Leaf != nil { | 580 | + | // Test the ACME challenge path with a fake token |
| 309 | - | log.Info("✅ Successfully obtained certificate for admin domain", | 581 | + | testURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test-token", domain) |
| 310 | - | "domain", adminDomain, | 582 | + | resp, err := client.Get(testURL) |
| 311 | - | "cert_expiry", cert.Leaf.NotAfter.Format("2006-01-02 15:04:05"), | 583 | + | |
| 312 | - | "issuer", cert.Leaf.Issuer.CommonName, | 584 | + | if err != nil { |
| 313 | - | "elapsed_time", fmt.Sprintf("%ds", i*5)) | 585 | + | log.Warn("HTTP challenge endpoint might not be accessible", |
| 314 | - | return | 586 | + | "domain", domain, |
| 587 | + | "url", testURL, | |||
| 588 | + | "error", err, | |||
| 589 | + | "solution", "Ensure port 80 is accessible and not blocked by firewall") | |||
| 590 | + | } else { | |||
| 591 | + | resp.Body.Close() | |||
| 592 | + | log.Info("HTTP challenge endpoint is accessible", | |||
| 593 | + | "domain", domain, | |||
| 594 | + | "url", testURL, | |||
| 595 | + | "status", resp.StatusCode) | |||
| 596 | + | } | |||
| 315 | } | 597 | } | ||
| 316 | 598 | ||||
| 317 | - | log.Debug("Certificate not ready yet, continuing to wait", | 599 | + | // Create context with timeout for this attempt |
| 318 | - | "attempt", i, | 600 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) |
| 319 | - | "elapsed_time", fmt.Sprintf("%ds", i*5)) | 601 | + | |
| 320 | - | } | 602 | + | // Request certificate |
| 603 | + | err := func(ctx context.Context) error { | |||
| 604 | + | defer cancel() | |||
| 605 | + | cert, certErr := p.certManager.GetCertificate(&tls.ClientHelloInfo{ | |||
| 606 | + | ServerName: domain, | |||
| 607 | + | }) | |||
| 608 | + | if cert == nil && certErr == nil { | |||
| 609 | + | return fmt.Errorf("certificate is nil but no error returned") | |||
| 610 | + | } | |||
| 611 | + | return certErr | |||
| 612 | + | }(ctx) | |||
| 321 | 613 | ||||
| 322 | - | // Final check | 614 | + | // Check result |
| 323 | - | hello := &tls.ClientHelloInfo{ServerName: adminDomain} | 615 | + | if err == nil { |
| 324 | - | cert, err := p.certManager.GetCertificate(hello) | 616 | + | log.Info("Successfully obtained Let's Encrypt certificate on retry", |
| 617 | + | "domain", domain, | |||
| 618 | + | "attempt", attempt) | |||
| 619 | + | return | |||
| 620 | + | } | |||
| 325 | 621 | ||||
| 326 | - | if err != nil { | 622 | + | log.Error("Let's Encrypt certificate request retry failed", |
| 327 | - | log.Error("Failed to request certificate for admin domain after multiple attempts", | 623 | + | "domain", domain, |
| 328 | - | "domain", adminDomain, | 624 | + | "attempt", attempt, |
| 329 | "error", err) | 625 | "error", err) | ||
| 330 | 626 | ||||
| 331 | - | // Let's provide clear guidance about what might be wrong | 627 | + | // Provide more detailed diagnostics based on the error |
| 332 | - | log.Error("⚠️ Certificate generation troubleshooting guide:") | 628 | + | if strings.Contains(strings.ToLower(err.Error()), "connection refused") || |
| 333 | - | log.Error("1. Ensure DNS is correctly configured with the domain pointing to this server's IP") | 629 | + | strings.Contains(strings.ToLower(err.Error()), "timeout") { |
| 334 | - | log.Error("2. Make sure port 80 is open and accessible from the internet for HTTP-01 challenge verification") | 630 | + | log.Error("Let's Encrypt connection failed - this typically indicates:", |
| 335 | - | log.Error("3. Check for rate limits from Let's Encrypt (https://letsencrypt.org/docs/rate-limits/)") | 631 | + | "issue_1", "Port 80 is not accessible from the internet", |
| 336 | - | log.Error("4. Verify that the domain belongs to you and is properly registered") | 632 | + | "issue_2", "Firewall is blocking inbound connections", |
| 337 | - | } else if cert != nil && cert.Leaf != nil { | 633 | + | "issue_3", "DNS records not properly propagated", |
| 338 | - | log.Info("✅ Successfully obtained certificate for admin domain", | 634 | + | "solution", "Check firewall settings and DNS configuration") |
| 339 | - | "domain", adminDomain, | 635 | + | } else if strings.Contains(strings.ToLower(err.Error()), "unauthorized") { |
| 340 | - | "cert_expiry", cert.Leaf.NotAfter.Format("2006-01-02 15:04:05"), | 636 | + | log.Error("Let's Encrypt authorization failed - this typically indicates:", |
| 341 | - | "issuer", cert.Leaf.Issuer.CommonName) | 637 | + | "issue", "Domain ownership validation failed", |
| 342 | - | } else { | 638 | + | "solution", "Ensure the server is publicly accessible on port 80") |
| 343 | - | log.Warn("Certificate request in progress for admin domain - may take longer to complete", | 639 | + | } else if strings.Contains(strings.ToLower(err.Error()), "ratelimited") { |
| 344 | - | "domain", adminDomain) | 640 | + | // Extract the retry-after date if present |
| 345 | - | log.Info("You may need to restart the server once your DNS propagation is complete") | 641 | + | retryAfterStr := "see error message for details" |
| 642 | + | if strings.Contains(err.Error(), "retry after") { | |||
| 643 | + | parts := strings.Split(err.Error(), "retry after") | |||
| 644 | + | if len(parts) > 1 { | |||
| 645 | + | retryAfterStr = strings.TrimSpace(parts[1]) | |||
| 646 | + | if idx := strings.Index(retryAfterStr, ":"); idx > 0 { | |||
| 647 | + | retryAfterStr = retryAfterStr[:idx] | |||
| 648 | + | } | |||
| 649 | + | } | |||
| 650 | + | } | |||
| 651 | + | ||||
| 652 | + | log.Error("Let's Encrypt rate limit reached - cannot issue more certificates for this domain yet", | |||
| 653 | + | "domain", domain, | |||
| 654 | + | "retry_after", retryAfterStr, | |||
| 655 | + | "solution", "Wait until the rate limit expires, then restart the server") | |||
| 656 | + | ||||
| 657 | + | // No point in retrying on rate limit errors | |||
| 658 | + | return | |||
| 659 | + | } | |||
| 660 | + | ||||
| 661 | + | // Increase backoff for next attempt (exponential backoff) | |||
| 662 | + | backoff *= 2 | |||
| 346 | } | 663 | } | ||
| 664 | + | ||||
| 665 | + | log.Error("All Let's Encrypt certificate request retries failed", | |||
| 666 | + | "domain", domain, | |||
| 667 | + | "max_retries", maxRetries, | |||
| 668 | + | "fallback", "Using self-signed certificate") | |||
| 669 | + | ||||
| 670 | + | // At this point, all retries have failed, so we'll rely on the fallback self-signed certificate | |||
| 671 | + | // But let's log this prominently for debugging | |||
| 672 | + | log.Error("⚠️ HTTPS is using a self-signed certificate which browsers will warn about", | |||
| 673 | + | "domain", domain, | |||
| 674 | + | "reason", "Let's Encrypt certificate issuance failed", | |||
| 675 | + | "solution", "Check network settings and Let's Encrypt status") | |||
| 676 | + | ||||
| 677 | + | // Suggest checking the common issues | |||
| 678 | + | log.Error("Common Let's Encrypt issues to check:", | |||
| 679 | + | "check_1", "Ensure ports 80 and 443 are open on your firewall", | |||
| 680 | + | "check_2", "Verify DNS records point to your server IP", | |||
| 681 | + | "check_3", "Make sure no other services are running on ports 80/443", | |||
| 682 | + | "check_4", "Check if Let's Encrypt service is having issues (https://letsencrypt.status.io/)") | |||
| 347 | } | 683 | } | ||
| 348 | 684 | ||||
| 349 | // Start loads the routes from the database and starts the proxy server | 685 | // Start loads the routes from the database and starts the proxy server | ||
| @@ -377,7 +713,7 @@ func (p *Proxy) Start() error { | |||||
| 377 | 713 | ||||
| 378 | // In a container environment, we need to use the host container IP | 714 | // In a container environment, we need to use the host container IP | ||
| 379 | // instead of 127.0.0.1 because each container has its own localhost | 715 | // instead of 127.0.0.1 because each container has its own localhost | ||
| 380 | - | containerIP := "host.containers.internal" // Modern Docker/Podman default hostname for host | 716 | + | containerIP := "localhost" // Default to localhost for most reliable connectivity |
| 381 | 717 | ||||
| 382 | // Fall back options for container IP | 718 | // Fall back options for container IP | ||
| 383 | if os.Getenv("GORDON_ADMIN_HOST") != "" { | 719 | if os.Getenv("GORDON_ADMIN_HOST") != "" { | ||
| @@ -392,14 +728,9 @@ func (p *Proxy) Start() error { | |||||
| 392 | "hostname", containerIP) | 728 | "hostname", containerIP) | ||
| 393 | } | 729 | } | ||
| 394 | 730 | ||||
| 395 | - | // Try to detect the optimal Gordon admin host by testing connections | 731 | + | // Don't test connections yet - the server isn't running |
| 396 | - | testedIP := p.testAdminConnection(containerIP, p.app.GetConfig().Http.Port) | 732 | + | // We'll add the admin route with the default host for now |
| 397 | - | if testedIP != "" && testedIP != containerIP { | 733 | + | // and test connections later with TestAdminConnectionLater |
| 398 | - | log.Info("Auto-detected working connection to Gordon admin", | |||
| 399 | - | "host", testedIP, | |||
| 400 | - | "original", containerIP) | |||
| 401 | - | containerIP = testedIP | |||
| 402 | - | } | |||
| 403 | 734 | ||||
| 404 | p.mu.Lock() | 735 | p.mu.Lock() | ||
| 405 | // Only add if it doesn't already exist | 736 | // Only add if it doesn't already exist | ||
| @@ -417,6 +748,25 @@ func (p *Proxy) Start() error { | |||||
| 417 | "domain", adminDomain, | 748 | "domain", adminDomain, | ||
| 418 | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | 749 | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | ||
| 419 | } | 750 | } | ||
| 751 | + | ||||
| 752 | + | // Add support for the root domain, redirecting to admin subdomain | |||
| 753 | + | rootDomain := p.app.GetConfig().Http.Domain | |||
| 754 | + | if rootDomain != "" && rootDomain != adminDomain { | |||
| 755 | + | if _, exists := p.routes[rootDomain]; !exists { | |||
| 756 | + | p.routes[rootDomain] = &ProxyRouteInfo{ | |||
| 757 | + | Domain: rootDomain, | |||
| 758 | + | ContainerIP: containerIP, | |||
| 759 | + | ContainerPort: p.app.GetConfig().Http.Port, | |||
| 760 | + | ContainerID: "gordon-server", | |||
| 761 | + | Protocol: "http", // Gordon server uses HTTP internally | |||
| 762 | + | Path: "/", | |||
| 763 | + | Active: true, | |||
| 764 | + | } | |||
| 765 | + | log.Info("Added route for root domain with redirect to admin subdomain", | |||
| 766 | + | "root_domain", rootDomain, | |||
| 767 | + | "admin_domain", adminDomain) | |||
| 768 | + | } | |||
| 769 | + | } | |||
| 420 | p.mu.Unlock() | 770 | p.mu.Unlock() | ||
| 421 | 771 | ||||
| 422 | // Configure the proxy routes | 772 | // Configure the proxy routes | ||
| @@ -441,6 +791,20 @@ func (p *Proxy) Start() error { | |||||
| 441 | host = strings.Split(host, ":")[0] | 791 | host = strings.Split(host, ":")[0] | ||
| 442 | } | 792 | } | ||
| 443 | 793 | ||||
| 794 | + | // Special handling for root domain - redirect to admin subdomain | |||
| 795 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 796 | + | rootDomain := p.app.GetConfig().Http.Domain | |||
| 797 | + | ||||
| 798 | + | if host == rootDomain && rootDomain != adminDomain { | |||
| 799 | + | // Redirect to HTTPS admin subdomain | |||
| 800 | + | redirectURL := fmt.Sprintf("https://%s%s", adminDomain, c.Request().RequestURI) | |||
| 801 | + | log.Debug("Redirecting HTTP root domain request to HTTPS admin subdomain", | |||
| 802 | + | "from", host, | |||
| 803 | + | "to", adminDomain, | |||
| 804 | + | "redirect_url", redirectURL) | |||
| 805 | + | return c.Redirect(http.StatusPermanentRedirect, redirectURL) | |||
| 806 | + | } | |||
| 807 | + | ||||
| 444 | // Redirect to HTTPS | 808 | // Redirect to HTTPS | ||
| 445 | return c.Redirect(http.StatusMovedPermanently, | 809 | return c.Redirect(http.StatusMovedPermanently, | ||
| 446 | fmt.Sprintf("https://%s%s", host, c.Request().RequestURI)) | 810 | fmt.Sprintf("https://%s%s", host, c.Request().RequestURI)) | ||
| @@ -451,11 +815,42 @@ func (p *Proxy) Start() error { | |||||
| 451 | Addr: ":" + p.config.Port, | 815 | Addr: ":" + p.config.Port, | ||
| 452 | Handler: p.httpsServer, | 816 | Handler: p.httpsServer, | ||
| 453 | TLSConfig: &tls.Config{ | 817 | TLSConfig: &tls.Config{ | ||
| 454 | - | GetCertificate: p.certManager.GetCertificate, | 818 | + | GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| 455 | - | MinVersion: tls.VersionTLS12, | 819 | + | // Enhanced logging to debug SNI issues |
| 820 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 821 | + | ||||
| 822 | + | // If SNI is missing, use the admin domain | |||
| 823 | + | if hello.ServerName == "" { | |||
| 824 | + | log.Debug("TLS handshake without SNI, using default admin domain", | |||
| 825 | + | "default_domain", adminDomain) | |||
| 826 | + | hello.ServerName = adminDomain | |||
| 827 | + | } else { | |||
| 828 | + | log.Debug("TLS handshake with SNI", | |||
| 829 | + | "server_name", hello.ServerName) | |||
| 830 | + | } | |||
| 831 | + | ||||
| 832 | + | // Try to get the certificate from the autocert manager | |||
| 833 | + | cert, err := p.certManager.GetCertificate(hello) | |||
| 834 | + | ||||
| 835 | + | // If we can't get a certificate and we have a fallback, use it for the admin domain | |||
| 836 | + | if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil { | |||
| 837 | + | log.Debug("Using fallback certificate for admin domain", | |||
| 838 | + | "domain", adminDomain, | |||
| 839 | + | "error", err) | |||
| 840 | + | return p.fallbackCert, nil | |||
| 841 | + | } | |||
| 842 | + | ||||
| 843 | + | return cert, err | |||
| 844 | + | }, | |||
| 845 | + | MinVersion: tls.VersionTLS12, | |||
| 846 | + | // Add server name to use when client doesn't send SNI | |||
| 847 | + | ServerName: p.app.GetConfig().Http.FullDomain(), | |||
| 848 | + | // Add support for TLS-ALPN-01 challenges | |||
| 849 | + | NextProtos: []string{acme.ALPNProto}, | |||
| 456 | }, | 850 | }, | ||
| 457 | - | ReadTimeout: 5 * time.Minute, | 851 | + | // Add timeouts to prevent hanging connections |
| 458 | - | WriteTimeout: 5 * time.Minute, | 852 | + | ReadTimeout: 30 * time.Second, |
| 853 | + | WriteTimeout: 30 * time.Second, | |||
| 459 | IdleTimeout: 120 * time.Second, | 854 | IdleTimeout: 120 * time.Second, | ||
| 460 | } | 855 | } | ||
| 461 | 856 | ||||
| @@ -463,8 +858,8 @@ func (p *Proxy) Start() error { | |||||
| 463 | httpServer := &http.Server{ | 858 | httpServer := &http.Server{ | ||
| 464 | Addr: ":" + p.config.HttpPort, | 859 | Addr: ":" + p.config.HttpPort, | ||
| 465 | Handler: p.httpServer, | 860 | Handler: p.httpServer, | ||
| 466 | - | ReadTimeout: 5 * time.Minute, | 861 | + | ReadTimeout: 30 * time.Second, |
| 467 | - | WriteTimeout: 5 * time.Minute, | 862 | + | WriteTimeout: 30 * time.Second, |
| 468 | IdleTimeout: 120 * time.Second, | 863 | IdleTimeout: 120 * time.Second, | ||
| 469 | } | 864 | } | ||
| 470 | 865 | ||||
| @@ -492,62 +887,80 @@ func (p *Proxy) Start() error { | |||||
| 492 | // testAdminConnection attempts to find a working connection to the Gordon admin | 887 | // testAdminConnection attempts to find a working connection to the Gordon admin | ||
| 493 | // by testing different hostnames and IPs | 888 | // by testing different hostnames and IPs | ||
| 494 | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | 889 | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | ||
| 495 | - | log.Debug("Testing connections to Gordon admin server") | 890 | + | log.Info("Testing connections to Gordon admin server") |
| 496 | 891 | ||||
| 497 | // Create a client with a short timeout to quickly test connections | 892 | // Create a client with a short timeout to quickly test connections | ||
| 498 | client := &http.Client{ | 893 | client := &http.Client{ | ||
| 499 | - | Timeout: 1 * time.Second, | 894 | + | Timeout: 2 * time.Second, |
| 500 | } | 895 | } | ||
| 501 | 896 | ||||
| 502 | - | // Define addresses to try (from most likely to least likely) | 897 | + | // List of potential hosts to try, in order of preference |
| 503 | - | addresses := []string{ | 898 | + | hostsToTry := []string{ |
| 504 | - | defaultHost, // Try the provided default first | 899 | + | "localhost", |
| 505 | - | "localhost", // Local connections | 900 | + | "127.0.0.1", // Always try localhost first |
| 506 | - | "127.0.0.1", // Localhost IP | 901 | + | defaultHost, // The current host value |
| 507 | - | "host.docker.internal", // Docker host | 902 | + | "host.docker.internal", // Docker Desktop default |
| 508 | - | "host.containers.internal", // Podman/Docker host | 903 | + | "host.containers.internal", // Modern Docker/Podman default |
| 509 | - | "172.17.0.1", // Common Docker gateway | |||
| 510 | - | os.Getenv("HOSTNAME"), // Container's own hostname | |||
| 511 | - | "gordon", // Service name | |||
| 512 | } | 904 | } | ||
| 513 | 905 | ||||
| 514 | - | // Remove duplicates and empty entries | 906 | + | // Add hostname if available |
| 515 | - | uniqueAddresses := make([]string, 0, len(addresses)) | 907 | + | if hostname := os.Getenv("HOSTNAME"); hostname != "" { |
| 516 | - | seen := make(map[string]bool) | 908 | + | hostsToTry = append(hostsToTry, hostname) |
| 909 | + | } | |||
| 517 | 910 | ||||
| 518 | - | for _, addr := range addresses { | 911 | + | // If GORDON_ADMIN_HOST is defined, make it highest priority after localhost |
| 519 | - | if addr == "" || seen[addr] { | 912 | + | if adminHost := os.Getenv("GORDON_ADMIN_HOST"); adminHost != "" && adminHost != defaultHost { |
| 520 | - | continue | 913 | + | // Add after localhost |
| 914 | + | hostsToTry = append(hostsToTry[:1], append([]string{adminHost}, hostsToTry[1:]...)...) | |||
| 915 | + | } | |||
| 916 | + | ||||
| 917 | + | // Deduplicate | |||
| 918 | + | seen := make(map[string]bool) | |||
| 919 | + | var uniqueHosts []string | |||
| 920 | + | for _, host := range hostsToTry { | |||
| 921 | + | if !seen[host] && host != "" { | |||
| 922 | + | seen[host] = true | |||
| 923 | + | uniqueHosts = append(uniqueHosts, host) | |||
| 521 | } | 924 | } | ||
| 522 | - | seen[addr] = true | |||
| 523 | - | uniqueAddresses = append(uniqueAddresses, addr) | |||
| 524 | } | 925 | } | ||
| 926 | + | hostsToTry = uniqueHosts | |||
| 525 | 927 | ||||
| 526 | - | // Test each address | 928 | + | log.Debug("Will test the following hosts for admin connections", |
| 527 | - | for _, addr := range uniqueAddresses { | 929 | + | "hosts", strings.Join(hostsToTry, ", ")) |
| 528 | - | url := fmt.Sprintf("http://%s:%s/admin/ping", addr, port) | 930 | + | |
| 529 | - | log.Debug("Testing connection to Gordon admin", "url", url) | 931 | + | // Test each host |
| 932 | + | for _, host := range hostsToTry { | |||
| 933 | + | testPath := fmt.Sprintf("http://%s:%s%s/ping", host, port, p.app.GetConfig().Admin.Path) | |||
| 934 | + | log.Info("Testing connection to Gordon admin", "host", host, "url", testPath) | |||
| 935 | + | ||||
| 936 | + | startTime := time.Now() | |||
| 937 | + | resp, err := client.Get(testPath) | |||
| 938 | + | duration := time.Since(startTime) | |||
| 530 | 939 | ||||
| 531 | - | resp, err := client.Get(url) | |||
| 532 | if err == nil { | 940 | if err == nil { | ||
| 941 | + | statusCode := resp.StatusCode | |||
| 533 | resp.Body.Close() | 942 | resp.Body.Close() | ||
| 534 | - | if resp.StatusCode < 500 { | 943 | + | |
| 535 | - | log.Debug("Successfully connected to Gordon admin", | 944 | + | log.Info("Successfully connected to Gordon admin", |
| 536 | - | "host", addr, | 945 | + | "host", host, |
| 537 | - | "status", resp.StatusCode) | 946 | + | "status", statusCode, |
| 538 | - | return addr | 947 | + | "duration", duration) |
| 539 | - | } | 948 | + | |
| 540 | - | log.Debug("Received error status from Gordon admin", | 949 | + | // Consider any connection successful, even non-2xx responses |
| 541 | - | "host", addr, | 950 | + | // This is important since the route might be valid even if the specific endpoint |
| 542 | - | "status", resp.StatusCode) | 951 | + | // returns a different status (e.g. 404 if /ping doesn't exist) |
| 952 | + | return host | |||
| 543 | } else { | 953 | } else { | ||
| 544 | log.Debug("Failed to connect to Gordon admin", | 954 | log.Debug("Failed to connect to Gordon admin", | ||
| 545 | - | "host", addr, | 955 | + | "host", host, |
| 546 | - | "error", err.Error()) | 956 | + | "error", err.Error(), |
| 957 | + | "duration", duration) | |||
| 547 | } | 958 | } | ||
| 548 | } | 959 | } | ||
| 549 | 960 | ||||
| 550 | - | // If none worked, return the default | 961 | + | // If no connections worked, default to the original |
| 962 | + | log.Warn("Could not establish connection to Gordon admin on any tested host", | |||
| 963 | + | "fallback", defaultHost) | |||
| 551 | return defaultHost | 964 | return defaultHost | ||
| 552 | } | 965 | } | ||
| 553 | 966 | ||||
| @@ -669,29 +1082,51 @@ func (p *Proxy) configureRoutes() { | |||||
| 669 | "path", c.Request().URL.Path, | 1082 | "path", c.Request().URL.Path, | ||
| 670 | "method", c.Request().Method) | 1083 | "method", c.Request().Method) | ||
| 671 | 1084 | ||||
| 1085 | + | // Special handling for root domain - redirect to admin subdomain | |||
| 1086 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 1087 | + | rootDomain := p.app.GetConfig().Http.Domain | |||
| 1088 | + | ||||
| 1089 | + | if host == rootDomain && rootDomain != adminDomain { | |||
| 1090 | + | redirectURL := fmt.Sprintf("https://%s%s", adminDomain, c.Request().URL.Path) | |||
| 1091 | + | log.Debug("Redirecting root domain request to admin subdomain", | |||
| 1092 | + | "from", host, | |||
| 1093 | + | "to", adminDomain, | |||
| 1094 | + | "redirect_url", redirectURL) | |||
| 1095 | + | return c.Redirect(http.StatusPermanentRedirect, redirectURL) | |||
| 1096 | + | } | |||
| 1097 | + | ||||
| 672 | // Find the route for this host | 1098 | // Find the route for this host | ||
| 673 | route, ok := p.routes[host] | 1099 | route, ok := p.routes[host] | ||
| 674 | if !ok { | 1100 | if !ok { | ||
| 1101 | + | // Check if the host is an IP address - silently handle without logging warnings | |||
| 1102 | + | if net.ParseIP(host) != nil { | |||
| 1103 | + | // For IP-based requests, just return a 404 without logging warnings | |||
| 1104 | + | return c.String(http.StatusNotFound, "Domain not found") | |||
| 1105 | + | } | |||
| 1106 | + | ||||
| 675 | // Create list of available domains for debugging | 1107 | // Create list of available domains for debugging | ||
| 676 | availableDomains := make([]string, 0, len(p.routes)) | 1108 | availableDomains := make([]string, 0, len(p.routes)) | ||
| 677 | for d := range p.routes { | 1109 | for d := range p.routes { | ||
| 678 | availableDomains = append(availableDomains, d) | 1110 | availableDomains = append(availableDomains, d) | ||
| 679 | } | 1111 | } | ||
| 680 | 1112 | ||||
| 681 | - | log.Warn("Domain not found in routes", | 1113 | + | // Log warning for non-IP hosts that aren't configured |
| 682 | - | "domain", host, | 1114 | + | log.Warn("Request with unknown host", |
| 1115 | + | "requested_host", host, | |||
| 1116 | + | "client_ip", c.RealIP(), | |||
| 683 | "available_domains", strings.Join(availableDomains, ", ")) | 1117 | "available_domains", strings.Join(availableDomains, ", ")) | ||
| 684 | return c.String(http.StatusNotFound, "Domain not found") | 1118 | return c.String(http.StatusNotFound, "Domain not found") | ||
| 685 | } | 1119 | } | ||
| 686 | 1120 | ||||
| 687 | // Check if the route is active | 1121 | // Check if the route is active | ||
| 688 | if !route.Active { | 1122 | if !route.Active { | ||
| 689 | - | log.Warn("Route is not active", "domain", host) | 1123 | + | log.Warn("Route is not active", |
| 1124 | + | "domain", host, | |||
| 1125 | + | "client_ip", c.RealIP()) | |||
| 690 | return c.String(http.StatusServiceUnavailable, "Route is not active") | 1126 | return c.String(http.StatusServiceUnavailable, "Route is not active") | ||
| 691 | } | 1127 | } | ||
| 692 | 1128 | ||||
| 693 | // Special handling for the admin domain | 1129 | // Special handling for the admin domain | ||
| 694 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 695 | if host == adminDomain { | 1130 | if host == adminDomain { | ||
| 696 | log.Debug("Proxying request to admin domain", | 1131 | log.Debug("Proxying request to admin domain", | ||
| 697 | "domain", host, | 1132 | "domain", host, | ||
| @@ -890,23 +1325,6 @@ func generateUUID() string { | |||||
| 890 | return fmt.Sprintf("%d", time.Now().UnixNano()) | 1325 | return fmt.Sprintf("%d", time.Now().UnixNano()) | ||
| 891 | } | 1326 | } | ||
| 892 | 1327 | ||||
| 893 | - | // joinPaths joins two paths ensuring there's only one slash between them | |||
| 894 | - | func joinPaths(a, b string) string { | |||
| 895 | - | if a == "" { | |||
| 896 | - | return b | |||
| 897 | - | } | |||
| 898 | - | if b == "" { | |||
| 899 | - | return a | |||
| 900 | - | } | |||
| 901 | - | if a[len(a)-1] == '/' { | |||
| 902 | - | a = a[:len(a)-1] | |||
| 903 | - | } | |||
| 904 | - | if b[0] == '/' { | |||
| 905 | - | b = b[1:] | |||
| 906 | - | } | |||
| 907 | - | return a + "/" + b | |||
| 908 | - | } | |||
| 909 | - | ||||
| 910 | // GetRoutes returns a copy of the routes map | 1328 | // GetRoutes returns a copy of the routes map | ||
| 911 | func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | 1329 | func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | ||
| 912 | p.mu.RLock() | 1330 | p.mu.RLock() | ||
| @@ -976,3 +1394,156 @@ func (p *Proxy) logBlockedIP(clientIP, path, userAgent string) { | |||||
| 976 | log.Debug("Request marked as blacklisted for logging skip", "ip", clientIP) | 1394 | log.Debug("Request marked as blacklisted for logging skip", "ip", clientIP) | ||
| 977 | } | 1395 | } | ||
| 978 | } | 1396 | } | ||
| 1397 | + | ||||
| 1398 | + | // generateFallbackCertificates creates a self-signed certificate for use when no certificate is available. | |||
| 1399 | + | // This prevents initial handshake failures while waiting for Let's Encrypt certificates. | |||
| 1400 | + | func generateFallbackCertificates(domains []string) (*tls.Certificate, error) { | |||
| 1401 | + | // Generate a private key | |||
| 1402 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 1403 | + | if err != nil { | |||
| 1404 | + | return nil, err | |||
| 1405 | + | } | |||
| 1406 | + | ||||
| 1407 | + | // Create a certificate template | |||
| 1408 | + | template := x509.Certificate{ | |||
| 1409 | + | SerialNumber: big.NewInt(1), | |||
| 1410 | + | Subject: pkix.Name{ | |||
| 1411 | + | Organization: []string{"Gordon Server Temporary Certificate"}, | |||
| 1412 | + | CommonName: domains[0], | |||
| 1413 | + | }, | |||
| 1414 | + | NotBefore: time.Now(), | |||
| 1415 | + | NotAfter: time.Now().Add(24 * time.Hour), // Valid for 24 hours | |||
| 1416 | + | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, | |||
| 1417 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |||
| 1418 | + | BasicConstraintsValid: true, | |||
| 1419 | + | DNSNames: domains, | |||
| 1420 | + | } | |||
| 1421 | + | ||||
| 1422 | + | // Create the certificate | |||
| 1423 | + | certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) | |||
| 1424 | + | if err != nil { | |||
| 1425 | + | return nil, err | |||
| 1426 | + | } | |||
| 1427 | + | ||||
| 1428 | + | // Convert to PEM format | |||
| 1429 | + | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |||
| 1430 | + | ||||
| 1431 | + | // Convert private key to PKCS8 format | |||
| 1432 | + | keyBytes, err := x509.MarshalPKCS8PrivateKey(key) | |||
| 1433 | + | if err != nil { | |||
| 1434 | + | return nil, err | |||
| 1435 | + | } | |||
| 1436 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) | |||
| 1437 | + | ||||
| 1438 | + | // Parse the certificate | |||
| 1439 | + | cert, err := tls.X509KeyPair(certPEM, keyPEM) | |||
| 1440 | + | if err != nil { | |||
| 1441 | + | return nil, err | |||
| 1442 | + | } | |||
| 1443 | + | ||||
| 1444 | + | return &cert, nil | |||
| 1445 | + | } | |||
| 1446 | + | ||||
| 1447 | + | // TestAdminConnectionLater tests connections to the admin server after startup | |||
| 1448 | + | func (p *Proxy) TestAdminConnectionLater() { | |||
| 1449 | + | log.Debug("Starting deferred admin connection testing") | |||
| 1450 | + | ||||
| 1451 | + | // Wait a moment for the server to fully start | |||
| 1452 | + | time.Sleep(2 * time.Second) | |||
| 1453 | + | ||||
| 1454 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 1455 | + | ||||
| 1456 | + | // Get the current route | |||
| 1457 | + | p.mu.RLock() | |||
| 1458 | + | currentRoute, exists := p.routes[adminDomain] | |||
| 1459 | + | p.mu.RUnlock() | |||
| 1460 | + | ||||
| 1461 | + | if !exists { | |||
| 1462 | + | log.Warn("Admin route not found when testing connections") | |||
| 1463 | + | return | |||
| 1464 | + | } | |||
| 1465 | + | ||||
| 1466 | + | log.Debug("Testing admin connections after server startup", | |||
| 1467 | + | "domain", adminDomain, | |||
| 1468 | + | "current_ip", currentRoute.ContainerIP) | |||
| 1469 | + | ||||
| 1470 | + | // Try to detect the optimal Gordon admin host by testing connections | |||
| 1471 | + | testedIP := p.testAdminConnection(currentRoute.ContainerIP, p.app.GetConfig().Http.Port) | |||
| 1472 | + | if testedIP != "" && testedIP != currentRoute.ContainerIP { | |||
| 1473 | + | log.Info("Auto-detected working connection to Gordon admin", | |||
| 1474 | + | "host", testedIP, | |||
| 1475 | + | "original", currentRoute.ContainerIP) | |||
| 1476 | + | ||||
| 1477 | + | // Update the route with the working IP | |||
| 1478 | + | p.mu.Lock() | |||
| 1479 | + | if route, exists := p.routes[adminDomain]; exists { | |||
| 1480 | + | route.ContainerIP = testedIP | |||
| 1481 | + | log.Info("Updated admin route with working connection", | |||
| 1482 | + | "domain", adminDomain, | |||
| 1483 | + | "target", fmt.Sprintf("http://%s:%s", testedIP, p.app.GetConfig().Http.Port)) | |||
| 1484 | + | } | |||
| 1485 | + | p.mu.Unlock() | |||
| 1486 | + | } | |||
| 1487 | + | ||||
| 1488 | + | // Now that we have a working connection, check if ports 80 and 443 are accessible | |||
| 1489 | + | p.checkExternalPortAccess() | |||
| 1490 | + | } | |||
| 1491 | + | ||||
| 1492 | + | // Helper function to check if ports 80 and 443 are accessible | |||
| 1493 | + | func (p *Proxy) checkExternalPortAccess() { | |||
| 1494 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 1495 | + | if adminDomain == "" { | |||
| 1496 | + | return | |||
| 1497 | + | } | |||
| 1498 | + | ||||
| 1499 | + | // Try to connect to our own HTTP server on port 80 | |||
| 1500 | + | client := &http.Client{ | |||
| 1501 | + | Timeout: 5 * time.Second, | |||
| 1502 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 1503 | + | // Don't follow redirects for this test | |||
| 1504 | + | return http.ErrUseLastResponse | |||
| 1505 | + | }, | |||
| 1506 | + | } | |||
| 1507 | + | ||||
| 1508 | + | // Testing port 80 (HTTP) | |||
| 1509 | + | httpURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test-token", adminDomain) | |||
| 1510 | + | _, httpErr := client.Get(httpURL) | |||
| 1511 | + | ||||
| 1512 | + | if httpErr != nil { | |||
| 1513 | + | log.Warn("External HTTP port 80 might not be accessible", | |||
| 1514 | + | "domain", adminDomain, | |||
| 1515 | + | "error", httpErr.Error(), | |||
| 1516 | + | "solution", "Ensure port 80 is open in your firewall and not blocked by ISP") | |||
| 1517 | + | } else { | |||
| 1518 | + | log.Info("External HTTP port 80 is accessible - good for Let's Encrypt validation", | |||
| 1519 | + | "domain", adminDomain) | |||
| 1520 | + | } | |||
| 1521 | + | ||||
| 1522 | + | // Testing port 443 (HTTPS) | |||
| 1523 | + | // Use a custom transport with InsecureSkipVerify since we might have a self-signed cert | |||
| 1524 | + | tr := &http.Transport{ | |||
| 1525 | + | TLSClientConfig: &tls.Config{ | |||
| 1526 | + | InsecureSkipVerify: true, | |||
| 1527 | + | }, | |||
| 1528 | + | } | |||
| 1529 | + | httpsClient := &http.Client{ | |||
| 1530 | + | Timeout: 5 * time.Second, | |||
| 1531 | + | Transport: tr, | |||
| 1532 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 1533 | + | return http.ErrUseLastResponse | |||
| 1534 | + | }, | |||
| 1535 | + | } | |||
| 1536 | + | ||||
| 1537 | + | httpsURL := fmt.Sprintf("https://%s/", adminDomain) | |||
| 1538 | + | _, httpsErr := httpsClient.Get(httpsURL) | |||
| 1539 | + | ||||
| 1540 | + | if httpsErr != nil { | |||
| 1541 | + | log.Warn("External HTTPS port 443 might not be accessible", | |||
| 1542 | + | "domain", adminDomain, | |||
| 1543 | + | "error", httpsErr.Error(), | |||
| 1544 | + | "solution", "Ensure port 443 is open in your firewall and not blocked by ISP") | |||
| 1545 | + | } else { | |||
| 1546 | + | log.Info("External HTTPS port 443 is accessible", | |||
| 1547 | + | "domain", adminDomain) | |||
| 1548 | + | } | |||
| 1549 | + | } | |||
M internal/templating/models/html/login/index.gohtml +43 -1
| @@ -45,9 +45,51 @@ | |||
| 45 | 45 | <div id="login-section"> | |
| 46 | 46 | <div class="flex flex-col items-center"> | |
| 47 | 47 | <h2 class="text-2xl font-semibold text-gray-800">Login</h2> | |
| 48 | + | ||
| 49 | + | <!-- Error Message Display --> | |
| 50 | + | {{ if .Error }} | |
| 51 | + | <div class="mt-4 w-full max-w-md bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert"> | |
| 52 | + | <span class="block sm:inline"> | |
| 53 | + | {{ if eq .Error "empty_token" }} | |
| 54 | + | Please enter a token. | |
| 55 | + | {{ else if eq .Error "invalid_token" }} | |
| 56 | + | Invalid token. Please try again. | |
| 57 | + | {{ else if eq .Error "server_error" }} | |
| 58 | + | A server error occurred. Please try again later. | |
| 59 | + | {{ else }} | |
| 60 | + | An error occurred. Please try again. | |
| 61 | + | {{ end }} | |
| 62 | + | </span> | |
| 63 | + | </div> | |
| 64 | + | {{ end }} | |
| 65 | + | ||
| 66 | + | <!-- Token Input Form --> | |
| 67 | + | <div class="mt-4 w-full max-w-md"> | |
| 68 | + | <form action="login/submit-token" method="POST" class="mb-4"> | |
| 69 | + | <div class="mb-2"> | |
| 70 | + | <label for="token" class="block text-sm font-medium text-gray-700">Admin Token</label> | |
| 71 | + | <input | |
| 72 | + | type="text" | |
| 73 | + | id="token" | |
| 74 | + | name="token" | |
| 75 | + | placeholder="Enter your admin token" | |
| 76 | + | class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-gordonmascot-hblue focus:border-gordonmascot-hblue" | |
| 77 | + | required | |
| 78 | + | /> | |
| 79 | + | </div> | |
| 80 | + | <button | |
| 81 | + | type="submit" | |
| 82 | + | class="w-full bg-gordonmascot-mblue hover:bg-gordonmascot-hblue text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-150 ease-in-out" | |
| 83 | + | > | |
| 84 | + | Continue with Token | |
| 85 | + | </button> | |
| 86 | + | </form> | |
| 87 | + | <div class="text-center text-sm text-gray-500 mb-4">- OR -</div> | |
| 88 | + | </div> | |
| 89 | + | ||
| 48 | 90 | <!-- GitHub OAuth --> | |
| 49 | 91 | <div | |
| 50 | - | class="mt-4 p-2 bg-gordonmascot-hblue text-white rounded-md flex justify-left"> | |
| 92 | + | class="mt-2 p-2 bg-gordonmascot-hblue text-white rounded-md flex justify-left"> | |
| 51 | 93 | <a href="login/oauth/github" class="flex items-center"> | |
| 52 | 94 | <img | |
| 53 | 95 | src="/assets/imgs/github/github-mark-white.png" | |
| @@ -45,9 +45,51 @@ | |||||
| 45 | <div id="login-section"> | 45 | <div id="login-section"> | ||
| 46 | <div class="flex flex-col items-center"> | 46 | <div class="flex flex-col items-center"> | ||
| 47 | <h2 class="text-2xl font-semibold text-gray-800">Login</h2> | 47 | <h2 class="text-2xl font-semibold text-gray-800">Login</h2> | ||
| 48 | + | ||||
| 49 | + | <!-- Error Message Display --> | |||
| 50 | + | {{ if .Error }} | |||
| 51 | + | <div class="mt-4 w-full max-w-md bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert"> | |||
| 52 | + | <span class="block sm:inline"> | |||
| 53 | + | {{ if eq .Error "empty_token" }} | |||
| 54 | + | Please enter a token. | |||
| 55 | + | {{ else if eq .Error "invalid_token" }} | |||
| 56 | + | Invalid token. Please try again. | |||
| 57 | + | {{ else if eq .Error "server_error" }} | |||
| 58 | + | A server error occurred. Please try again later. | |||
| 59 | + | {{ else }} | |||
| 60 | + | An error occurred. Please try again. | |||
| 61 | + | {{ end }} | |||
| 62 | + | </span> | |||
| 63 | + | </div> | |||
| 64 | + | {{ end }} | |||
| 65 | + | ||||
| 66 | + | <!-- Token Input Form --> | |||
| 67 | + | <div class="mt-4 w-full max-w-md"> | |||
| 68 | + | <form action="login/submit-token" method="POST" class="mb-4"> | |||
| 69 | + | <div class="mb-2"> | |||
| 70 | + | <label for="token" class="block text-sm font-medium text-gray-700">Admin Token</label> | |||
| 71 | + | <input | |||
| 72 | + | type="text" | |||
| 73 | + | id="token" | |||
| 74 | + | name="token" | |||
| 75 | + | placeholder="Enter your admin token" | |||
| 76 | + | class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-gordonmascot-hblue focus:border-gordonmascot-hblue" | |||
| 77 | + | required | |||
| 78 | + | /> | |||
| 79 | + | </div> | |||
| 80 | + | <button | |||
| 81 | + | type="submit" | |||
| 82 | + | class="w-full bg-gordonmascot-mblue hover:bg-gordonmascot-hblue text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-150 ease-in-out" | |||
| 83 | + | > | |||
| 84 | + | Continue with Token | |||
| 85 | + | </button> | |||
| 86 | + | </form> | |||
| 87 | + | <div class="text-center text-sm text-gray-500 mb-4">- OR -</div> | |||
| 88 | + | </div> | |||
| 89 | + | ||||
| 48 | <!-- GitHub OAuth --> | 90 | <!-- GitHub OAuth --> | ||
| 49 | <div | 91 | <div | ||
| 50 | - | class="mt-4 p-2 bg-gordonmascot-hblue text-white rounded-md flex justify-left"> | 92 | + | class="mt-2 p-2 bg-gordonmascot-hblue text-white rounded-md flex justify-left"> |
| 51 | <a href="login/oauth/github" class="flex items-center"> | 93 | <a href="login/oauth/github" class="flex items-center"> | ||
| 52 | <img | 94 | <img | ||
| 53 | src="/assets/imgs/github/github-mark-white.png" | 95 | src="/assets/imgs/github/github-mark-white.png" | ||
M pkg/docker/detect.go +54 -2
| @@ -2,6 +2,10 @@ package docker | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | + | "os/user" | |
| 6 | + | "path/filepath" | |
| 7 | + | ||
| 8 | + | log "github.com/charmbracelet/log" | |
| 5 | 9 | ) | |
| 6 | 10 | ||
| 7 | 11 | func fileExists(filepath string) bool { | |
| @@ -10,6 +14,54 @@ func fileExists(filepath string) bool { | |||
| 10 | 14 | } | |
| 11 | 15 | ||
| 12 | 16 | func IsRunningInContainer() bool { | |
| 13 | - | // Check for .dockerenv file | |
| 14 | - | return fileExists("/.iscontainer") || fileExists("/.dockerenv") | |
| 17 | + | ||
| 18 | + | // Check for .dockerenv file or .iscontainer | |
| 19 | + | isContainer := fileExists("/.iscontainer") || fileExists("/.dockerenv") | |
| 20 | + | log.Debug("IsRunningInContainer: Container detection result", "isContainer", isContainer) | |
| 21 | + | return isContainer | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // DetectPodman automatically detects if Podman is available and returns | |
| 25 | + | // true along with the appropriate socket path if found | |
| 26 | + | func DetectPodman() (bool, string) { | |
| 27 | + | // Check system socket locations first (root installs) | |
| 28 | + | systemSockets := []string{ | |
| 29 | + | "/run/podman/podman.sock", | |
| 30 | + | "/var/run/podman/podman.sock", | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | for _, socket := range systemSockets { | |
| 34 | + | if fileExists(socket) { | |
| 35 | + | log.Debug("DetectPodman: Found system Podman socket", "path", socket) | |
| 36 | + | return true, socket | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Then check for rootless installation | |
| 41 | + | currentUser, err := user.Current() | |
| 42 | + | if err != nil { | |
| 43 | + | log.Error("DetectPodman: Failed to get current user", "error", err) | |
| 44 | + | return false, "" | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Check XDG_RUNTIME_DIR based socket (common for rootless) | |
| 48 | + | xdgRuntime := os.Getenv("XDG_RUNTIME_DIR") | |
| 49 | + | if xdgRuntime != "" { | |
| 50 | + | xdgSocket := filepath.Join(xdgRuntime, "podman", "podman.sock") | |
| 51 | + | if fileExists(xdgSocket) { | |
| 52 | + | log.Debug("DetectPodman: Found rootless Podman socket (XDG)", "path", xdgSocket) | |
| 53 | + | return true, xdgSocket | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Check home directory socket location | |
| 58 | + | homeSocket := filepath.Join(currentUser.HomeDir, ".local", "share", "containers", "podman", "machine", "podman.sock") | |
| 59 | + | if fileExists(homeSocket) { | |
| 60 | + | log.Debug("DetectPodman: Found rootless Podman socket (home)", "path", homeSocket) | |
| 61 | + | return true, homeSocket | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // No Podman socket found | |
| 65 | + | log.Debug("DetectPodman: No Podman sockets found") | |
| 66 | + | return false, "" | |
| 15 | 67 | } | |
| @@ -2,6 +2,10 @@ package docker | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "os" | 4 | "os" | ||
| 5 | + | "os/user" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ||||
| 8 | + | log "github.com/charmbracelet/log" | |||
| 5 | ) | 9 | ) | ||
| 6 | 10 | ||||
| 7 | func fileExists(filepath string) bool { | 11 | func fileExists(filepath string) bool { | ||
| @@ -10,6 +14,54 @@ func fileExists(filepath string) bool { | |||||
| 10 | } | 14 | } | ||
| 11 | 15 | ||||
| 12 | func IsRunningInContainer() bool { | 16 | func IsRunningInContainer() bool { | ||
| 13 | - | // Check for .dockerenv file | 17 | + | |
| 14 | - | return fileExists("/.iscontainer") || fileExists("/.dockerenv") | 18 | + | // Check for .dockerenv file or .iscontainer |
| 19 | + | isContainer := fileExists("/.iscontainer") || fileExists("/.dockerenv") | |||
| 20 | + | log.Debug("IsRunningInContainer: Container detection result", "isContainer", isContainer) | |||
| 21 | + | return isContainer | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // DetectPodman automatically detects if Podman is available and returns | |||
| 25 | + | // true along with the appropriate socket path if found | |||
| 26 | + | func DetectPodman() (bool, string) { | |||
| 27 | + | // Check system socket locations first (root installs) | |||
| 28 | + | systemSockets := []string{ | |||
| 29 | + | "/run/podman/podman.sock", | |||
| 30 | + | "/var/run/podman/podman.sock", | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | for _, socket := range systemSockets { | |||
| 34 | + | if fileExists(socket) { | |||
| 35 | + | log.Debug("DetectPodman: Found system Podman socket", "path", socket) | |||
| 36 | + | return true, socket | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Then check for rootless installation | |||
| 41 | + | currentUser, err := user.Current() | |||
| 42 | + | if err != nil { | |||
| 43 | + | log.Error("DetectPodman: Failed to get current user", "error", err) | |||
| 44 | + | return false, "" | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Check XDG_RUNTIME_DIR based socket (common for rootless) | |||
| 48 | + | xdgRuntime := os.Getenv("XDG_RUNTIME_DIR") | |||
| 49 | + | if xdgRuntime != "" { | |||
| 50 | + | xdgSocket := filepath.Join(xdgRuntime, "podman", "podman.sock") | |||
| 51 | + | if fileExists(xdgSocket) { | |||
| 52 | + | log.Debug("DetectPodman: Found rootless Podman socket (XDG)", "path", xdgSocket) | |||
| 53 | + | return true, xdgSocket | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Check home directory socket location | |||
| 58 | + | homeSocket := filepath.Join(currentUser.HomeDir, ".local", "share", "containers", "podman", "machine", "podman.sock") | |||
| 59 | + | if fileExists(homeSocket) { | |||
| 60 | + | log.Debug("DetectPodman: Found rootless Podman socket (home)", "path", homeSocket) | |||
| 61 | + | return true, homeSocket | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // No Podman socket found | |||
| 65 | + | log.Debug("DetectPodman: No Podman sockets found") | |||
| 66 | + | return false, "" | |||
| 15 | } | 67 | } | ||