detached 5620434

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
5620434bf84877fe89efffccbf54d0fd0d3163cb
author
bamen · · 2025-03-09 15:54 UTC
parent
07c6c3c
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:
191191 | `SESSION_SECRET` | Secret for session encryption | Required |
192192 | `RUN_ENV` | Runtime environment (`dev` or `prod`) | `prod` |
193193
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+
194222 ## Secrets Management
195223
196224 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 Management222 ## 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 (
9696 golang.org/x/net v0.33.0 // indirect
9797 golang.org/x/sys v0.31.0 // indirect
9898 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
100100 golang.org/x/tools v0.24.0 // indirect
101101 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
102102 gotest.tools/v3 v3.5.0 // indirect
@@ -96,7 +96,7 @@ require (
96 golang.org/x/net v0.33.0 // indirect96 golang.org/x/net v0.33.0 // indirect
97 golang.org/x/sys v0.31.0 // indirect97 golang.org/x/sys v0.31.0 // indirect
98 golang.org/x/text v0.23.0 // indirect98 golang.org/x/text v0.23.0 // indirect
99- golang.org/x/time v0.8.0 // indirect99+ golang.org/x/time v0.8.0 // direct
100 golang.org/x/tools v0.24.0 // indirect100 golang.org/x/tools v0.24.0 // indirect
101 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect101 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
102 gotest.tools/v3 v3.5.0 // indirect102 gotest.tools/v3 v3.5.0 // indirect
M internal/cli/cmd/deploy.go +1 -1
@@ -22,7 +22,7 @@ import (
2222
2323 func init() {
2424 log.SetReportTimestamp(true)
25- log.SetTimeFormat("15:04")
25+ log.SetTimeFormat("15:04:05")
2626 }
2727
2828 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 {
114114
115115 if !pushResponse.Success {
116116 log.Error("Push failed", "resp", pushResponse.Message)
117- return fmt.Errorf(pushResponse.Message)
117+ return fmt.Errorf("%s", pushResponse.Message)
118118 }
119119
120120 // 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 message120 // 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 {
6262 e = httpserve.RegisterRoutes(e, a)
6363
6464 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+
6575 if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil {
6676 log.Fatal(err)
6777 }
@@ -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 (
1313
1414 "github.com/bnema/gordon/pkg/docker"
1515 "github.com/bnema/gordon/pkg/parser"
16+ "github.com/charmbracelet/log"
1617 )
1718
18-// Global singleton configuration instance
1919 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
2325 )
2426
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+
2541 type Config struct {
2642 General GeneralConfig `yaml:"General"`
2743 Http HttpConfig `yaml:"Http"`
@@ -77,7 +93,7 @@ type ReverseProxyConfig struct {
7793 // Default values
7894 var (
7995 sock = "/var/run/docker.sock"
80- podmansock = "/run/user/1000/podman/podman.sock"
96+ podmansock = "/run/podman/podman.sock"
8197 reverseProxyPort = "443" // Changed to 443 for standard HTTPS
8298 httpPort = "80" // Standard HTTP port
8399 certDir = "/certs"
@@ -88,11 +104,100 @@ var (
88104 gracePeriod = 30 // seconds
89105 )
90106
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+}
93194
94195 // GetGlobalConfig returns the singleton config instance, loading it if necessary
95196 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")
96201 globalConfigMu.RLock()
97202 if globalConfig != nil {
98203 // If buildConfig is provided, update the Build field
@@ -100,15 +205,21 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) {
100205 globalConfig.Build = *buildConfig
101206 }
102207 config := globalConfig
208+ logger.Debug("GetGlobalConfig: Config exists, releasing read lock")
103209 globalConfigMu.RUnlock()
104- fmt.Println("Using existing global configuration (already loaded)")
210+ logger.Debug("GetGlobalConfig: Using existing global configuration (already loaded)")
105211 return config, nil
106212 }
213+ logger.Debug("GetGlobalConfig: Config not found, releasing read lock")
107214 globalConfigMu.RUnlock()
108215
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")
110218 globalConfigMu.Lock()
111- defer globalConfigMu.Unlock()
219+ defer func() {
220+ logger.Debug("GetGlobalConfig: Releasing write lock via defer")
221+ globalConfigMu.Unlock()
222+ }()
112223
113224 // Double-check in case another goroutine loaded the config while we were waiting
114225 if globalConfig != nil {
@@ -116,12 +227,10 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) {
116227 if buildConfig != nil {
117228 globalConfig.Build = *buildConfig
118229 }
119- fmt.Println("Using existing global configuration (loaded by another goroutine)")
230+ logger.Debug("GetGlobalConfig: Using existing global configuration (loaded by another goroutine)")
120231 return globalConfig, nil
121232 }
122233
123- fmt.Println("Loading global configuration for the first time")
124-
125234 // Initialize config with build info
126235 config := &Config{}
127236 if buildConfig != nil {
@@ -130,74 +239,102 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) {
130239
131240 // Load the config once
132241 var err error
242+ logger.Debug("GetGlobalConfig: About to execute globalConfigOnce.Do")
133243 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()
135247 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")
138251 } else {
139- fmt.Printf("Error loading global configuration: %v\n", err)
252+ logger.Error("Error loading global configuration", "error", err)
140253 }
141254 })
255+ logger.Debug("GetGlobalConfig: Completed globalConfigOnce.Do")
142256
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
144263 }
145264
146265 func getConfigDir() (string, error) {
266+ logger.Debug("getConfigDir: Starting getConfigDir function")
147267 var configDir string
148268
269+ logger.Debug("getConfigDir: Checking if running in WSL")
149270 if isWSL() {
271+ logger.Debug("getConfigDir: Running in WSL")
150272 // Use XDG_CONFIG_HOME for WSL
151273 if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
274+ logger.Debug("getConfigDir: Using XDG_CONFIG_HOME environment variable", "value", xdgConfigHome)
152275 configDir = filepath.Join(xdgConfigHome, "Gordon")
153276 } else {
154277 // 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")
155279 homeDir, err := os.UserHomeDir()
156280 if err != nil {
157281 // 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)
158283 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)
160285 }
161286
162287 configDir = filepath.Join(homeDir, ".config", "Gordon")
163-
288+ logger.Debug("getConfigDir: Set config directory based on home dir", "dir", configDir)
164289 }
165290
291+ logger.Debug("getConfigDir: Returning WSL config directory", "dir", configDir)
166292 return configDir, nil
167293 }
168294
295+ logger.Debug("getConfigDir: Checking if running in container")
169296 if docker.IsRunningInContainer() {
170- return "/", nil
297+ logger.Debug("getConfigDir: Running in container, using current directory")
298+ return ".", nil
171299 }
172300
173301 // Check for XDG_CONFIG_HOME first
302+ logger.Debug("getConfigDir: Checking XDG_CONFIG_HOME environment variable")
174303 if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
304+ logger.Debug("getConfigDir: Using XDG_CONFIG_HOME environment variable", "value", xdgConfigHome)
175305 configDir = filepath.Join(xdgConfigHome, "Gordon")
176306 } else {
177307 // 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")
178309 homeDir, err := os.UserHomeDir()
179310 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)
183313 }
184314
185- // Select the configuration directory based on the OS
315+ // For Windows, use the standard application data directory
186316 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")
188319 } else {
320+ // For Unix systems, use the XDG Base Directory Specification
321+ logger.Debug("getConfigDir: On Unix system, using XDG pattern")
189322 configDir = filepath.Join(homeDir, ".config", "Gordon")
190323 }
191324 }
192325
326+ logger.Debug("getConfigDir: Returning config directory", "dir", configDir)
193327 return configDir, nil
194328 }
195329
196330 func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error {
331+ logger.Debug("readAndUnmarshalConfig: Starting to read and unmarshal config file", "path", filePath)
197332 err := parser.ParseYAMLFile(fs, filePath, config)
198333 if err != nil {
334+ logger.Debug("readAndUnmarshalConfig: Failed to parse YAML file", "error", err)
199335 return fmt.Errorf("error reading and unmarshaling configuration file: %w", err)
200336 }
337+ logger.Debug("readAndUnmarshalConfig: Successfully parsed YAML file")
201338 return nil
202339 }
203340
@@ -207,13 +344,13 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
207344 if val := os.Getenv("GORDON_STORAGE_DIR"); val != "" {
208345 config.General.StorageDir = val
209346 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)
211348 }
212349 }
213350 if val := os.Getenv("GORDON_TOKEN"); val != "" {
214351 config.General.Token = val
215352 if printLogs {
216- fmt.Printf("Using environment variable GORDON_TOKEN\n")
353+ logger.Info("Using environment variable GORDON_TOKEN")
217354 }
218355 }
219356
@@ -221,31 +358,31 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
221358 if val := os.Getenv("GORDON_HTTP_PORT"); val != "" {
222359 config.Http.Port = val
223360 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)
225362 }
226363 }
227364 if val := os.Getenv("GORDON_HTTP_DOMAIN"); val != "" {
228365 config.Http.Domain = val
229366 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)
231368 }
232369 }
233370 if val := os.Getenv("GORDON_HTTP_SUBDOMAIN"); val != "" {
234371 config.Http.SubDomain = val
235372 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)
237374 }
238375 }
239376 if val := os.Getenv("GORDON_HTTP_BACKEND_URL"); val != "" {
240377 config.Http.BackendURL = val
241378 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)
243380 }
244381 }
245382 if val := os.Getenv("GORDON_HTTP_HTTPS"); val != "" {
246383 config.Http.Https = strings.ToLower(val) == "true"
247384 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)
249386 }
250387 }
251388
@@ -253,7 +390,7 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
253390 if val := os.Getenv("GORDON_ADMIN_PATH"); val != "" {
254391 config.Admin.Path = val
255392 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)
257394 }
258395 }
259396
@@ -261,41 +398,45 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
261398 if val := os.Getenv("GORDON_CONTAINER_SOCK"); val != "" {
262399 config.ContainerEngine.Sock = val
263400 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)
265402 }
266403 }
267- if val := os.Getenv("GORDON_CONTAINER_PODMAN_SOCK"); val != "" {
404+ if val := os.Getenv("GORDON_CONTAINER_PODMANSOCK"); val != "" {
268405 config.ContainerEngine.PodmanSock = val
269406 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)
271408 }
272409 }
273410 if val := os.Getenv("GORDON_CONTAINER_PODMAN"); val != "" {
274411 config.ContainerEngine.Podman = strings.ToLower(val) == "true"
275412 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)
277414 }
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
282421 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)
284425 }
285426 }
286427 }
287428 if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" {
288429 config.ContainerEngine.Network = val
289430 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)
291432 }
292433 }
293434
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
297438 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)
299440 }
300441 }
301442
@@ -303,52 +444,52 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
303444 if val := os.Getenv("GORDON_PROXY_PORT"); val != "" {
304445 config.ReverseProxy.Port = val
305446 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)
307448 }
308449 }
309450 if val := os.Getenv("GORDON_PROXY_HTTP_PORT"); val != "" {
310451 config.ReverseProxy.HttpPort = val
311452 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)
313454 }
314455 }
315456 if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" {
316457 config.ReverseProxy.CertDir = val
317458 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)
319460 }
320461 }
321462 if val := os.Getenv("GORDON_PROXY_AUTO_RENEW"); val != "" {
322463 config.ReverseProxy.AutoRenew = strings.ToLower(val) == "true"
323464 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)
325466 }
326467 }
327468 if val := os.Getenv("GORDON_PROXY_RENEW_BEFORE"); val != "" {
328469 if i, err := strconv.Atoi(val); err == nil {
329470 config.ReverseProxy.RenewBefore = i
330471 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)
332473 }
333474 }
334475 }
335476 if val := os.Getenv("GORDON_PROXY_LETSENCRYPT_MODE"); val != "" {
336477 config.ReverseProxy.LetsEncryptMode = val
337478 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)
339480 }
340481 }
341482 if val := os.Getenv("GORDON_PROXY_EMAIL"); val != "" {
342483 config.ReverseProxy.Email = val
343484 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)
345486 }
346487 }
347488 if val := os.Getenv("GORDON_PROXY_CACHE_SIZE"); val != "" {
348489 if i, err := strconv.Atoi(val); err == nil {
349490 config.ReverseProxy.CacheSize = i
350491 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)
352493 }
353494 }
354495 }
@@ -356,61 +497,172 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
356497 if i, err := strconv.Atoi(val); err == nil {
357498 config.ReverseProxy.GracePeriod = i
358499 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)
360501 }
361502 }
362503 }
363504 }
364505
365506 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+
373567 return config, nil
374568 }
375- globalConfigMu.RUnlock()
376569
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()")
377592 configDir, err := getConfigDir()
378593 if err != nil {
594+ logger.Error("LoadConfig: Failed to get config directory", "error", err)
379595 return nil, fmt.Errorf("error getting configuration directory: %w", err)
380596 }
381597
598+ logger.Debug("LoadConfig: Got config directory", "dir", configDir)
599+ logger.Info("Using configuration directory", "dir", configDir)
600+
382601 // Check for directly mounted config.yml in root when in container
383602 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")
387607 } else {
388608 configFilePath = filepath.Join(configDir, "config.yml")
609+ logger.Info("Using config file path", "path", configFilePath)
389610 }
390611
391612 // Add an indicator if this is not the first time we're loading config
392613 if configLogsPrinted {
393- fmt.Println("Reloading configuration (already loaded previously)...")
614+ logger.Info("Reloading configuration (already loaded previously)...")
394615 }
395616
396617 configExists := true
618+ var defaultsApplied bool = false
619+
620+ logger.Debug("LoadConfig: Checking if config file exists", "path", configFilePath)
397621 _, err = os.Stat(configFilePath)
398622 if errors.Is(err, fs.ErrNotExist) {
399623 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)
401625
402626 // Create config dir if it doesn't exist
627+ logger.Debug("LoadConfig: Creating config directory", "dir", configDir)
403628 err = os.MkdirAll(configDir, 0755)
404629 if err != nil {
630+ logger.Error("LoadConfig: Failed to create config directory", "error", err)
405631 return nil, fmt.Errorf("error creating configuration directory: %w", err)
406632 }
407633
408634 // Create config file with the default values of ContainerEngineConfig
635+ logger.Debug("LoadConfig: Getting user home directory")
409636 homeDir, err := os.UserHomeDir()
410637 if err != nil {
638+ logger.Error("LoadConfig: Failed to get user home directory", "error", err)
411639 return nil, fmt.Errorf("could not get user home directory: %w", err)
412640 }
413641
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")
414666 *config = Config{
415667 General: GeneralConfig{
416668 StorageDir: homeDir + "/.gordon",
@@ -426,7 +678,7 @@ func (config *Config) LoadConfig() (*Config, error) {
426678 ContainerEngine: ContainerEngineConfig{
427679 Sock: sock,
428680 PodmanSock: podmansock,
429- Podman: ReadUserInput("Are you using podman ? (y/n)") == "y",
681+ Podman: defaultPodman,
430682 Network: "gordon",
431683 },
432684 ReverseProxy: ReverseProxyConfig{
@@ -441,65 +693,76 @@ func (config *Config) LoadConfig() (*Config, error) {
441693 },
442694 }
443695
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
453698 } else if err != nil {
699+ logger.Error("LoadConfig: Error checking if config file exists", "error", err)
454700 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")
459705 err = readAndUnmarshalConfig(os.DirFS(filepath.Dir(configFilePath)), filepath.Base(configFilePath), config)
460706 if err != nil {
707+ logger.Error("LoadConfig: Failed to read/unmarshal config file", "error", err)
461708 return nil, err
462709 }
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)
463715 }
464716
465717 // 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")
466719 if docker.IsRunningInContainer() {
467720 // Only print logs if we haven't printed them before
468721 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")
470724 loadConfigFromEnv(config, shouldPrintLogs)
471725 configLogsPrinted = true
726+ logger.Debug("LoadConfig: Finished loading config from environment")
472727 }
473728
474729 // Load run env
730+ logger.Debug("LoadConfig: Setting run environment")
475731 config.Build.RunEnv = os.Getenv("RUN_ENV")
732+ logger.Info("Run environment", "env", config.Build.RunEnv)
476733
477734 if config.Build.RunEnv == "" {
478735 config.Build.RunEnv = "prod"
736+ logger.Info("No RUN_ENV specified, defaulting to 'prod'")
479737 }
480738
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")
483743 err = config.SaveConfig()
484744 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)
486747 }
748+ logger.Debug("LoadConfig: Successfully saved configuration with defaults")
487749 }
488750
489751 // Debug output to verify config was loaded correctly
490752 // Only print this debug information if we're also printing the env var logs
491753 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)
498760 } else {
499761 // Just print a simplified message
500- fmt.Printf("Loaded configuration from %s\n", configFilePath)
762+ logger.Info("Loaded configuration from", "path", configFilePath)
501763 }
502764
765+ logger.Debug("LoadConfig: Completed LoadConfig function")
503766 return config, nil
504767 }
505768
@@ -517,7 +780,7 @@ func (config *Config) SaveConfig() error {
517780 configFilePath = filepath.Join(configDir, "config.yml")
518781 }
519782
520- fmt.Printf("Saving configuration to %s\n", configFilePath)
783+ logger.Info("Saving configuration to", "path", configFilePath)
521784
522785 err = parser.WriteYAMLFile(configFilePath, config)
523786 if err != nil {
@@ -562,20 +825,27 @@ func (c *Config) SetToken(token string) {
562825 }
563826
564827 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
571834 }
572835
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
576842 }
577843
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
579849 }
580850
581851 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 *Config20+ logger = log.NewWithOptions(os.Stderr, log.Options{Level: log.InfoLevel})
21- globalConfigOnce sync.Once21+ globalConfig *Config
22- globalConfigMu sync.RWMutex22+ 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 values93 // 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 HTTPS97 reverseProxyPort = "443" // Changed to 443 for standard HTTPS
82 httpPort = "80" // Standard HTTP port98 httpPort = "80" // Standard HTTP port
83 certDir = "/certs"99 certDir = "/certs"
@@ -88,11 +104,100 @@ var (
88 gracePeriod = 30 // seconds104 gracePeriod = 30 // seconds
89 )105 )
90 106
91-// Track whether config logs have been printed already107+// applyDefaultsToConfig applies default values to any fields that have zero values
92-var configLogsPrinted bool = false108+// 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 necessary195 // 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 field203 // If buildConfig is provided, update the Build field
@@ -100,15 +205,21 @@ func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) {
100 globalConfig.Build = *buildConfig205 globalConfig.Build = *buildConfig
101 }206 }
102 config := globalConfig207 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, nil211 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 config216+ // 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 waiting224 // 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 = *buildConfig228 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, nil231 return globalConfig, nil
121 }232 }
122 233
123- fmt.Println("Loading global configuration for the first time")
124-
125 // Initialize config with build info234 // 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 once240 // Load the config once
132 var err error241 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 = config248+ 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, err257+ // 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 string267 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 WSL272 // 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 locations277 // 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 fallback281 // 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 fallback283 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, nil292 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 "/", nil297+ logger.Debug("getConfigDir: Running in container, using current directory")
298+ return ".", nil
171 }299 }
172 300
173 // Check for XDG_CONFIG_HOME first301 // 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 locations307 // 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 fallback311+ logger.Debug("getConfigDir: Failed to get user home directory", "error", err)
181- homeDir = os.TempDir() // Use the system's temp directory as a fallback312+ 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 OS315+ // 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, nil327 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 nil338 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 = val345 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 = val351 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 = val359 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 = val365 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 = val371 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 = val377 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 = val391 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 = val399 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 = val405 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 field416+ // 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.PodmanSock418+ 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 = val429 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 mounting435+ // 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 = val445 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 = val451 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 = val457 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 = i470 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 = val477 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 = val483 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 = i490 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 = i498 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 loaded507+ // 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 instance510+
370- *config = *globalConfig511+ 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, nil567 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 container601 // Check for directly mounted config.yml in root when in container
383 var configFilePath string602 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 config612 // 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 := true617 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 = false623 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 exist626 // 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 ContainerEngineConfig634 // 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 prompt696+ // 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 it703+ 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, err708 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 one717 // 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 before720 // Only print logs if we haven't printed them before
468 shouldPrintLogs := !configLogsPrinted721 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 = true725 configLogsPrinted = true
726+ logger.Debug("LoadConfig: Finished loading config from environment")
472 }727 }
473 728
474 // Load run env729 // 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 it739+ // 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 correctly751 // Debug output to verify config was loaded correctly
490 // Only print this debug information if we're also printing the env var logs752 // 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 message761 // 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, nil766 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 file828+ 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 true832+ 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 variable836+ // Read /proc/version file
574- if os.Getenv("WSL_DISTRO_NAME") != "" {837+ logger.Debug("isWSL: Reading /proc/version file")
575- return true838+ 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 false844+ // 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
33 import (
44 "bufio"
55 "fmt"
6- "log"
76 "os"
87 "strings"
8+
9+ "github.com/charmbracelet/log"
910 )
1011
1112 // ReadUserInput reads a string input from the user with a prompt.
1213 // It handles multi-word inputs and properly checks for errors.
1314 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()
1520
1621 // Use bufio.NewReader for better input handling
1722 reader := bufio.NewReader(os.Stdin)
23+ log.Debug("ReadUserInput: Created reader, waiting for input...")
1824
1925 // ReadString reads until newline, handling spaces in input
2026 input, err := reader.ReadString('\n')
2127 if err != nil {
22- log.Printf("Error reading input: %v", err)
28+ log.Error("ReadUserInput: Error reading input", "error", err)
2329 return ""
2430 }
2531
2632 // 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
2836 }
2937
3038 // ReadUserInputWithValidation reads user input and validates it using a provided function.
@@ -42,11 +50,13 @@ func ReadUserInputWithValidation(prompt string, validate func(string) bool) stri
4250 // ReadUserInputWithDefault reads user input with a default value if input is empty.
4351 func ReadUserInputWithDefault(prompt string, defaultValue string) string {
4452 fmt.Printf("%s (default: %s) ", prompt, defaultValue)
53+ // Flush to ensure the prompt is displayed
54+ os.Stdout.Sync()
4555
4656 reader := bufio.NewReader(os.Stdin)
4757 input, err := reader.ReadString('\n')
4858 if err != nil {
49- log.Printf("Error reading input: %v", err)
59+ log.Error("Error reading input", "error", err)
5060 return defaultValue
5161 }
5262
@@ -70,3 +80,32 @@ func ReadConfirmation(prompt string) bool {
7080 input = strings.ToLower(input)
7181 return input == "y" || input == "yes"
7282 }
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 handling21 // 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 input25 // 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 characters32 // 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 defaultValue60 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 {
3737 return nil
3838 }
3939
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+
4067 // RenderLoginPage renders the login.html template
4168 func RenderLoginPage(c echo.Context, a *server.App) error {
69+ // Check for token in query param for backward compatibility
4270 urlToken = c.QueryParam("token")
71+
72+ // Check for error message
73+ errorMsg := c.QueryParam("error")
74+
4375 data := map[string]interface{}{
4476 "Title": "Login",
77+ "Error": errorMsg,
4578 }
4679
4780 // Navigate inside the fs.FS to get the template
@@ -37,11 +37,44 @@ func CompareGordonToken(c echo.Context, a *server.App) error {
37 return nil37 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 template67 // 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 template80 // Navigate inside the fs.FS to get the template
M internal/httpserve/router.go +16 -1
@@ -4,6 +4,7 @@ import (
44 "github.com/bnema/gordon/internal/httpserve/handlers"
55 "github.com/bnema/gordon/internal/httpserve/middleware"
66 "github.com/bnema/gordon/internal/server"
7+ "github.com/charmbracelet/log"
78 _ "github.com/joho/godotenv/autoload"
89 "github.com/labstack/echo/v4"
910 echomid "github.com/labstack/echo/v4/middleware"
@@ -11,7 +12,8 @@ import (
1112
1213 // RegisterRoutes registers all routes and middlewares
1314 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())
1517 e.Use(echomid.Recover())
1618 // Initiate the session middleware
1719 e.Use(middleware.InitSessionMiddleware(a))
@@ -102,6 +104,9 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
102104 e.GET(adminPath+"/login", func(c echo.Context) error {
103105 return handlers.RenderLoginPage(c, a)
104106 })
107+ e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {
108+ return handlers.HandleTokenSubmission(c, a)
109+ })
105110 e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error {
106111 return handlers.StartOAuthGithub(c, a)
107112 })
@@ -127,6 +132,16 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
127132 adminGroup.GET("/cc/:ID", func(c echo.Context) error {
128133 return handlers.CreateContainerFullGET(c, a)
129134 }, 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+ })
130145 }
131146
132147 // 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 middlewares13 // 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 verbose15+ // Add default Echo logger middleware
16+ e.Use(echomid.Logger())
15 e.Use(echomid.Recover())17 e.Use(echomid.Recover())
16 // Initiate the session middleware18 // 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 endpoints147 // bindHTMXEndpoints binds all HTMX endpoints
M internal/proxy/proxy.go +734 -163
@@ -2,13 +2,22 @@ package proxy
22
33 import (
44 "context"
5+ "crypto/ecdsa"
6+ "crypto/elliptic"
7+ "crypto/rand"
58 "crypto/tls"
9+ "crypto/x509"
10+ "crypto/x509/pkix"
611 "database/sql"
12+ "encoding/pem"
713 "fmt"
14+ "math/big"
15+ "net"
816 "net/http"
917 "net/http/httputil"
1018 "net/url"
1119 "os"
20+ "regexp"
1221 "strings"
1322 "sync"
1423 "time"
@@ -20,6 +29,7 @@ import (
2029 "github.com/labstack/echo/v4/middleware"
2130 "golang.org/x/crypto/acme"
2231 "golang.org/x/crypto/acme/autocert"
32+ "golang.org/x/time/rate"
2333 )
2434
2535 // Proxy represents the reverse proxy server
@@ -31,6 +41,7 @@ type Proxy struct {
3141 routes map[string]*ProxyRouteInfo
3242 mu sync.RWMutex
3343 certManager *autocert.Manager
44+ fallbackCert *tls.Certificate // Fallback self-signed certificate
3445 serverStarted bool
3546 blacklist *BlacklistConfig
3647
@@ -139,32 +150,50 @@ func (p *Proxy) setupMiddleware() {
139150 // Create blacklist middleware for HTTPS
140151 p.httpsServer.Use(p.createBlacklistMiddleware())
141152
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")
151168 },
152169 }))
153170
171+ // Add default Echo logger middleware
172+ p.httpsServer.Use(middleware.Logger())
173+
154174 // Create blacklist middleware for HTTP
155175 p.httpServer.Use(p.createBlacklistMiddleware())
156176
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")
166192 },
167193 }))
194+
195+ // Add default Echo logger middleware for HTTP server
196+ p.httpServer.Use(middleware.Logger())
168197 }
169198
170199 // createBlacklistMiddleware returns a middleware function that checks IPs against the blacklist
@@ -174,6 +203,24 @@ func (p *Proxy) createBlacklistMiddleware() echo.MiddlewareFunc {
174203 // Get client IP
175204 clientIP := c.RealIP()
176205
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+
177224 // Check if IP is blacklisted (if blacklist exists)
178225 if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) {
179226 // Set context for other middleware
@@ -201,6 +248,13 @@ func (p *Proxy) setupCertManager() {
201248 dir = p.app.GetConfig().General.StorageDir + "/certs"
202249 }
203250
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+
204258 // Set up the certificate manager
205259 certManager := &autocert.Manager{
206260 Prompt: autocert.AcceptTOS,
@@ -217,13 +271,26 @@ func (p *Proxy) setupCertManager() {
217271 certManager.Client = &acme.Client{
218272 DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory",
219273 }
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)
220283 }
221284
222285 // Set HostPolicy to allow the admin domain
223286 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
224291 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 {
227294 return nil
228295 }
229296
@@ -235,7 +302,36 @@ func (p *Proxy) setupCertManager() {
235302 return nil
236303 }
237304
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
239335 }
240336
241337 p.certManager = certManager
@@ -247,103 +343,343 @@ func (p *Proxy) setupCertManager() {
247343 go p.requestAdminCertificate()
248344 }
249345
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+
250413 // requestAdminCertificate preemptively requests a Let's Encrypt certificate
251414 // for the Gordon admin interface
252415 func (p *Proxy) requestAdminCertificate() {
253416 adminDomain := p.app.GetConfig().Http.FullDomain()
417+ if adminDomain == "" {
418+ return
419+ }
254420
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",
256444 "domain", adminDomain,
257- "email", p.config.Email,
258- "mode", p.config.LetsEncryptMode,
259- "cert_dir", p.config.CertDir)
445+ "ips", ipStrings)
260446
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
269453 }
270454
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
274458
275- log.Debug("Sending HTTPS request to trigger certificate creation",
276- "url", url)
459+ if !p.app.IsDevEnvironment() && email != "" {
460+ mode = "production"
461+ }
277462
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",
282507 "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+ }
284522
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",
290531 "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)
291542 }
543+ } else if certResult != nil {
544+ log.Info("Successfully obtained Let's Encrypt certificate",
545+ "domain", adminDomain)
292546 } else {
293- resp.Body.Close()
294- log.Info("Initial HTTPS request completed (unexpected)",
547+ log.Error("Unexpected error: received nil certificate but no error",
295548 "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)
297552 }
553+}
298554
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
303558
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+ }
307579
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+ }
315597 }
316598
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)
321613
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+ }
325621
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,
329625 "error", err)
330626
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
346663 }
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/)")
347683 }
348684
349685 // Start loads the routes from the database and starts the proxy server
@@ -377,7 +713,7 @@ func (p *Proxy) Start() error {
377713
378714 // In a container environment, we need to use the host container IP
379715 // 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
381717
382718 // Fall back options for container IP
383719 if os.Getenv("GORDON_ADMIN_HOST") != "" {
@@ -392,14 +728,9 @@ func (p *Proxy) Start() error {
392728 "hostname", containerIP)
393729 }
394730
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
403734
404735 p.mu.Lock()
405736 // Only add if it doesn't already exist
@@ -417,6 +748,25 @@ func (p *Proxy) Start() error {
417748 "domain", adminDomain,
418749 "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
419750 }
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+ }
420770 p.mu.Unlock()
421771
422772 // Configure the proxy routes
@@ -441,6 +791,20 @@ func (p *Proxy) Start() error {
441791 host = strings.Split(host, ":")[0]
442792 }
443793
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+
444808 // Redirect to HTTPS
445809 return c.Redirect(http.StatusMovedPermanently,
446810 fmt.Sprintf("https://%s%s", host, c.Request().RequestURI))
@@ -451,11 +815,42 @@ func (p *Proxy) Start() error {
451815 Addr: ":" + p.config.Port,
452816 Handler: p.httpsServer,
453817 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},
456850 },
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,
459854 IdleTimeout: 120 * time.Second,
460855 }
461856
@@ -463,8 +858,8 @@ func (p *Proxy) Start() error {
463858 httpServer := &http.Server{
464859 Addr: ":" + p.config.HttpPort,
465860 Handler: p.httpServer,
466- ReadTimeout: 5 * time.Minute,
467- WriteTimeout: 5 * time.Minute,
861+ ReadTimeout: 30 * time.Second,
862+ WriteTimeout: 30 * time.Second,
468863 IdleTimeout: 120 * time.Second,
469864 }
470865
@@ -492,62 +887,80 @@ func (p *Proxy) Start() error {
492887 // testAdminConnection attempts to find a working connection to the Gordon admin
493888 // by testing different hostnames and IPs
494889 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")
496891
497892 // Create a client with a short timeout to quickly test connections
498893 client := &http.Client{
499- Timeout: 1 * time.Second,
894+ Timeout: 2 * time.Second,
500895 }
501896
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
512904 }
513905
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+ }
517910
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)
521924 }
522- seen[addr] = true
523- uniqueAddresses = append(uniqueAddresses, addr)
524925 }
926+ hostsToTry = uniqueHosts
525927
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)
530939
531- resp, err := client.Get(url)
532940 if err == nil {
941+ statusCode := resp.StatusCode
533942 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
543953 } else {
544954 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)
547958 }
548959 }
549960
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)
551964 return defaultHost
552965 }
553966
@@ -669,29 +1082,51 @@ func (p *Proxy) configureRoutes() {
6691082 "path", c.Request().URL.Path,
6701083 "method", c.Request().Method)
6711084
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+
6721098 // Find the route for this host
6731099 route, ok := p.routes[host]
6741100 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+
6751107 // Create list of available domains for debugging
6761108 availableDomains := make([]string, 0, len(p.routes))
6771109 for d := range p.routes {
6781110 availableDomains = append(availableDomains, d)
6791111 }
6801112
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(),
6831117 "available_domains", strings.Join(availableDomains, ", "))
6841118 return c.String(http.StatusNotFound, "Domain not found")
6851119 }
6861120
6871121 // Check if the route is active
6881122 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())
6901126 return c.String(http.StatusServiceUnavailable, "Route is not active")
6911127 }
6921128
6931129 // Special handling for the admin domain
694- adminDomain := p.app.GetConfig().Http.FullDomain()
6951130 if host == adminDomain {
6961131 log.Debug("Proxying request to admin domain",
6971132 "domain", host,
@@ -890,23 +1325,6 @@ func generateUUID() string {
8901325 return fmt.Sprintf("%d", time.Now().UnixNano())
8911326 }
8921327
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-
9101328 // GetRoutes returns a copy of the routes map
9111329 func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo {
9121330 p.mu.RLock()
@@ -976,3 +1394,156 @@ func (p *Proxy) logBlockedIP(clientIP, path, userAgent string) {
9761394 log.Debug("Request marked as blacklisted for logging skip", "ip", clientIP)
9771395 }
9781396 }
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 server35 // Proxy represents the reverse proxy server
@@ -31,6 +41,7 @@ type Proxy struct {
31 routes map[string]*ProxyRouteInfo41 routes map[string]*ProxyRouteInfo
32 mu sync.RWMutex42 mu sync.RWMutex
33 certManager *autocert.Manager43 certManager *autocert.Manager
44+ fallbackCert *tls.Certificate // Fallback self-signed certificate
34 serverStarted bool45 serverStarted bool
35 blacklist *BlacklistConfig46 blacklist *BlacklistConfig
36 47
@@ -139,32 +150,50 @@ func (p *Proxy) setupMiddleware() {
139 // Create blacklist middleware for HTTPS150 // 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 IPs153+ // 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 blacklisted157+ 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 true159+ ExpiresIn: 3 * time.Minute, // Store expiration
149- }160+ },
150- return false161+ ),
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 HTTP174 // 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 IPs177+ // 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 blacklisted181+ 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 true183+ ExpiresIn: 3 * time.Minute, // Store expiration
164- }184+ },
165- return false185+ ),
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 blacklist199 // createBlacklistMiddleware returns a middleware function that checks IPs against the blacklist
@@ -174,6 +203,24 @@ func (p *Proxy) createBlacklistMiddleware() echo.MiddlewareFunc {
174 // Get client IP203 // 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 middleware226 // 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 manager258 // 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 domain285 // 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 domain292+ // Always allow the admin domain and root domain
226- if host == adminDomain {293+ if host == adminDomain || host == rootDomain {
227 return nil294 return nil
228 }295 }
229 296
@@ -235,7 +302,36 @@ func (p *Proxy) setupCertManager() {
235 return nil302 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 = certManager337 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 certificate413 // requestAdminCertificate preemptively requests a Let's Encrypt certificate
251 // for the Gordon admin interface414 // 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 generation447+ // 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 cert451+ "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 generation455+ // Check if environment is production
272- // The first attempt will generate a certificate through Let's Encrypt456+ 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 request463+ // 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 configured523+ 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 obtained555+// 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 cache559+ 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- return586+ "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 check614+ // 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 wrong627+ // 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 server685 // 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 IP714 // 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 localhost715 // instead of 127.0.0.1 because each container has its own localhost
380- containerIP := "host.containers.internal" // Modern Docker/Podman default hostname for host716+ containerIP := "localhost" // Default to localhost for most reliable connectivity
381 717
382 // Fall back options for container IP718 // 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 connections731+ // 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 exist736 // 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 routes772 // 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 HTTPS808 // 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 admin887 // testAdminConnection attempts to find a working connection to the Gordon admin
493 // by testing different hostnames and IPs888 // 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 connections892 // 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 first899+ "localhost",
505- "localhost", // Local connections900+ "127.0.0.1", // Always try localhost first
506- "127.0.0.1", // Localhost IP901+ defaultHost, // The current host value
507- "host.docker.internal", // Docker host902+ "host.docker.internal", // Docker Desktop default
508- "host.containers.internal", // Podman/Docker host903+ "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 entries906+ // 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- continue913+ // 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 address928+ 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 addr947+ "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 default961+ // 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 defaultHost964 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 host1098 // 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 debugging1107 // 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 active1121 // 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 domain1129 // 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 map1328 // 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 @@
4545 <div id="login-section">
4646 <div class="flex flex-col items-center">
4747 <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+
4890 <!-- GitHub OAuth -->
4991 <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">
5193 <a href="login/oauth/github" class="flex items-center">
5294 <img
5395 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 <div91 <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 <img94 <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
22
33 import (
44 "os"
5+ "os/user"
6+ "path/filepath"
7+
8+ log "github.com/charmbracelet/log"
59 )
610
711 func fileExists(filepath string) bool {
@@ -10,6 +14,54 @@ func fileExists(filepath string) bool {
1014 }
1115
1216 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, ""
1567 }
@@ -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 file17+
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 }

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close