feat: Enhance configuration defaults and certificate handling
- Implement checks for explicit configuration of EnableHttpLogs in config.yml. - Update default behavior to set EnableHttpLogs to true only if not explicitly defined. - Improve logging for applied default values and explicit configurations. - Refactor certificate handling to ensure fallback mechanisms are robust and well-logged. - Introduce methods for preloading certificates and handling rate limits effectively.
- commit
- da25f67eb0aed8be9a392939e31d80fe4cc8cee5
- author
- bamen · · 2025-03-25 08:17 UTC
- parent
- 2f01687
- signature
- unsigned
7 files · +1160 -408
M internal/common/init_config.go +24 -3
| @@ -202,8 +202,29 @@ func applyDefaultsToConfig(config *Config) bool { | |||
| 202 | 202 | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | |
| 203 | 203 | defaultsApplied = true | |
| 204 | 204 | } | |
| 205 | - | // Set EnableHttpLogs to true by default if not specified | |
| 206 | - | if !config.ReverseProxy.EnableHttpLogs { | |
| 205 | + | // Check if "enableHttpLogs" is explicitly mentioned in the config file | |
| 206 | + | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | |
| 207 | + | if fileExists(configFilePath) { | |
| 208 | + | yamlContent, err := os.ReadFile(configFilePath) | |
| 209 | + | if err == nil { | |
| 210 | + | // Only set default if enableHttpLogs not explicitly specified | |
| 211 | + | if !strings.Contains(string(yamlContent), "enableHttpLogs:") { | |
| 212 | + | config.ReverseProxy.EnableHttpLogs = true | |
| 213 | + | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true) | |
| 214 | + | defaultsApplied = true | |
| 215 | + | } else { | |
| 216 | + | logger.Debug("Keeping explicit value for ReverseProxy.EnableHttpLogs", "value", config.ReverseProxy.EnableHttpLogs) | |
| 217 | + | } | |
| 218 | + | } else { | |
| 219 | + | // If we can't read the file, apply the default only if not set | |
| 220 | + | if !config.ReverseProxy.EnableHttpLogs { | |
| 221 | + | config.ReverseProxy.EnableHttpLogs = true | |
| 222 | + | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true) | |
| 223 | + | defaultsApplied = true | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } else { | |
| 227 | + | // If config file doesn't exist yet, set the default | |
| 207 | 228 | config.ReverseProxy.EnableHttpLogs = true | |
| 208 | 229 | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true) | |
| 209 | 230 | defaultsApplied = true | |
| @@ -230,7 +251,7 @@ func applyDefaultsToConfig(config *Config) bool { | |||
| 230 | 251 | } | |
| 231 | 252 | ||
| 232 | 253 | // Handle autoRenew | |
| 233 | - | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | |
| 254 | + | configFilePath = filepath.Join(getConfigDirMustExist(), "config.yml") | |
| 234 | 255 | if fileExists(configFilePath) { | |
| 235 | 256 | yamlContent, err := os.ReadFile(configFilePath) | |
| 236 | 257 | if err == nil { | |
| @@ -202,8 +202,29 @@ func applyDefaultsToConfig(config *Config) bool { | |||||
| 202 | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | 202 | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | ||
| 203 | defaultsApplied = true | 203 | defaultsApplied = true | ||
| 204 | } | 204 | } | ||
| 205 | - | // Set EnableHttpLogs to true by default if not specified | 205 | + | // Check if "enableHttpLogs" is explicitly mentioned in the config file |
| 206 | - | if !config.ReverseProxy.EnableHttpLogs { | 206 | + | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") |
| 207 | + | if fileExists(configFilePath) { | |||
| 208 | + | yamlContent, err := os.ReadFile(configFilePath) | |||
| 209 | + | if err == nil { | |||
| 210 | + | // Only set default if enableHttpLogs not explicitly specified | |||
| 211 | + | if !strings.Contains(string(yamlContent), "enableHttpLogs:") { | |||
| 212 | + | config.ReverseProxy.EnableHttpLogs = true | |||
| 213 | + | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true) | |||
| 214 | + | defaultsApplied = true | |||
| 215 | + | } else { | |||
| 216 | + | logger.Debug("Keeping explicit value for ReverseProxy.EnableHttpLogs", "value", config.ReverseProxy.EnableHttpLogs) | |||
| 217 | + | } | |||
| 218 | + | } else { | |||
| 219 | + | // If we can't read the file, apply the default only if not set | |||
| 220 | + | if !config.ReverseProxy.EnableHttpLogs { | |||
| 221 | + | config.ReverseProxy.EnableHttpLogs = true | |||
| 222 | + | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true) | |||
| 223 | + | defaultsApplied = true | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | } else { | |||
| 227 | + | // If config file doesn't exist yet, set the default | |||
| 207 | config.ReverseProxy.EnableHttpLogs = true | 228 | config.ReverseProxy.EnableHttpLogs = true | ||
| 208 | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true) | 229 | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true) | ||
| 209 | defaultsApplied = true | 230 | defaultsApplied = true | ||
| @@ -230,7 +251,7 @@ func applyDefaultsToConfig(config *Config) bool { | |||||
| 230 | } | 251 | } | ||
| 231 | 252 | ||||
| 232 | // Handle autoRenew | 253 | // Handle autoRenew | ||
| 233 | - | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | 254 | + | configFilePath = filepath.Join(getConfigDirMustExist(), "config.yml") |
| 234 | if fileExists(configFilePath) { | 255 | if fileExists(configFilePath) { | ||
| 235 | yamlContent, err := os.ReadFile(configFilePath) | 256 | yamlContent, err := os.ReadFile(configFilePath) | ||
| 236 | if err == nil { | 257 | if err == nil { | ||
M internal/proxy/certificates.go +748 -55
| @@ -109,36 +109,40 @@ func (p *Proxy) setupCertManager() { | |||
| 109 | 109 | // First check for upstream TLS-terminating proxies if detection is enabled | |
| 110 | 110 | isProxied := p.detectUpstreamProxy() | |
| 111 | 111 | ||
| 112 | + | // Get admin domain and root domain | |
| 113 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 114 | + | rootDomain := p.app.GetConfig().Http.Domain | |
| 115 | + | ||
| 116 | + | // Generate a fallback certificate for admin domain regardless of proxy status | |
| 117 | + | var fallbackDomains []string | |
| 118 | + | fallbackDomains = append(fallbackDomains, adminDomain) | |
| 119 | + | ||
| 120 | + | // Include root domain in fallback certificate if it's different from admin domain | |
| 121 | + | if rootDomain != "" && rootDomain != adminDomain { | |
| 122 | + | fallbackDomains = append(fallbackDomains, rootDomain) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Generate the fallback certificate | |
| 126 | + | fallbackCert, err := generateFallbackCertificates(fallbackDomains) | |
| 127 | + | if err != nil { | |
| 128 | + | logger.Warn("Failed to generate fallback certificate", "error", err) | |
| 129 | + | } else { | |
| 130 | + | logger.Info("Generated fallback self-signed certificate for admin domain", | |
| 131 | + | "domain", adminDomain, | |
| 132 | + | "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05")) | |
| 133 | + | // Store the fallback certificate | |
| 134 | + | p.fallbackCert = fallbackCert | |
| 135 | + | } | |
| 136 | + | ||
| 112 | 137 | // Skip certificate acquisition if we're behind a TLS-terminating proxy | |
| 113 | 138 | // and the skip certificates option is enabled | |
| 114 | 139 | if isProxied && p.config.SkipCertificates { | |
| 115 | 140 | logger.Info("Upstream TLS-terminating proxy detected, skipping Let's Encrypt certificate acquisition", | |
| 116 | 141 | "upstream_proxy", "detected", | |
| 117 | 142 | "certificate_acquisition", "disabled") | |
| 118 | - | // We still setup a fallback cert for the admin domain | |
| 119 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 120 | - | rootDomain := p.app.GetConfig().Http.Domain | |
| 121 | - | ||
| 122 | - | var fallbackDomains []string | |
| 123 | - | fallbackDomains = append(fallbackDomains, adminDomain) | |
| 124 | - | ||
| 125 | - | // Include root domain in fallback certificate if it's different from admin domain | |
| 126 | - | if rootDomain != "" && rootDomain != adminDomain { | |
| 127 | - | fallbackDomains = append(fallbackDomains, rootDomain) | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | fallbackCert, err := generateFallbackCertificates(fallbackDomains) | |
| 131 | - | if err != nil { | |
| 132 | - | logger.Warn("Failed to generate fallback certificate", "error", err) | |
| 133 | - | } else { | |
| 134 | - | logger.Info("Generated fallback self-signed certificate for admin domain behind proxy", | |
| 135 | - | "domain", adminDomain, | |
| 136 | - | "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05")) | |
| 137 | - | // Store the fallback certificate | |
| 138 | - | p.fallbackCert = fallbackCert | |
| 139 | - | } | |
| 140 | 143 | ||
| 141 | - | return | |
| 144 | + | // We will still initialize the certificate manager but will use it in a more limited way | |
| 145 | + | logger.Info("Initializing minimal Let's Encrypt certificate manager for fallback purposes") | |
| 142 | 146 | } | |
| 143 | 147 | ||
| 144 | 148 | // Create a cache directory if it doesn't exist | |
| @@ -181,10 +185,6 @@ func (p *Proxy) setupCertManager() { | |||
| 181 | 185 | "url", acme.LetsEncryptURL) | |
| 182 | 186 | } | |
| 183 | 187 | ||
| 184 | - | // Set HostPolicy to allow the admin domain | |
| 185 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 186 | - | rootDomain := p.app.GetConfig().Http.Domain | |
| 187 | - | ||
| 188 | 188 | // Use a stricter hostpolicy that only allows domains in our routes | |
| 189 | 189 | certManager.HostPolicy = func(_ context.Context, host string) error { | |
| 190 | 190 | // Always allow the admin domain and root domain | |
| @@ -210,31 +210,14 @@ func (p *Proxy) setupCertManager() { | |||
| 210 | 210 | return fmt.Errorf("host %q not configured in routes", host) | |
| 211 | 211 | } | |
| 212 | 212 | ||
| 213 | - | // Generate a fallback self-signed certificate for the admin domain | |
| 214 | - | var fallbackDomains []string | |
| 215 | - | fallbackDomains = append(fallbackDomains, adminDomain) | |
| 216 | - | ||
| 217 | - | // Include root domain in fallback certificate if it's different from admin domain | |
| 218 | - | if rootDomain != "" && rootDomain != adminDomain { | |
| 219 | - | fallbackDomains = append(fallbackDomains, rootDomain) | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | fallbackCert, err := generateFallbackCertificates(fallbackDomains) | |
| 223 | - | if err != nil { | |
| 224 | - | logger.Warn("Failed to generate fallback certificate", "error", err) | |
| 225 | - | } else { | |
| 226 | - | logger.Info("Generated fallback self-signed certificate for admin domain", | |
| 227 | - | "domain", adminDomain, | |
| 228 | - | "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05")) | |
| 229 | - | // Store the fallback certificate | |
| 230 | - | p.fallbackCert = fallbackCert | |
| 231 | - | } | |
| 232 | - | ||
| 233 | 213 | p.certManager = certManager | |
| 234 | 214 | logger.Debug("Certificate manager setup completed", | |
| 235 | 215 | "directory", dir, | |
| 236 | 216 | "mode", p.config.LetsEncryptMode) | |
| 237 | 217 | ||
| 218 | + | // Preload certificates from disk into cache to avoid unnecessary Let's Encrypt requests | |
| 219 | + | p.preloadCertificates() | |
| 220 | + | ||
| 238 | 221 | // Only request admin certificate during server startup, not during specific domain processing | |
| 239 | 222 | if !p.processingSpecificDomain { | |
| 240 | 223 | // Request the certificate for the admin domain | |
| @@ -343,13 +326,52 @@ func getCertificateType(cert *x509.Certificate) string { | |||
| 343 | 326 | // Returns whether it's valid and how many days until expiration, and the certificate type | |
| 344 | 327 | func isCertificateValid(certData []byte, domain string) (bool, float64, string, *x509.Certificate) { | |
| 345 | 328 | // Parse the certificate to check its validity | |
| 346 | - | block, _ := pem.Decode(certData) | |
| 329 | + | block, rest := pem.Decode(certData) | |
| 347 | 330 | if block == nil || block.Type != "CERTIFICATE" { | |
| 348 | - | logger.Warn("Invalid certificate data", | |
| 349 | - | "domain", domain) | |
| 331 | + | logger.Warn("Invalid certificate data - not a valid PEM CERTIFICATE block", | |
| 332 | + | "domain", domain, | |
| 333 | + | "dataLength", len(certData)) | |
| 334 | + | ||
| 335 | + | // Debug: output first few bytes to help diagnose format issues | |
| 336 | + | if len(certData) > 0 { | |
| 337 | + | previewLen := 50 | |
| 338 | + | if len(certData) < previewLen { | |
| 339 | + | previewLen = len(certData) | |
| 340 | + | } | |
| 341 | + | preview := string(certData[:previewLen]) | |
| 342 | + | logger.Debug("Certificate data preview", | |
| 343 | + | "domain", domain, | |
| 344 | + | "preview", preview+"...") | |
| 345 | + | } | |
| 350 | 346 | return false, 0, "unknown", nil | |
| 351 | 347 | } | |
| 352 | 348 | ||
| 349 | + | // Check if there are additional certificates in the chain | |
| 350 | + | var certChain []*x509.Certificate | |
| 351 | + | currentRest := rest | |
| 352 | + | ||
| 353 | + | for len(currentRest) > 0 { | |
| 354 | + | var nextBlock *pem.Block | |
| 355 | + | nextBlock, currentRest = pem.Decode(currentRest) | |
| 356 | + | if nextBlock == nil || nextBlock.Type != "CERTIFICATE" { | |
| 357 | + | break | |
| 358 | + | } | |
| 359 | + | cert, err := x509.ParseCertificate(nextBlock.Bytes) | |
| 360 | + | if err != nil { | |
| 361 | + | logger.Debug("Failed to parse certificate in chain", | |
| 362 | + | "domain", domain, | |
| 363 | + | "error", err) | |
| 364 | + | continue | |
| 365 | + | } | |
| 366 | + | certChain = append(certChain, cert) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | if len(certChain) > 0 { | |
| 370 | + | logger.Debug("Found certificate chain with additional certificates", | |
| 371 | + | "domain", domain, | |
| 372 | + | "chain_length", len(certChain)) | |
| 373 | + | } | |
| 374 | + | ||
| 353 | 375 | cert, err := x509.ParseCertificate(block.Bytes) | |
| 354 | 376 | if err != nil { | |
| 355 | 377 | logger.Warn("Failed to parse certificate", | |
| @@ -358,8 +380,35 @@ func isCertificateValid(certData []byte, domain string) (bool, float64, string, | |||
| 358 | 380 | return false, 0, "unknown", nil | |
| 359 | 381 | } | |
| 360 | 382 | ||
| 383 | + | // Verify the certificate includes the domain we're checking for | |
| 384 | + | var domainFound bool | |
| 385 | + | if len(cert.DNSNames) > 0 { | |
| 386 | + | logger.Debug("Certificate DNS names", | |
| 387 | + | "domain", domain, | |
| 388 | + | "certificate_domains", cert.DNSNames) | |
| 389 | + | ||
| 390 | + | for _, dnsName := range cert.DNSNames { | |
| 391 | + | if dnsName == domain { | |
| 392 | + | domainFound = true | |
| 393 | + | break | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | if !domainFound && cert.Subject.CommonName != domain { | |
| 399 | + | logger.Warn("Certificate doesn't match the required domain", | |
| 400 | + | "domain", domain, | |
| 401 | + | "cert_common_name", cert.Subject.CommonName, | |
| 402 | + | "cert_dns_names", cert.DNSNames) | |
| 403 | + | return false, 0, "unknown", cert | |
| 404 | + | } | |
| 405 | + | ||
| 361 | 406 | // Determine the certificate type | |
| 362 | 407 | certType := getCertificateType(cert) | |
| 408 | + | logger.Debug("Determined certificate type", | |
| 409 | + | "domain", domain, | |
| 410 | + | "type", certType, | |
| 411 | + | "issuer", cert.Issuer.CommonName) | |
| 363 | 412 | ||
| 364 | 413 | // Check if the certificate is still valid | |
| 365 | 414 | now := time.Now() | |
| @@ -816,6 +865,46 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 816 | 865 | return | |
| 817 | 866 | } | |
| 818 | 867 | ||
| 868 | + | // First, check if certificate is already in the autocert cache | |
| 869 | + | // This is the critical step to avoid unnecessary Let's Encrypt requests | |
| 870 | + | cacheKey := "cert-" + adminDomain | |
| 871 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 872 | + | defer cancel() | |
| 873 | + | ||
| 874 | + | if p.certManager != nil && p.certManager.Cache != nil { | |
| 875 | + | certData, err := p.certManager.Cache.Get(ctx, cacheKey) | |
| 876 | + | if err == nil && len(certData) > 0 { | |
| 877 | + | // Check if the cached certificate is valid | |
| 878 | + | isValid, expiresInDays, certType, _ := isCertificateValid(certData, adminDomain) | |
| 879 | + | if isValid { | |
| 880 | + | logger.Info("Using valid certificate from cache", | |
| 881 | + | "domain", adminDomain, | |
| 882 | + | "type", certType, | |
| 883 | + | "expires_in_days", expiresInDays) | |
| 884 | + | return | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | // If certificate is invalid or expiring soon, log but continue | |
| 888 | + | logger.Info("Cached certificate is invalid or expiring soon", | |
| 889 | + | "domain", adminDomain, | |
| 890 | + | "expires_in_days", expiresInDays, | |
| 891 | + | "valid", isValid) | |
| 892 | + | } | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | // Additional rate limit safety check | |
| 896 | + | // Check if we've recently hit a rate limit for this domain | |
| 897 | + | // This is a temporary memory-only safeguard | |
| 898 | + | rlCacheKey := adminDomain + "_ratelimit_history" | |
| 899 | + | rateLimitData, _ := p.certManager.Cache.Get(ctx, rlCacheKey) | |
| 900 | + | if len(rateLimitData) > 0 { | |
| 901 | + | logger.Warn("Rate limit history found for admin domain, skipping certificate request", | |
| 902 | + | "domain", adminDomain, | |
| 903 | + | "data", string(rateLimitData), | |
| 904 | + | "solution", "Wait for rate limit to expire") | |
| 905 | + | return | |
| 906 | + | } | |
| 907 | + | ||
| 819 | 908 | // Extract hostname from admin domain, resolving it to see if it's publicly accessible | |
| 820 | 909 | host := adminDomain | |
| 821 | 910 | ips, err := net.LookupIP(host) | |
| @@ -907,7 +996,7 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 907 | 996 | "timeout", "1 minute") | |
| 908 | 997 | ||
| 909 | 998 | // Create a context with timeout for the initial certificate request | |
| 910 | - | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) | |
| 999 | + | ctx, cancel = context.WithTimeout(context.Background(), 1*time.Minute) | |
| 911 | 1000 | defer cancel() | |
| 912 | 1001 | ||
| 913 | 1002 | // Try to obtain the certificate with timeout | |
| @@ -951,7 +1040,10 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 951 | 1040 | re := regexp.MustCompile(`retry after ([^:]+)`) | |
| 952 | 1041 | matches := re.FindStringSubmatch(certErr.Error()) | |
| 953 | 1042 | if len(matches) > 1 { | |
| 954 | - | retryAfterStr = matches[1] | |
| 1043 | + | retryAfterStr = strings.TrimSpace(matches[1]) | |
| 1044 | + | if idx := strings.Index(retryAfterStr, ":"); idx > 0 { | |
| 1045 | + | retryAfterStr = retryAfterStr[:idx] | |
| 1046 | + | } | |
| 955 | 1047 | } | |
| 956 | 1048 | ||
| 957 | 1049 | logger.Error("Let's Encrypt rate limit reached", | |
| @@ -960,6 +1052,27 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 960 | 1052 | "retry_after", retryAfterStr, | |
| 961 | 1053 | "solution", "Wait until the rate limit expires, then restart the server") | |
| 962 | 1054 | ||
| 1055 | + | // Store rate limit history in cache to prevent further requests | |
| 1056 | + | rlCacheKey := adminDomain + "_ratelimit_history" | |
| 1057 | + | rlCtx, rlCancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 1058 | + | defer rlCancel() | |
| 1059 | + | ||
| 1060 | + | // Store the error message and timestamp in the cache | |
| 1061 | + | rlData := []byte(fmt.Sprintf("Rate limited at %s: %s", | |
| 1062 | + | time.Now().Format(time.RFC3339), certErr.Error())) | |
| 1063 | + | ||
| 1064 | + | if p.certManager != nil && p.certManager.Cache != nil { | |
| 1065 | + | // Store with a long expiration - Let's Encrypt rate limits are typically for a week | |
| 1066 | + | err := p.certManager.Cache.Put(rlCtx, rlCacheKey, rlData) | |
| 1067 | + | if err != nil { | |
| 1068 | + | logger.Debug("Failed to store rate limit history in cache", | |
| 1069 | + | "domain", adminDomain, "error", err) | |
| 1070 | + | } else { | |
| 1071 | + | logger.Info("Stored rate limit history in cache to prevent further requests", | |
| 1072 | + | "domain", adminDomain, "retry_after", retryAfterStr) | |
| 1073 | + | } | |
| 1074 | + | } | |
| 1075 | + | ||
| 963 | 1076 | // Don't retry on rate limits | |
| 964 | 1077 | logger.Info("Skipping certificate request retries due to rate limiting", | |
| 965 | 1078 | "domain", adminDomain) | |
| @@ -989,10 +1102,96 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 989 | 1102 | newCertPath := filepath.Join(certDir, newName) | |
| 990 | 1103 | newKeyPath := filepath.Join(certDir, newName+"+rsa") | |
| 991 | 1104 | ||
| 1105 | + | // Debug log certificate file paths | |
| 1106 | + | logger.Debug("Certificate file paths", | |
| 1107 | + | "domain", adminDomain, | |
| 1108 | + | "old_cert", oldCertPath, | |
| 1109 | + | "new_cert", newCertPath, | |
| 1110 | + | "old_key", oldKeyPath, | |
| 1111 | + | "new_key", newKeyPath) | |
| 1112 | + | ||
| 1113 | + | // Check if files exist first | |
| 1114 | + | oldCertExists := false | |
| 1115 | + | if _, err := os.Stat(oldCertPath); err == nil { | |
| 1116 | + | oldCertExists = true | |
| 1117 | + | logger.Debug("Old certificate file exists", "path", oldCertPath) | |
| 1118 | + | } else { | |
| 1119 | + | logger.Debug("Old certificate file not found", "path", oldCertPath, "error", err) | |
| 1120 | + | } | |
| 1121 | + | ||
| 1122 | + | // Read the certificate files directly to verify they contain valid data | |
| 1123 | + | if oldCertExists { | |
| 1124 | + | certBytes, err := os.ReadFile(oldCertPath) | |
| 1125 | + | if err != nil { | |
| 1126 | + | logger.Error("Failed to read certificate file", | |
| 1127 | + | "path", oldCertPath, | |
| 1128 | + | "error", err) | |
| 1129 | + | } else { | |
| 1130 | + | logger.Debug("Successfully read certificate file", | |
| 1131 | + | "path", oldCertPath, | |
| 1132 | + | "size", len(certBytes)) | |
| 1133 | + | ||
| 1134 | + | // Verify it's a valid PEM format | |
| 1135 | + | block, _ := pem.Decode(certBytes) | |
| 1136 | + | if block == nil { | |
| 1137 | + | logger.Error("Certificate file does not contain valid PEM data", | |
| 1138 | + | "path", oldCertPath) | |
| 1139 | + | } else { | |
| 1140 | + | logger.Debug("Certificate file contains valid PEM data", | |
| 1141 | + | "path", oldCertPath, | |
| 1142 | + | "type", block.Type) | |
| 1143 | + | } | |
| 1144 | + | } | |
| 1145 | + | ||
| 1146 | + | // Check permissions on cert and key files | |
| 1147 | + | certFileInfo, err := os.Stat(oldCertPath) | |
| 1148 | + | if err == nil { | |
| 1149 | + | mode := certFileInfo.Mode() | |
| 1150 | + | logger.Debug("Certificate file permissions", | |
| 1151 | + | "path", oldCertPath, | |
| 1152 | + | "mode", mode.String()) | |
| 1153 | + | ||
| 1154 | + | // Ensure cert file is readable by all | |
| 1155 | + | if mode.Perm()&0444 != 0444 { | |
| 1156 | + | logger.Warn("Certificate file has restrictive permissions, fixing", | |
| 1157 | + | "path", oldCertPath, | |
| 1158 | + | "current_mode", mode.String()) | |
| 1159 | + | if err := os.Chmod(oldCertPath, 0644); err != nil { | |
| 1160 | + | logger.Error("Failed to fix certificate file permissions", | |
| 1161 | + | "path", oldCertPath, | |
| 1162 | + | "error", err) | |
| 1163 | + | } | |
| 1164 | + | } | |
| 1165 | + | } | |
| 1166 | + | ||
| 1167 | + | // Check key file permissions | |
| 1168 | + | if _, err := os.Stat(oldKeyPath); err == nil { | |
| 1169 | + | keyFileInfo, err := os.Stat(oldKeyPath) | |
| 1170 | + | if err == nil { | |
| 1171 | + | mode := keyFileInfo.Mode() | |
| 1172 | + | logger.Debug("Key file permissions", | |
| 1173 | + | "path", oldKeyPath, | |
| 1174 | + | "mode", mode.String()) | |
| 1175 | + | ||
| 1176 | + | // Ensure key file is only readable by owner | |
| 1177 | + | if mode.Perm()&0400 != 0400 || mode.Perm()&0077 != 0 { | |
| 1178 | + | logger.Warn("Key file has incorrect permissions, fixing", | |
| 1179 | + | "path", oldKeyPath, | |
| 1180 | + | "current_mode", mode.String()) | |
| 1181 | + | if err := os.Chmod(oldKeyPath, 0600); err != nil { | |
| 1182 | + | logger.Error("Failed to fix key file permissions", | |
| 1183 | + | "path", oldKeyPath, | |
| 1184 | + | "error", err) | |
| 1185 | + | } | |
| 1186 | + | } | |
| 1187 | + | } | |
| 1188 | + | } | |
| 1189 | + | } | |
| 1190 | + | ||
| 992 | 1191 | // Only rename if the files exist and the new names are different | |
| 993 | 1192 | if oldCertPath != newCertPath { | |
| 994 | 1193 | // Check if old cert exists | |
| 995 | - | if _, err := os.Stat(oldCertPath); err == nil { | |
| 1194 | + | if oldCertExists { | |
| 996 | 1195 | // Rename cert | |
| 997 | 1196 | if err := os.Rename(oldCertPath, newCertPath); err != nil { | |
| 998 | 1197 | logger.Warn("Failed to rename certificate file", | |
| @@ -1023,6 +1222,8 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 1023 | 1222 | "from", oldKeyPath, | |
| 1024 | 1223 | "to", newKeyPath) | |
| 1025 | 1224 | } | |
| 1225 | + | } else { | |
| 1226 | + | logger.Warn("Old key file not found", "path", oldKeyPath, "error", err) | |
| 1026 | 1227 | } | |
| 1027 | 1228 | } | |
| 1028 | 1229 | } | |
| @@ -1030,6 +1231,75 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 1030 | 1231 | logger.Info("Successfully obtained Let's Encrypt certificate", | |
| 1031 | 1232 | "domain", adminDomain, | |
| 1032 | 1233 | "type", certType) | |
| 1234 | + | ||
| 1235 | + | // Verify the certificate files are accessible in the cache | |
| 1236 | + | cacheKey := "cert-" + adminDomain | |
| 1237 | + | cacheCtx, cacheCancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 1238 | + | defer cacheCancel() | |
| 1239 | + | ||
| 1240 | + | certData, cacheErr := p.certManager.Cache.Get(cacheCtx, cacheKey) | |
| 1241 | + | if cacheErr != nil { | |
| 1242 | + | logger.Warn("Certificate not found in cache after successful obtainment", | |
| 1243 | + | "domain", adminDomain, | |
| 1244 | + | "error", cacheErr, | |
| 1245 | + | "solution", "Check certificate permissions and file paths") | |
| 1246 | + | ||
| 1247 | + | // If certificate isn't in autocert cache but exists in filesystem, try to manually add it | |
| 1248 | + | newCertExists := false | |
| 1249 | + | if _, err := os.Stat(newCertPath); err == nil { | |
| 1250 | + | newCertExists = true | |
| 1251 | + | } | |
| 1252 | + | ||
| 1253 | + | if oldCertExists || newCertExists { | |
| 1254 | + | logger.Info("Attempting to manually add certificate to cache from filesystem", | |
| 1255 | + | "domain", adminDomain) | |
| 1256 | + | ||
| 1257 | + | sourceCertPath := newCertPath | |
| 1258 | + | sourceKeyPath := newKeyPath | |
| 1259 | + | ||
| 1260 | + | // If the file wasn't renamed, use the old paths | |
| 1261 | + | if _, err := os.Stat(sourceCertPath); err != nil { | |
| 1262 | + | sourceCertPath = oldCertPath | |
| 1263 | + | sourceKeyPath = oldKeyPath | |
| 1264 | + | } | |
| 1265 | + | ||
| 1266 | + | // Read the certificate and key files | |
| 1267 | + | certBytes, err := os.ReadFile(sourceCertPath) | |
| 1268 | + | if err != nil { | |
| 1269 | + | logger.Error("Failed to read certificate file for manual cache addition", | |
| 1270 | + | "path", sourceCertPath, | |
| 1271 | + | "error", err) | |
| 1272 | + | } else { | |
| 1273 | + | keyBytes, err := os.ReadFile(sourceKeyPath) | |
| 1274 | + | if err != nil { | |
| 1275 | + | logger.Error("Failed to read key file for manual cache addition", | |
| 1276 | + | "path", sourceKeyPath, | |
| 1277 | + | "error", err) | |
| 1278 | + | } else { | |
| 1279 | + | // Add to autocert cache | |
| 1280 | + | if err := p.certManager.Cache.Put(cacheCtx, cacheKey, certBytes); err != nil { | |
| 1281 | + | logger.Error("Failed to manually add certificate to cache", | |
| 1282 | + | "domain", adminDomain, | |
| 1283 | + | "error", err) | |
| 1284 | + | } else { | |
| 1285 | + | keyKey := "key-" + adminDomain | |
| 1286 | + | if err := p.certManager.Cache.Put(cacheCtx, keyKey, keyBytes); err != nil { | |
| 1287 | + | logger.Error("Failed to manually add key to cache", | |
| 1288 | + | "domain", adminDomain, | |
| 1289 | + | "error", err) | |
| 1290 | + | } else { | |
| 1291 | + | logger.Info("Successfully manually added certificate to cache", | |
| 1292 | + | "domain", adminDomain) | |
| 1293 | + | } | |
| 1294 | + | } | |
| 1295 | + | } | |
| 1296 | + | } | |
| 1297 | + | } | |
| 1298 | + | } else { | |
| 1299 | + | logger.Debug("Certificate successfully stored in cache", | |
| 1300 | + | "domain", adminDomain, | |
| 1301 | + | "cert_size", len(certData)) | |
| 1302 | + | } | |
| 1033 | 1303 | } else { | |
| 1034 | 1304 | logger.Error("Unexpected error: received nil certificate but no error", | |
| 1035 | 1305 | "domain", adminDomain, | |
| @@ -1056,6 +1326,33 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 1056 | 1326 | return | |
| 1057 | 1327 | } | |
| 1058 | 1328 | ||
| 1329 | + | // First, check if certificate is already in the autocert cache | |
| 1330 | + | // This is the critical step to avoid unnecessary Let's Encrypt requests | |
| 1331 | + | cacheKey := "cert-" + domain | |
| 1332 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 1333 | + | defer cancel() | |
| 1334 | + | ||
| 1335 | + | if p.certManager != nil && p.certManager.Cache != nil { | |
| 1336 | + | certData, err := p.certManager.Cache.Get(ctx, cacheKey) | |
| 1337 | + | if err == nil && len(certData) > 0 { | |
| 1338 | + | // Check if the cached certificate is valid | |
| 1339 | + | isValid, expiresInDays, certType, _ := isCertificateValid(certData, domain) | |
| 1340 | + | if isValid { | |
| 1341 | + | logger.Info("Using valid certificate from cache", | |
| 1342 | + | "domain", domain, | |
| 1343 | + | "type", certType, | |
| 1344 | + | "expires_in_days", expiresInDays) | |
| 1345 | + | return | |
| 1346 | + | } | |
| 1347 | + | ||
| 1348 | + | // If certificate is invalid or expiring soon, log but continue | |
| 1349 | + | logger.Info("Cached certificate is invalid or expiring soon", | |
| 1350 | + | "domain", domain, | |
| 1351 | + | "expires_in_days", expiresInDays, | |
| 1352 | + | "valid", isValid) | |
| 1353 | + | } | |
| 1354 | + | } | |
| 1355 | + | ||
| 1059 | 1356 | // Extract hostname from domain, resolving it to see if it's publicly accessible | |
| 1060 | 1357 | ips, err := net.LookupIP(domain) | |
| 1061 | 1358 | if err != nil { | |
| @@ -1100,6 +1397,46 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 1100 | 1397 | validInCache, _ := p.checkCertificateInCache(domain) | |
| 1101 | 1398 | validInFS, _ := p.checkCertificateInFilesystem(domain) | |
| 1102 | 1399 | if !validInCache && validInFS { | |
| 1400 | + | // Try to load the certificate into the cache if it's not there already | |
| 1401 | + | // This helps prevent future Let's Encrypt requests | |
| 1402 | + | hasOnDisk, certType := p.checkCertificateInFilesystem(domain) | |
| 1403 | + | if hasOnDisk { | |
| 1404 | + | logger.Info("Loading filesystem certificate into cache", | |
| 1405 | + | "domain", domain, | |
| 1406 | + | "type", certType) | |
| 1407 | + | ||
| 1408 | + | // Get the certificate directory path for finding files | |
| 1409 | + | certDir := p.config.CertDir | |
| 1410 | + | if certDir == "" { | |
| 1411 | + | certDir = p.app.GetConfig().General.StorageDir + "/certs" | |
| 1412 | + | } | |
| 1413 | + | ||
| 1414 | + | // Look for production certificate first, then staging | |
| 1415 | + | certFile := getCertificateFileName(domain, "production") | |
| 1416 | + | certPath := filepath.Join(certDir, certFile) | |
| 1417 | + | keyPath := filepath.Join(certDir, certFile+"+rsa") | |
| 1418 | + | ||
| 1419 | + | // If production cert doesn't exist, try staging | |
| 1420 | + | if _, err := os.Stat(certPath); os.IsNotExist(err) { | |
| 1421 | + | certFile = getCertificateFileName(domain, "staging") | |
| 1422 | + | certPath = filepath.Join(certDir, certFile) | |
| 1423 | + | keyPath = filepath.Join(certDir, certFile+"+rsa") | |
| 1424 | + | } | |
| 1425 | + | ||
| 1426 | + | // If no specific cert found, try the domain name directly | |
| 1427 | + | if _, err := os.Stat(certPath); os.IsNotExist(err) { | |
| 1428 | + | certPath = filepath.Join(certDir, domain) | |
| 1429 | + | keyPath = filepath.Join(certDir, domain+"+rsa") | |
| 1430 | + | } | |
| 1431 | + | ||
| 1432 | + | // Add to cache if files exist | |
| 1433 | + | if _, err := os.Stat(certPath); err == nil { | |
| 1434 | + | if _, err := os.Stat(keyPath); err == nil { | |
| 1435 | + | p.addCertificateToCache(domain, certPath, keyPath) | |
| 1436 | + | } | |
| 1437 | + | } | |
| 1438 | + | } | |
| 1439 | + | ||
| 1103 | 1440 | if testCertificateWithTLSConnection(domain, p.config.Port) { | |
| 1104 | 1441 | logger.Debug("Certificate verification succeeded via TLS connection", | |
| 1105 | 1442 | "domain", domain, | |
| @@ -1116,6 +1453,18 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 1116 | 1453 | } | |
| 1117 | 1454 | } | |
| 1118 | 1455 | ||
| 1456 | + | // Additional rate limit safety check | |
| 1457 | + | // Check if we've recently hit a rate limit for this domain | |
| 1458 | + | // This is a temporary memory-only safeguard | |
| 1459 | + | cacheKey = domain + "_ratelimit_history" | |
| 1460 | + | rateLimitData, _ := p.certManager.Cache.Get(ctx, cacheKey) | |
| 1461 | + | if len(rateLimitData) > 0 { | |
| 1462 | + | logger.Warn("Rate limit history found for this domain, skipping certificate request", | |
| 1463 | + | "domain", domain, | |
| 1464 | + | "solution", "Wait for rate limit to expire") | |
| 1465 | + | return | |
| 1466 | + | } | |
| 1467 | + | ||
| 1119 | 1468 | // Get the certificate directory path for naming the output files | |
| 1120 | 1469 | certDir := p.config.CertDir | |
| 1121 | 1470 | if certDir == "" { | |
| @@ -1146,7 +1495,7 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 1146 | 1495 | "timeout", "1 minute") | |
| 1147 | 1496 | ||
| 1148 | 1497 | // Create a context with timeout for the initial certificate request | |
| 1149 | - | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) | |
| 1498 | + | ctx, cancel = context.WithTimeout(context.Background(), 1*time.Minute) | |
| 1150 | 1499 | defer cancel() | |
| 1151 | 1500 | ||
| 1152 | 1501 | // Try to obtain the certificate with timeout | |
| @@ -1190,7 +1539,10 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 1190 | 1539 | re := regexp.MustCompile(`retry after ([^:]+)`) | |
| 1191 | 1540 | matches := re.FindStringSubmatch(certErr.Error()) | |
| 1192 | 1541 | if len(matches) > 1 { | |
| 1193 | - | retryAfterStr = matches[1] | |
| 1542 | + | retryAfterStr = strings.TrimSpace(matches[1]) | |
| 1543 | + | if idx := strings.Index(retryAfterStr, ":"); idx > 0 { | |
| 1544 | + | retryAfterStr = retryAfterStr[:idx] | |
| 1545 | + | } | |
| 1194 | 1546 | } | |
| 1195 | 1547 | ||
| 1196 | 1548 | logger.Error("Let's Encrypt rate limit reached", | |
| @@ -1199,6 +1551,27 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 1199 | 1551 | "retry_after", retryAfterStr, | |
| 1200 | 1552 | "solution", "Wait until the rate limit expires, then restart the server") | |
| 1201 | 1553 | ||
| 1554 | + | // Store rate limit history in cache to prevent further requests | |
| 1555 | + | rlCacheKey := domain + "_ratelimit_history" | |
| 1556 | + | rlCtx, rlCancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 1557 | + | defer rlCancel() | |
| 1558 | + | ||
| 1559 | + | // Store the error message and timestamp in the cache | |
| 1560 | + | rlData := []byte(fmt.Sprintf("Rate limited at %s: %s", | |
| 1561 | + | time.Now().Format(time.RFC3339), certErr.Error())) | |
| 1562 | + | ||
| 1563 | + | if p.certManager != nil && p.certManager.Cache != nil { | |
| 1564 | + | // Store with a long expiration - Let's Encrypt rate limits are typically for a week | |
| 1565 | + | err := p.certManager.Cache.Put(rlCtx, rlCacheKey, rlData) | |
| 1566 | + | if err != nil { | |
| 1567 | + | logger.Debug("Failed to store rate limit history in cache", | |
| 1568 | + | "domain", domain, "error", err) | |
| 1569 | + | } else { | |
| 1570 | + | logger.Info("Stored rate limit history in cache to prevent further requests", | |
| 1571 | + | "domain", domain, "retry_after", retryAfterStr) | |
| 1572 | + | } | |
| 1573 | + | } | |
| 1574 | + | ||
| 1202 | 1575 | // Don't retry on rate limits | |
| 1203 | 1576 | logger.Info("Skipping certificate request retries due to rate limiting", | |
| 1204 | 1577 | "domain", domain) | |
| @@ -1262,6 +1635,8 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 1262 | 1635 | "from", oldKeyPath, | |
| 1263 | 1636 | "to", newKeyPath) | |
| 1264 | 1637 | } | |
| 1638 | + | } else { | |
| 1639 | + | logger.Warn("Old key file not found", "path", oldKeyPath, "error", err) | |
| 1265 | 1640 | } | |
| 1266 | 1641 | } | |
| 1267 | 1642 | } | |
| @@ -1393,6 +1768,8 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa | |||
| 1393 | 1768 | "from", oldKeyPath, | |
| 1394 | 1769 | "to", newKeyPath) | |
| 1395 | 1770 | } | |
| 1771 | + | } else { | |
| 1772 | + | logger.Warn("Old key file not found", "path", oldKeyPath, "error", err) | |
| 1396 | 1773 | } | |
| 1397 | 1774 | } | |
| 1398 | 1775 | } | |
| @@ -1585,3 +1962,319 @@ func generateFallbackCertificates(domains []string) (*tls.Certificate, error) { | |||
| 1585 | 1962 | ||
| 1586 | 1963 | return &cert, nil | |
| 1587 | 1964 | } | |
| 1965 | + | ||
| 1966 | + | // preloadCertificates scans the certificate directory and loads all valid certificates | |
| 1967 | + | // into the autocert cache to prevent unnecessary requests to Let's Encrypt | |
| 1968 | + | func (p *Proxy) preloadCertificates() { | |
| 1969 | + | if p.certManager == nil || p.certManager.Cache == nil { | |
| 1970 | + | logger.Warn("Cannot preload certificates: certificate manager or cache not initialized") | |
| 1971 | + | return | |
| 1972 | + | } | |
| 1973 | + | ||
| 1974 | + | // Get the certificate directory path | |
| 1975 | + | certDir := p.config.CertDir | |
| 1976 | + | if certDir == "" { | |
| 1977 | + | certDir = p.app.GetConfig().General.StorageDir + "/certs" | |
| 1978 | + | } | |
| 1979 | + | ||
| 1980 | + | logger.Info("Preloading certificates from directory", "dir", certDir) | |
| 1981 | + | ||
| 1982 | + | // Ensure directory exists | |
| 1983 | + | if _, err := os.Stat(certDir); os.IsNotExist(err) { | |
| 1984 | + | logger.Warn("Certificate directory does not exist, creating it", "dir", certDir) | |
| 1985 | + | if err := os.MkdirAll(certDir, 0755); err != nil { | |
| 1986 | + | logger.Error("Failed to create certificate directory", "dir", certDir, "error", err) | |
| 1987 | + | return | |
| 1988 | + | } | |
| 1989 | + | } | |
| 1990 | + | ||
| 1991 | + | // List all files in the certificate directory | |
| 1992 | + | files, err := os.ReadDir(certDir) | |
| 1993 | + | if err != nil { | |
| 1994 | + | logger.Error("Failed to read certificate directory", "dir", certDir, "error", err) | |
| 1995 | + | return | |
| 1996 | + | } | |
| 1997 | + | ||
| 1998 | + | // Log all files in the directory to help troubleshoot | |
| 1999 | + | var fileNames []string | |
| 2000 | + | for _, file := range files { | |
| 2001 | + | fileNames = append(fileNames, file.Name()) | |
| 2002 | + | ||
| 2003 | + | // Check file permissions and fix if too restrictive | |
| 2004 | + | filePath := filepath.Join(certDir, file.Name()) | |
| 2005 | + | info, err := os.Stat(filePath) | |
| 2006 | + | if err != nil { | |
| 2007 | + | logger.Warn("Could not stat file", "path", filePath, "error", err) | |
| 2008 | + | continue | |
| 2009 | + | } | |
| 2010 | + | ||
| 2011 | + | mode := info.Mode() | |
| 2012 | + | if strings.HasSuffix(file.Name(), "+rsa") { | |
| 2013 | + | // Key files should be 0600 (owner read/write only) | |
| 2014 | + | if mode.Perm() != 0600 { | |
| 2015 | + | logger.Info("Fixing key file permissions", "file", file.Name(), "current_mode", mode.String(), "target_mode", "0600") | |
| 2016 | + | if err := os.Chmod(filePath, 0600); err != nil { | |
| 2017 | + | logger.Warn("Failed to fix key file permissions", "file", file.Name(), "error", err) | |
| 2018 | + | } | |
| 2019 | + | } | |
| 2020 | + | } else { | |
| 2021 | + | // Certificate files should be 0644 (readable by all) | |
| 2022 | + | if mode.Perm() != 0644 { | |
| 2023 | + | logger.Info("Fixing certificate file permissions", "file", file.Name(), "current_mode", mode.String(), "target_mode", "0644") | |
| 2024 | + | if err := os.Chmod(filePath, 0644); err != nil { | |
| 2025 | + | logger.Warn("Failed to fix certificate file permissions", "file", file.Name(), "error", err) | |
| 2026 | + | } | |
| 2027 | + | } | |
| 2028 | + | } | |
| 2029 | + | } | |
| 2030 | + | ||
| 2031 | + | logger.Debug("Files in certificate directory", "dir", certDir, "files", fileNames) | |
| 2032 | + | ||
| 2033 | + | // Track the number of certificates loaded | |
| 2034 | + | loadedCount := 0 | |
| 2035 | + | ||
| 2036 | + | // Create a context for cache operations | |
| 2037 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 2038 | + | defer cancel() | |
| 2039 | + | ||
| 2040 | + | // Find and process certificate files (excluding key files with +rsa suffix) | |
| 2041 | + | // Group them by domain for easier processing | |
| 2042 | + | domainCerts := make(map[string][]string) | |
| 2043 | + | ||
| 2044 | + | for _, file := range files { | |
| 2045 | + | name := file.Name() | |
| 2046 | + | ||
| 2047 | + | // Skip account keys and +rsa key files as we'll handle these separately | |
| 2048 | + | if strings.HasSuffix(name, "+rsa") || strings.Contains(name, "acme_account") { | |
| 2049 | + | continue | |
| 2050 | + | } | |
| 2051 | + | ||
| 2052 | + | // Get associated key file name (file+rsa) | |
| 2053 | + | keyName := name + "+rsa" | |
| 2054 | + | keyPath := filepath.Join(certDir, keyName) | |
| 2055 | + | ||
| 2056 | + | // Skip certificates without key files | |
| 2057 | + | if _, err := os.Stat(keyPath); os.IsNotExist(err) { | |
| 2058 | + | logger.Debug("Skipping certificate without key file", "cert", name, "key", keyName) | |
| 2059 | + | continue | |
| 2060 | + | } | |
| 2061 | + | ||
| 2062 | + | // Try to determine the domain from the filename | |
| 2063 | + | domain := name | |
| 2064 | + | ||
| 2065 | + | // Handle special patterns like domain_letsencrypt-production | |
| 2066 | + | for _, pattern := range []string{"_letsencrypt-production", "_letsencrypt-staging", "_self-signed"} { | |
| 2067 | + | if strings.HasSuffix(domain, pattern) { | |
| 2068 | + | domain = strings.TrimSuffix(domain, pattern) | |
| 2069 | + | break | |
| 2070 | + | } | |
| 2071 | + | } | |
| 2072 | + | ||
| 2073 | + | // Add to domain certificates map | |
| 2074 | + | domainCerts[domain] = append(domainCerts[domain], name) | |
| 2075 | + | } | |
| 2076 | + | ||
| 2077 | + | // Process certificates for each domain | |
| 2078 | + | for domain, certFiles := range domainCerts { | |
| 2079 | + | logger.Debug("Found certificate files for domain", "domain", domain, "files", certFiles) | |
| 2080 | + | ||
| 2081 | + | // Prioritize production certificates if multiple exist | |
| 2082 | + | var bestCert string | |
| 2083 | + | bestCertType := "" | |
| 2084 | + | ||
| 2085 | + | for _, certFile := range certFiles { | |
| 2086 | + | certPath := filepath.Join(certDir, certFile) | |
| 2087 | + | ||
| 2088 | + | // Read certificate to check its validity | |
| 2089 | + | certData, err := os.ReadFile(certPath) | |
| 2090 | + | if err != nil { | |
| 2091 | + | logger.Warn("Failed to read certificate file", "path", certPath, "error", err) | |
| 2092 | + | continue | |
| 2093 | + | } | |
| 2094 | + | ||
| 2095 | + | // Debug certificate content | |
| 2096 | + | if len(certData) < 100 { | |
| 2097 | + | logger.Warn("Certificate file too small, may be corrupted", | |
| 2098 | + | "path", certPath, "size", len(certData)) | |
| 2099 | + | continue | |
| 2100 | + | } | |
| 2101 | + | ||
| 2102 | + | // Validate the certificate | |
| 2103 | + | valid, expiresInDays, certType, _ := isCertificateValid(certData, domain) | |
| 2104 | + | if !valid { | |
| 2105 | + | logger.Debug("Invalid certificate found", "domain", domain, "file", certFile) | |
| 2106 | + | continue | |
| 2107 | + | } | |
| 2108 | + | ||
| 2109 | + | logger.Info("Found valid certificate", "domain", domain, "type", certType, | |
| 2110 | + | "expires_in_days", expiresInDays, "file", certFile) | |
| 2111 | + | ||
| 2112 | + | // Prioritize production certificates over staging ones | |
| 2113 | + | if bestCertType == "" || | |
| 2114 | + | (bestCertType != "production" && certType == "production") { | |
| 2115 | + | bestCert = certFile | |
| 2116 | + | bestCertType = certType | |
| 2117 | + | } | |
| 2118 | + | } | |
| 2119 | + | ||
| 2120 | + | // If we found a valid certificate for this domain, add it to the cache | |
| 2121 | + | if bestCert != "" { | |
| 2122 | + | certPath := filepath.Join(certDir, bestCert) | |
| 2123 | + | keyPath := filepath.Join(certDir, bestCert+"+rsa") | |
| 2124 | + | ||
| 2125 | + | // Read certificate and key data | |
| 2126 | + | certData, err := os.ReadFile(certPath) | |
| 2127 | + | if err != nil { | |
| 2128 | + | logger.Warn("Failed to read certificate file", "path", certPath, "error", err) | |
| 2129 | + | continue | |
| 2130 | + | } | |
| 2131 | + | ||
| 2132 | + | keyData, err := os.ReadFile(keyPath) | |
| 2133 | + | if err != nil { | |
| 2134 | + | logger.Warn("Failed to read key file", "path", keyPath, "error", err) | |
| 2135 | + | continue | |
| 2136 | + | } | |
| 2137 | + | ||
| 2138 | + | // Add certificate to autocert cache | |
| 2139 | + | cacheKey := "cert-" + domain | |
| 2140 | + | err = p.certManager.Cache.Put(ctx, cacheKey, certData) | |
| 2141 | + | if err != nil { | |
| 2142 | + | logger.Error("Failed to add certificate to cache", "domain", domain, "error", err) | |
| 2143 | + | continue | |
| 2144 | + | } | |
| 2145 | + | ||
| 2146 | + | // Add key to cache | |
| 2147 | + | keyKey := "key-" + domain | |
| 2148 | + | err = p.certManager.Cache.Put(ctx, keyKey, keyData) | |
| 2149 | + | if err != nil { | |
| 2150 | + | logger.Error("Failed to add key to cache", "domain", domain, "error", err) | |
| 2151 | + | continue | |
| 2152 | + | } | |
| 2153 | + | ||
| 2154 | + | logger.Info("Preloaded certificate into cache", "domain", domain, "type", bestCertType) | |
| 2155 | + | loadedCount++ | |
| 2156 | + | } else { | |
| 2157 | + | logger.Warn("No valid certificate found for domain after checking all files", | |
| 2158 | + | "domain", domain, | |
| 2159 | + | "files_checked", certFiles) | |
| 2160 | + | } | |
| 2161 | + | } | |
| 2162 | + | ||
| 2163 | + | logger.Info("Certificate preloading complete", "loaded_certificates", loadedCount) | |
| 2164 | + | } | |
| 2165 | + | ||
| 2166 | + | // manuallyLoadCertificate is a fallback method to load certificates directly | |
| 2167 | + | // when autocert cache is failing. This directly builds the tls.Certificate from files. | |
| 2168 | + | func (p *Proxy) manuallyLoadCertificate(domain string) (*tls.Certificate, error) { | |
| 2169 | + | // Get certificate directory | |
| 2170 | + | certDir := p.config.CertDir | |
| 2171 | + | if certDir == "" { | |
| 2172 | + | certDir = p.app.GetConfig().General.StorageDir + "/certs" | |
| 2173 | + | } | |
| 2174 | + | ||
| 2175 | + | // Check preferred file naming patterns | |
| 2176 | + | filePatterns := []string{ | |
| 2177 | + | // Try production certificate first | |
| 2178 | + | fmt.Sprintf(LetsEncryptProdPattern, domain), | |
| 2179 | + | // Then staging certificate | |
| 2180 | + | fmt.Sprintf(LetsEncryptStagingPattern, domain), | |
| 2181 | + | // Then self-signed | |
| 2182 | + | fmt.Sprintf(SelfSignedCertPattern, domain), | |
| 2183 | + | // Finally plain domain name | |
| 2184 | + | domain, | |
| 2185 | + | } | |
| 2186 | + | ||
| 2187 | + | for _, pattern := range filePatterns { | |
| 2188 | + | certPath := filepath.Join(certDir, pattern) | |
| 2189 | + | keyPath := filepath.Join(certDir, pattern+"+rsa") | |
| 2190 | + | ||
| 2191 | + | // Check if both files exist | |
| 2192 | + | if _, err := os.Stat(certPath); os.IsNotExist(err) { | |
| 2193 | + | continue | |
| 2194 | + | } | |
| 2195 | + | if _, err := os.Stat(keyPath); os.IsNotExist(err) { | |
| 2196 | + | continue | |
| 2197 | + | } | |
| 2198 | + | ||
| 2199 | + | // Try to load the certificate pair | |
| 2200 | + | cert, err := tls.LoadX509KeyPair(certPath, keyPath) | |
| 2201 | + | if err != nil { | |
| 2202 | + | logger.Warn("Failed to load certificate key pair", | |
| 2203 | + | "domain", domain, | |
| 2204 | + | "certPath", certPath, | |
| 2205 | + | "keyPath", keyPath, | |
| 2206 | + | "error", err) | |
| 2207 | + | ||
| 2208 | + | // Additional diagnostics | |
| 2209 | + | if data, readErr := os.ReadFile(certPath); readErr == nil { | |
| 2210 | + | logger.Debug("Certificate file content preview", | |
| 2211 | + | "path", certPath, | |
| 2212 | + | "size", len(data), | |
| 2213 | + | "preview", string(data[:min(100, len(data))])) | |
| 2214 | + | ||
| 2215 | + | // Try to parse the certificate - this may help diagnose format issues | |
| 2216 | + | block, _ := pem.Decode(data) | |
| 2217 | + | if block == nil { | |
| 2218 | + | logger.Error("Certificate file does not contain valid PEM data", | |
| 2219 | + | "path", certPath) | |
| 2220 | + | } else { | |
| 2221 | + | logger.Debug("Certificate PEM block info", | |
| 2222 | + | "type", block.Type, | |
| 2223 | + | "headers", block.Headers) | |
| 2224 | + | } | |
| 2225 | + | } | |
| 2226 | + | ||
| 2227 | + | continue | |
| 2228 | + | } | |
| 2229 | + | ||
| 2230 | + | // Success! | |
| 2231 | + | logger.Info("Manually loaded certificate from filesystem", | |
| 2232 | + | "domain", domain, | |
| 2233 | + | "certPath", certPath) | |
| 2234 | + | return &cert, nil | |
| 2235 | + | } | |
| 2236 | + | ||
| 2237 | + | // Also try with glob pattern | |
| 2238 | + | filePaths, err := filepath.Glob(filepath.Join(certDir, domain+"*")) | |
| 2239 | + | if err != nil { | |
| 2240 | + | return nil, fmt.Errorf("glob error: %w", err) | |
| 2241 | + | } | |
| 2242 | + | ||
| 2243 | + | for _, certPath := range filePaths { | |
| 2244 | + | // Skip if it's an RSA key | |
| 2245 | + | if strings.HasSuffix(certPath, "+rsa") { | |
| 2246 | + | continue | |
| 2247 | + | } | |
| 2248 | + | ||
| 2249 | + | // Check if key exists | |
| 2250 | + | keyPath := certPath + "+rsa" | |
| 2251 | + | if _, err := os.Stat(keyPath); os.IsNotExist(err) { | |
| 2252 | + | continue | |
| 2253 | + | } | |
| 2254 | + | ||
| 2255 | + | // Try to load | |
| 2256 | + | cert, err := tls.LoadX509KeyPair(certPath, keyPath) | |
| 2257 | + | if err != nil { | |
| 2258 | + | logger.Debug("Failed to load certificate from glob match", | |
| 2259 | + | "domain", domain, | |
| 2260 | + | "certPath", certPath, | |
| 2261 | + | "error", err) | |
| 2262 | + | continue | |
| 2263 | + | } | |
| 2264 | + | ||
| 2265 | + | logger.Info("Manually loaded certificate from glob search", | |
| 2266 | + | "domain", domain, | |
| 2267 | + | "certPath", certPath) | |
| 2268 | + | return &cert, nil | |
| 2269 | + | } | |
| 2270 | + | ||
| 2271 | + | return nil, fmt.Errorf("no valid certificate found for domain %s", domain) | |
| 2272 | + | } | |
| 2273 | + | ||
| 2274 | + | // min returns the smaller of x or y. | |
| 2275 | + | func min(x, y int) int { | |
| 2276 | + | if x < y { | |
| 2277 | + | return x | |
| 2278 | + | } | |
| 2279 | + | return y | |
| 2280 | + | } | |
| @@ -109,36 +109,40 @@ func (p *Proxy) setupCertManager() { | |||||
| 109 | // First check for upstream TLS-terminating proxies if detection is enabled | 109 | // First check for upstream TLS-terminating proxies if detection is enabled | ||
| 110 | isProxied := p.detectUpstreamProxy() | 110 | isProxied := p.detectUpstreamProxy() | ||
| 111 | 111 | ||||
| 112 | + | // Get admin domain and root domain | |||
| 113 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 114 | + | rootDomain := p.app.GetConfig().Http.Domain | |||
| 115 | + | ||||
| 116 | + | // Generate a fallback certificate for admin domain regardless of proxy status | |||
| 117 | + | var fallbackDomains []string | |||
| 118 | + | fallbackDomains = append(fallbackDomains, adminDomain) | |||
| 119 | + | ||||
| 120 | + | // Include root domain in fallback certificate if it's different from admin domain | |||
| 121 | + | if rootDomain != "" && rootDomain != adminDomain { | |||
| 122 | + | fallbackDomains = append(fallbackDomains, rootDomain) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // Generate the fallback certificate | |||
| 126 | + | fallbackCert, err := generateFallbackCertificates(fallbackDomains) | |||
| 127 | + | if err != nil { | |||
| 128 | + | logger.Warn("Failed to generate fallback certificate", "error", err) | |||
| 129 | + | } else { | |||
| 130 | + | logger.Info("Generated fallback self-signed certificate for admin domain", | |||
| 131 | + | "domain", adminDomain, | |||
| 132 | + | "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05")) | |||
| 133 | + | // Store the fallback certificate | |||
| 134 | + | p.fallbackCert = fallbackCert | |||
| 135 | + | } | |||
| 136 | + | ||||
| 112 | // Skip certificate acquisition if we're behind a TLS-terminating proxy | 137 | // Skip certificate acquisition if we're behind a TLS-terminating proxy | ||
| 113 | // and the skip certificates option is enabled | 138 | // and the skip certificates option is enabled | ||
| 114 | if isProxied && p.config.SkipCertificates { | 139 | if isProxied && p.config.SkipCertificates { | ||
| 115 | logger.Info("Upstream TLS-terminating proxy detected, skipping Let's Encrypt certificate acquisition", | 140 | logger.Info("Upstream TLS-terminating proxy detected, skipping Let's Encrypt certificate acquisition", | ||
| 116 | "upstream_proxy", "detected", | 141 | "upstream_proxy", "detected", | ||
| 117 | "certificate_acquisition", "disabled") | 142 | "certificate_acquisition", "disabled") | ||
| 118 | - | // We still setup a fallback cert for the admin domain | |||
| 119 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 120 | - | rootDomain := p.app.GetConfig().Http.Domain | |||
| 121 | - | ||||
| 122 | - | var fallbackDomains []string | |||
| 123 | - | fallbackDomains = append(fallbackDomains, adminDomain) | |||
| 124 | - | ||||
| 125 | - | // Include root domain in fallback certificate if it's different from admin domain | |||
| 126 | - | if rootDomain != "" && rootDomain != adminDomain { | |||
| 127 | - | fallbackDomains = append(fallbackDomains, rootDomain) | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | fallbackCert, err := generateFallbackCertificates(fallbackDomains) | |||
| 131 | - | if err != nil { | |||
| 132 | - | logger.Warn("Failed to generate fallback certificate", "error", err) | |||
| 133 | - | } else { | |||
| 134 | - | logger.Info("Generated fallback self-signed certificate for admin domain behind proxy", | |||
| 135 | - | "domain", adminDomain, | |||
| 136 | - | "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05")) | |||
| 137 | - | // Store the fallback certificate | |||
| 138 | - | p.fallbackCert = fallbackCert | |||
| 139 | - | } | |||
| 140 | 143 | ||||
| 141 | - | return | 144 | + | // We will still initialize the certificate manager but will use it in a more limited way |
| 145 | + | logger.Info("Initializing minimal Let's Encrypt certificate manager for fallback purposes") | |||
| 142 | } | 146 | } | ||
| 143 | 147 | ||||
| 144 | // Create a cache directory if it doesn't exist | 148 | // Create a cache directory if it doesn't exist | ||
| @@ -181,10 +185,6 @@ func (p *Proxy) setupCertManager() { | |||||
| 181 | "url", acme.LetsEncryptURL) | 185 | "url", acme.LetsEncryptURL) | ||
| 182 | } | 186 | } | ||
| 183 | 187 | ||||
| 184 | - | // Set HostPolicy to allow the admin domain | |||
| 185 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 186 | - | rootDomain := p.app.GetConfig().Http.Domain | |||
| 187 | - | ||||
| 188 | // Use a stricter hostpolicy that only allows domains in our routes | 188 | // Use a stricter hostpolicy that only allows domains in our routes | ||
| 189 | certManager.HostPolicy = func(_ context.Context, host string) error { | 189 | certManager.HostPolicy = func(_ context.Context, host string) error { | ||
| 190 | // Always allow the admin domain and root domain | 190 | // Always allow the admin domain and root domain | ||
| @@ -210,31 +210,14 @@ func (p *Proxy) setupCertManager() { | |||||
| 210 | return fmt.Errorf("host %q not configured in routes", host) | 210 | return fmt.Errorf("host %q not configured in routes", host) | ||
| 211 | } | 211 | } | ||
| 212 | 212 | ||||
| 213 | - | // Generate a fallback self-signed certificate for the admin domain | |||
| 214 | - | var fallbackDomains []string | |||
| 215 | - | fallbackDomains = append(fallbackDomains, adminDomain) | |||
| 216 | - | ||||
| 217 | - | // Include root domain in fallback certificate if it's different from admin domain | |||
| 218 | - | if rootDomain != "" && rootDomain != adminDomain { | |||
| 219 | - | fallbackDomains = append(fallbackDomains, rootDomain) | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | fallbackCert, err := generateFallbackCertificates(fallbackDomains) | |||
| 223 | - | if err != nil { | |||
| 224 | - | logger.Warn("Failed to generate fallback certificate", "error", err) | |||
| 225 | - | } else { | |||
| 226 | - | logger.Info("Generated fallback self-signed certificate for admin domain", | |||
| 227 | - | "domain", adminDomain, | |||
| 228 | - | "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05")) | |||
| 229 | - | // Store the fallback certificate | |||
| 230 | - | p.fallbackCert = fallbackCert | |||
| 231 | - | } | |||
| 232 | - | ||||
| 233 | p.certManager = certManager | 213 | p.certManager = certManager | ||
| 234 | logger.Debug("Certificate manager setup completed", | 214 | logger.Debug("Certificate manager setup completed", | ||
| 235 | "directory", dir, | 215 | "directory", dir, | ||
| 236 | "mode", p.config.LetsEncryptMode) | 216 | "mode", p.config.LetsEncryptMode) | ||
| 237 | 217 | ||||
| 218 | + | // Preload certificates from disk into cache to avoid unnecessary Let's Encrypt requests | |||
| 219 | + | p.preloadCertificates() | |||
| 220 | + | ||||
| 238 | // Only request admin certificate during server startup, not during specific domain processing | 221 | // Only request admin certificate during server startup, not during specific domain processing | ||
| 239 | if !p.processingSpecificDomain { | 222 | if !p.processingSpecificDomain { | ||
| 240 | // Request the certificate for the admin domain | 223 | // Request the certificate for the admin domain | ||
| @@ -343,13 +326,52 @@ func getCertificateType(cert *x509.Certificate) string { | |||||
| 343 | // Returns whether it's valid and how many days until expiration, and the certificate type | 326 | // Returns whether it's valid and how many days until expiration, and the certificate type | ||
| 344 | func isCertificateValid(certData []byte, domain string) (bool, float64, string, *x509.Certificate) { | 327 | func isCertificateValid(certData []byte, domain string) (bool, float64, string, *x509.Certificate) { | ||
| 345 | // Parse the certificate to check its validity | 328 | // Parse the certificate to check its validity | ||
| 346 | - | block, _ := pem.Decode(certData) | 329 | + | block, rest := pem.Decode(certData) |
| 347 | if block == nil || block.Type != "CERTIFICATE" { | 330 | if block == nil || block.Type != "CERTIFICATE" { | ||
| 348 | - | logger.Warn("Invalid certificate data", | 331 | + | logger.Warn("Invalid certificate data - not a valid PEM CERTIFICATE block", |
| 349 | - | "domain", domain) | 332 | + | "domain", domain, |
| 333 | + | "dataLength", len(certData)) | |||
| 334 | + | ||||
| 335 | + | // Debug: output first few bytes to help diagnose format issues | |||
| 336 | + | if len(certData) > 0 { | |||
| 337 | + | previewLen := 50 | |||
| 338 | + | if len(certData) < previewLen { | |||
| 339 | + | previewLen = len(certData) | |||
| 340 | + | } | |||
| 341 | + | preview := string(certData[:previewLen]) | |||
| 342 | + | logger.Debug("Certificate data preview", | |||
| 343 | + | "domain", domain, | |||
| 344 | + | "preview", preview+"...") | |||
| 345 | + | } | |||
| 350 | return false, 0, "unknown", nil | 346 | return false, 0, "unknown", nil | ||
| 351 | } | 347 | } | ||
| 352 | 348 | ||||
| 349 | + | // Check if there are additional certificates in the chain | |||
| 350 | + | var certChain []*x509.Certificate | |||
| 351 | + | currentRest := rest | |||
| 352 | + | ||||
| 353 | + | for len(currentRest) > 0 { | |||
| 354 | + | var nextBlock *pem.Block | |||
| 355 | + | nextBlock, currentRest = pem.Decode(currentRest) | |||
| 356 | + | if nextBlock == nil || nextBlock.Type != "CERTIFICATE" { | |||
| 357 | + | break | |||
| 358 | + | } | |||
| 359 | + | cert, err := x509.ParseCertificate(nextBlock.Bytes) | |||
| 360 | + | if err != nil { | |||
| 361 | + | logger.Debug("Failed to parse certificate in chain", | |||
| 362 | + | "domain", domain, | |||
| 363 | + | "error", err) | |||
| 364 | + | continue | |||
| 365 | + | } | |||
| 366 | + | certChain = append(certChain, cert) | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | if len(certChain) > 0 { | |||
| 370 | + | logger.Debug("Found certificate chain with additional certificates", | |||
| 371 | + | "domain", domain, | |||
| 372 | + | "chain_length", len(certChain)) | |||
| 373 | + | } | |||
| 374 | + | ||||
| 353 | cert, err := x509.ParseCertificate(block.Bytes) | 375 | cert, err := x509.ParseCertificate(block.Bytes) | ||
| 354 | if err != nil { | 376 | if err != nil { | ||
| 355 | logger.Warn("Failed to parse certificate", | 377 | logger.Warn("Failed to parse certificate", | ||
| @@ -358,8 +380,35 @@ func isCertificateValid(certData []byte, domain string) (bool, float64, string, | |||||
| 358 | return false, 0, "unknown", nil | 380 | return false, 0, "unknown", nil | ||
| 359 | } | 381 | } | ||
| 360 | 382 | ||||
| 383 | + | // Verify the certificate includes the domain we're checking for | |||
| 384 | + | var domainFound bool | |||
| 385 | + | if len(cert.DNSNames) > 0 { | |||
| 386 | + | logger.Debug("Certificate DNS names", | |||
| 387 | + | "domain", domain, | |||
| 388 | + | "certificate_domains", cert.DNSNames) | |||
| 389 | + | ||||
| 390 | + | for _, dnsName := range cert.DNSNames { | |||
| 391 | + | if dnsName == domain { | |||
| 392 | + | domainFound = true | |||
| 393 | + | break | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | if !domainFound && cert.Subject.CommonName != domain { | |||
| 399 | + | logger.Warn("Certificate doesn't match the required domain", | |||
| 400 | + | "domain", domain, | |||
| 401 | + | "cert_common_name", cert.Subject.CommonName, | |||
| 402 | + | "cert_dns_names", cert.DNSNames) | |||
| 403 | + | return false, 0, "unknown", cert | |||
| 404 | + | } | |||
| 405 | + | ||||
| 361 | // Determine the certificate type | 406 | // Determine the certificate type | ||
| 362 | certType := getCertificateType(cert) | 407 | certType := getCertificateType(cert) | ||
| 408 | + | logger.Debug("Determined certificate type", | |||
| 409 | + | "domain", domain, | |||
| 410 | + | "type", certType, | |||
| 411 | + | "issuer", cert.Issuer.CommonName) | |||
| 363 | 412 | ||||
| 364 | // Check if the certificate is still valid | 413 | // Check if the certificate is still valid | ||
| 365 | now := time.Now() | 414 | now := time.Now() | ||
| @@ -816,6 +865,46 @@ func (p *Proxy) requestAdminCertificate() { | |||||
| 816 | return | 865 | return | ||
| 817 | } | 866 | } | ||
| 818 | 867 | ||||
| 868 | + | // First, check if certificate is already in the autocert cache | |||
| 869 | + | // This is the critical step to avoid unnecessary Let's Encrypt requests | |||
| 870 | + | cacheKey := "cert-" + adminDomain | |||
| 871 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 872 | + | defer cancel() | |||
| 873 | + | ||||
| 874 | + | if p.certManager != nil && p.certManager.Cache != nil { | |||
| 875 | + | certData, err := p.certManager.Cache.Get(ctx, cacheKey) | |||
| 876 | + | if err == nil && len(certData) > 0 { | |||
| 877 | + | // Check if the cached certificate is valid | |||
| 878 | + | isValid, expiresInDays, certType, _ := isCertificateValid(certData, adminDomain) | |||
| 879 | + | if isValid { | |||
| 880 | + | logger.Info("Using valid certificate from cache", | |||
| 881 | + | "domain", adminDomain, | |||
| 882 | + | "type", certType, | |||
| 883 | + | "expires_in_days", expiresInDays) | |||
| 884 | + | return | |||
| 885 | + | } | |||
| 886 | + | ||||
| 887 | + | // If certificate is invalid or expiring soon, log but continue | |||
| 888 | + | logger.Info("Cached certificate is invalid or expiring soon", | |||
| 889 | + | "domain", adminDomain, | |||
| 890 | + | "expires_in_days", expiresInDays, | |||
| 891 | + | "valid", isValid) | |||
| 892 | + | } | |||
| 893 | + | } | |||
| 894 | + | ||||
| 895 | + | // Additional rate limit safety check | |||
| 896 | + | // Check if we've recently hit a rate limit for this domain | |||
| 897 | + | // This is a temporary memory-only safeguard | |||
| 898 | + | rlCacheKey := adminDomain + "_ratelimit_history" | |||
| 899 | + | rateLimitData, _ := p.certManager.Cache.Get(ctx, rlCacheKey) | |||
| 900 | + | if len(rateLimitData) > 0 { | |||
| 901 | + | logger.Warn("Rate limit history found for admin domain, skipping certificate request", | |||
| 902 | + | "domain", adminDomain, | |||
| 903 | + | "data", string(rateLimitData), | |||
| 904 | + | "solution", "Wait for rate limit to expire") | |||
| 905 | + | return | |||
| 906 | + | } | |||
| 907 | + | ||||
| 819 | // Extract hostname from admin domain, resolving it to see if it's publicly accessible | 908 | // Extract hostname from admin domain, resolving it to see if it's publicly accessible | ||
| 820 | host := adminDomain | 909 | host := adminDomain | ||
| 821 | ips, err := net.LookupIP(host) | 910 | ips, err := net.LookupIP(host) | ||
| @@ -907,7 +996,7 @@ func (p *Proxy) requestAdminCertificate() { | |||||
| 907 | "timeout", "1 minute") | 996 | "timeout", "1 minute") | ||
| 908 | 997 | ||||
| 909 | // Create a context with timeout for the initial certificate request | 998 | // Create a context with timeout for the initial certificate request | ||
| 910 | - | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) | 999 | + | ctx, cancel = context.WithTimeout(context.Background(), 1*time.Minute) |
| 911 | defer cancel() | 1000 | defer cancel() | ||
| 912 | 1001 | ||||
| 913 | // Try to obtain the certificate with timeout | 1002 | // Try to obtain the certificate with timeout | ||
| @@ -951,7 +1040,10 @@ func (p *Proxy) requestAdminCertificate() { | |||||
| 951 | re := regexp.MustCompile(`retry after ([^:]+)`) | 1040 | re := regexp.MustCompile(`retry after ([^:]+)`) | ||
| 952 | matches := re.FindStringSubmatch(certErr.Error()) | 1041 | matches := re.FindStringSubmatch(certErr.Error()) | ||
| 953 | if len(matches) > 1 { | 1042 | if len(matches) > 1 { | ||
| 954 | - | retryAfterStr = matches[1] | 1043 | + | retryAfterStr = strings.TrimSpace(matches[1]) |
| 1044 | + | if idx := strings.Index(retryAfterStr, ":"); idx > 0 { | |||
| 1045 | + | retryAfterStr = retryAfterStr[:idx] | |||
| 1046 | + | } | |||
| 955 | } | 1047 | } | ||
| 956 | 1048 | ||||
| 957 | logger.Error("Let's Encrypt rate limit reached", | 1049 | logger.Error("Let's Encrypt rate limit reached", | ||
| @@ -960,6 +1052,27 @@ func (p *Proxy) requestAdminCertificate() { | |||||
| 960 | "retry_after", retryAfterStr, | 1052 | "retry_after", retryAfterStr, | ||
| 961 | "solution", "Wait until the rate limit expires, then restart the server") | 1053 | "solution", "Wait until the rate limit expires, then restart the server") | ||
| 962 | 1054 | ||||
| 1055 | + | // Store rate limit history in cache to prevent further requests | |||
| 1056 | + | rlCacheKey := adminDomain + "_ratelimit_history" | |||
| 1057 | + | rlCtx, rlCancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 1058 | + | defer rlCancel() | |||
| 1059 | + | ||||
| 1060 | + | // Store the error message and timestamp in the cache | |||
| 1061 | + | rlData := []byte(fmt.Sprintf("Rate limited at %s: %s", | |||
| 1062 | + | time.Now().Format(time.RFC3339), certErr.Error())) | |||
| 1063 | + | ||||
| 1064 | + | if p.certManager != nil && p.certManager.Cache != nil { | |||
| 1065 | + | // Store with a long expiration - Let's Encrypt rate limits are typically for a week | |||
| 1066 | + | err := p.certManager.Cache.Put(rlCtx, rlCacheKey, rlData) | |||
| 1067 | + | if err != nil { | |||
| 1068 | + | logger.Debug("Failed to store rate limit history in cache", | |||
| 1069 | + | "domain", adminDomain, "error", err) | |||
| 1070 | + | } else { | |||
| 1071 | + | logger.Info("Stored rate limit history in cache to prevent further requests", | |||
| 1072 | + | "domain", adminDomain, "retry_after", retryAfterStr) | |||
| 1073 | + | } | |||
| 1074 | + | } | |||
| 1075 | + | ||||
| 963 | // Don't retry on rate limits | 1076 | // Don't retry on rate limits | ||
| 964 | logger.Info("Skipping certificate request retries due to rate limiting", | 1077 | logger.Info("Skipping certificate request retries due to rate limiting", | ||
| 965 | "domain", adminDomain) | 1078 | "domain", adminDomain) | ||
| @@ -989,10 +1102,96 @@ func (p *Proxy) requestAdminCertificate() { | |||||
| 989 | newCertPath := filepath.Join(certDir, newName) | 1102 | newCertPath := filepath.Join(certDir, newName) | ||
| 990 | newKeyPath := filepath.Join(certDir, newName+"+rsa") | 1103 | newKeyPath := filepath.Join(certDir, newName+"+rsa") | ||
| 991 | 1104 | ||||
| 1105 | + | // Debug log certificate file paths | |||
| 1106 | + | logger.Debug("Certificate file paths", | |||
| 1107 | + | "domain", adminDomain, | |||
| 1108 | + | "old_cert", oldCertPath, | |||
| 1109 | + | "new_cert", newCertPath, | |||
| 1110 | + | "old_key", oldKeyPath, | |||
| 1111 | + | "new_key", newKeyPath) | |||
| 1112 | + | ||||
| 1113 | + | // Check if files exist first | |||
| 1114 | + | oldCertExists := false | |||
| 1115 | + | if _, err := os.Stat(oldCertPath); err == nil { | |||
| 1116 | + | oldCertExists = true | |||
| 1117 | + | logger.Debug("Old certificate file exists", "path", oldCertPath) | |||
| 1118 | + | } else { | |||
| 1119 | + | logger.Debug("Old certificate file not found", "path", oldCertPath, "error", err) | |||
| 1120 | + | } | |||
| 1121 | + | ||||
| 1122 | + | // Read the certificate files directly to verify they contain valid data | |||
| 1123 | + | if oldCertExists { | |||
| 1124 | + | certBytes, err := os.ReadFile(oldCertPath) | |||
| 1125 | + | if err != nil { | |||
| 1126 | + | logger.Error("Failed to read certificate file", | |||
| 1127 | + | "path", oldCertPath, | |||
| 1128 | + | "error", err) | |||
| 1129 | + | } else { | |||
| 1130 | + | logger.Debug("Successfully read certificate file", | |||
| 1131 | + | "path", oldCertPath, | |||
| 1132 | + | "size", len(certBytes)) | |||
| 1133 | + | ||||
| 1134 | + | // Verify it's a valid PEM format | |||
| 1135 | + | block, _ := pem.Decode(certBytes) | |||
| 1136 | + | if block == nil { | |||
| 1137 | + | logger.Error("Certificate file does not contain valid PEM data", | |||
| 1138 | + | "path", oldCertPath) | |||
| 1139 | + | } else { | |||
| 1140 | + | logger.Debug("Certificate file contains valid PEM data", | |||
| 1141 | + | "path", oldCertPath, | |||
| 1142 | + | "type", block.Type) | |||
| 1143 | + | } | |||
| 1144 | + | } | |||
| 1145 | + | ||||
| 1146 | + | // Check permissions on cert and key files | |||
| 1147 | + | certFileInfo, err := os.Stat(oldCertPath) | |||
| 1148 | + | if err == nil { | |||
| 1149 | + | mode := certFileInfo.Mode() | |||
| 1150 | + | logger.Debug("Certificate file permissions", | |||
| 1151 | + | "path", oldCertPath, | |||
| 1152 | + | "mode", mode.String()) | |||
| 1153 | + | ||||
| 1154 | + | // Ensure cert file is readable by all | |||
| 1155 | + | if mode.Perm()&0444 != 0444 { | |||
| 1156 | + | logger.Warn("Certificate file has restrictive permissions, fixing", | |||
| 1157 | + | "path", oldCertPath, | |||
| 1158 | + | "current_mode", mode.String()) | |||
| 1159 | + | if err := os.Chmod(oldCertPath, 0644); err != nil { | |||
| 1160 | + | logger.Error("Failed to fix certificate file permissions", | |||
| 1161 | + | "path", oldCertPath, | |||
| 1162 | + | "error", err) | |||
| 1163 | + | } | |||
| 1164 | + | } | |||
| 1165 | + | } | |||
| 1166 | + | ||||
| 1167 | + | // Check key file permissions | |||
| 1168 | + | if _, err := os.Stat(oldKeyPath); err == nil { | |||
| 1169 | + | keyFileInfo, err := os.Stat(oldKeyPath) | |||
| 1170 | + | if err == nil { | |||
| 1171 | + | mode := keyFileInfo.Mode() | |||
| 1172 | + | logger.Debug("Key file permissions", | |||
| 1173 | + | "path", oldKeyPath, | |||
| 1174 | + | "mode", mode.String()) | |||
| 1175 | + | ||||
| 1176 | + | // Ensure key file is only readable by owner | |||
| 1177 | + | if mode.Perm()&0400 != 0400 || mode.Perm()&0077 != 0 { | |||
| 1178 | + | logger.Warn("Key file has incorrect permissions, fixing", | |||
| 1179 | + | "path", oldKeyPath, | |||
| 1180 | + | "current_mode", mode.String()) | |||
| 1181 | + | if err := os.Chmod(oldKeyPath, 0600); err != nil { | |||
| 1182 | + | logger.Error("Failed to fix key file permissions", | |||
| 1183 | + | "path", oldKeyPath, | |||
| 1184 | + | "error", err) | |||
| 1185 | + | } | |||
| 1186 | + | } | |||
| 1187 | + | } | |||
| 1188 | + | } | |||
| 1189 | + | } | |||
| 1190 | + | ||||
| 992 | // Only rename if the files exist and the new names are different | 1191 | // Only rename if the files exist and the new names are different | ||
| 993 | if oldCertPath != newCertPath { | 1192 | if oldCertPath != newCertPath { | ||
| 994 | // Check if old cert exists | 1193 | // Check if old cert exists | ||
| 995 | - | if _, err := os.Stat(oldCertPath); err == nil { | 1194 | + | if oldCertExists { |
| 996 | // Rename cert | 1195 | // Rename cert | ||
| 997 | if err := os.Rename(oldCertPath, newCertPath); err != nil { | 1196 | if err := os.Rename(oldCertPath, newCertPath); err != nil { | ||
| 998 | logger.Warn("Failed to rename certificate file", | 1197 | logger.Warn("Failed to rename certificate file", | ||
| @@ -1023,6 +1222,8 @@ func (p *Proxy) requestAdminCertificate() { | |||||
| 1023 | "from", oldKeyPath, | 1222 | "from", oldKeyPath, | ||
| 1024 | "to", newKeyPath) | 1223 | "to", newKeyPath) | ||
| 1025 | } | 1224 | } | ||
| 1225 | + | } else { | |||
| 1226 | + | logger.Warn("Old key file not found", "path", oldKeyPath, "error", err) | |||
| 1026 | } | 1227 | } | ||
| 1027 | } | 1228 | } | ||
| 1028 | } | 1229 | } | ||
| @@ -1030,6 +1231,75 @@ func (p *Proxy) requestAdminCertificate() { | |||||
| 1030 | logger.Info("Successfully obtained Let's Encrypt certificate", | 1231 | logger.Info("Successfully obtained Let's Encrypt certificate", | ||
| 1031 | "domain", adminDomain, | 1232 | "domain", adminDomain, | ||
| 1032 | "type", certType) | 1233 | "type", certType) | ||
| 1234 | + | ||||
| 1235 | + | // Verify the certificate files are accessible in the cache | |||
| 1236 | + | cacheKey := "cert-" + adminDomain | |||
| 1237 | + | cacheCtx, cacheCancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 1238 | + | defer cacheCancel() | |||
| 1239 | + | ||||
| 1240 | + | certData, cacheErr := p.certManager.Cache.Get(cacheCtx, cacheKey) | |||
| 1241 | + | if cacheErr != nil { | |||
| 1242 | + | logger.Warn("Certificate not found in cache after successful obtainment", | |||
| 1243 | + | "domain", adminDomain, | |||
| 1244 | + | "error", cacheErr, | |||
| 1245 | + | "solution", "Check certificate permissions and file paths") | |||
| 1246 | + | ||||
| 1247 | + | // If certificate isn't in autocert cache but exists in filesystem, try to manually add it | |||
| 1248 | + | newCertExists := false | |||
| 1249 | + | if _, err := os.Stat(newCertPath); err == nil { | |||
| 1250 | + | newCertExists = true | |||
| 1251 | + | } | |||
| 1252 | + | ||||
| 1253 | + | if oldCertExists || newCertExists { | |||
| 1254 | + | logger.Info("Attempting to manually add certificate to cache from filesystem", | |||
| 1255 | + | "domain", adminDomain) | |||
| 1256 | + | ||||
| 1257 | + | sourceCertPath := newCertPath | |||
| 1258 | + | sourceKeyPath := newKeyPath | |||
| 1259 | + | ||||
| 1260 | + | // If the file wasn't renamed, use the old paths | |||
| 1261 | + | if _, err := os.Stat(sourceCertPath); err != nil { | |||
| 1262 | + | sourceCertPath = oldCertPath | |||
| 1263 | + | sourceKeyPath = oldKeyPath | |||
| 1264 | + | } | |||
| 1265 | + | ||||
| 1266 | + | // Read the certificate and key files | |||
| 1267 | + | certBytes, err := os.ReadFile(sourceCertPath) | |||
| 1268 | + | if err != nil { | |||
| 1269 | + | logger.Error("Failed to read certificate file for manual cache addition", | |||
| 1270 | + | "path", sourceCertPath, | |||
| 1271 | + | "error", err) | |||
| 1272 | + | } else { | |||
| 1273 | + | keyBytes, err := os.ReadFile(sourceKeyPath) | |||
| 1274 | + | if err != nil { | |||
| 1275 | + | logger.Error("Failed to read key file for manual cache addition", | |||
| 1276 | + | "path", sourceKeyPath, | |||
| 1277 | + | "error", err) | |||
| 1278 | + | } else { | |||
| 1279 | + | // Add to autocert cache | |||
| 1280 | + | if err := p.certManager.Cache.Put(cacheCtx, cacheKey, certBytes); err != nil { | |||
| 1281 | + | logger.Error("Failed to manually add certificate to cache", | |||
| 1282 | + | "domain", adminDomain, | |||
| 1283 | + | "error", err) | |||
| 1284 | + | } else { | |||
| 1285 | + | keyKey := "key-" + adminDomain | |||
| 1286 | + | if err := p.certManager.Cache.Put(cacheCtx, keyKey, keyBytes); err != nil { | |||
| 1287 | + | logger.Error("Failed to manually add key to cache", | |||
| 1288 | + | "domain", adminDomain, | |||
| 1289 | + | "error", err) | |||
| 1290 | + | } else { | |||
| 1291 | + | logger.Info("Successfully manually added certificate to cache", | |||
| 1292 | + | "domain", adminDomain) | |||
| 1293 | + | } | |||
| 1294 | + | } | |||
| 1295 | + | } | |||
| 1296 | + | } | |||
| 1297 | + | } | |||
| 1298 | + | } else { | |||
| 1299 | + | logger.Debug("Certificate successfully stored in cache", | |||
| 1300 | + | "domain", adminDomain, | |||
| 1301 | + | "cert_size", len(certData)) | |||
| 1302 | + | } | |||
| 1033 | } else { | 1303 | } else { | ||
| 1034 | logger.Error("Unexpected error: received nil certificate but no error", | 1304 | logger.Error("Unexpected error: received nil certificate but no error", | ||
| 1035 | "domain", adminDomain, | 1305 | "domain", adminDomain, | ||
| @@ -1056,6 +1326,33 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||||
| 1056 | return | 1326 | return | ||
| 1057 | } | 1327 | } | ||
| 1058 | 1328 | ||||
| 1329 | + | // First, check if certificate is already in the autocert cache | |||
| 1330 | + | // This is the critical step to avoid unnecessary Let's Encrypt requests | |||
| 1331 | + | cacheKey := "cert-" + domain | |||
| 1332 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 1333 | + | defer cancel() | |||
| 1334 | + | ||||
| 1335 | + | if p.certManager != nil && p.certManager.Cache != nil { | |||
| 1336 | + | certData, err := p.certManager.Cache.Get(ctx, cacheKey) | |||
| 1337 | + | if err == nil && len(certData) > 0 { | |||
| 1338 | + | // Check if the cached certificate is valid | |||
| 1339 | + | isValid, expiresInDays, certType, _ := isCertificateValid(certData, domain) | |||
| 1340 | + | if isValid { | |||
| 1341 | + | logger.Info("Using valid certificate from cache", | |||
| 1342 | + | "domain", domain, | |||
| 1343 | + | "type", certType, | |||
| 1344 | + | "expires_in_days", expiresInDays) | |||
| 1345 | + | return | |||
| 1346 | + | } | |||
| 1347 | + | ||||
| 1348 | + | // If certificate is invalid or expiring soon, log but continue | |||
| 1349 | + | logger.Info("Cached certificate is invalid or expiring soon", | |||
| 1350 | + | "domain", domain, | |||
| 1351 | + | "expires_in_days", expiresInDays, | |||
| 1352 | + | "valid", isValid) | |||
| 1353 | + | } | |||
| 1354 | + | } | |||
| 1355 | + | ||||
| 1059 | // Extract hostname from domain, resolving it to see if it's publicly accessible | 1356 | // Extract hostname from domain, resolving it to see if it's publicly accessible | ||
| 1060 | ips, err := net.LookupIP(domain) | 1357 | ips, err := net.LookupIP(domain) | ||
| 1061 | if err != nil { | 1358 | if err != nil { | ||
| @@ -1100,6 +1397,46 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||||
| 1100 | validInCache, _ := p.checkCertificateInCache(domain) | 1397 | validInCache, _ := p.checkCertificateInCache(domain) | ||
| 1101 | validInFS, _ := p.checkCertificateInFilesystem(domain) | 1398 | validInFS, _ := p.checkCertificateInFilesystem(domain) | ||
| 1102 | if !validInCache && validInFS { | 1399 | if !validInCache && validInFS { | ||
| 1400 | + | // Try to load the certificate into the cache if it's not there already | |||
| 1401 | + | // This helps prevent future Let's Encrypt requests | |||
| 1402 | + | hasOnDisk, certType := p.checkCertificateInFilesystem(domain) | |||
| 1403 | + | if hasOnDisk { | |||
| 1404 | + | logger.Info("Loading filesystem certificate into cache", | |||
| 1405 | + | "domain", domain, | |||
| 1406 | + | "type", certType) | |||
| 1407 | + | ||||
| 1408 | + | // Get the certificate directory path for finding files | |||
| 1409 | + | certDir := p.config.CertDir | |||
| 1410 | + | if certDir == "" { | |||
| 1411 | + | certDir = p.app.GetConfig().General.StorageDir + "/certs" | |||
| 1412 | + | } | |||
| 1413 | + | ||||
| 1414 | + | // Look for production certificate first, then staging | |||
| 1415 | + | certFile := getCertificateFileName(domain, "production") | |||
| 1416 | + | certPath := filepath.Join(certDir, certFile) | |||
| 1417 | + | keyPath := filepath.Join(certDir, certFile+"+rsa") | |||
| 1418 | + | ||||
| 1419 | + | // If production cert doesn't exist, try staging | |||
| 1420 | + | if _, err := os.Stat(certPath); os.IsNotExist(err) { | |||
| 1421 | + | certFile = getCertificateFileName(domain, "staging") | |||
| 1422 | + | certPath = filepath.Join(certDir, certFile) | |||
| 1423 | + | keyPath = filepath.Join(certDir, certFile+"+rsa") | |||
| 1424 | + | } | |||
| 1425 | + | ||||
| 1426 | + | // If no specific cert found, try the domain name directly | |||
| 1427 | + | if _, err := os.Stat(certPath); os.IsNotExist(err) { | |||
| 1428 | + | certPath = filepath.Join(certDir, domain) | |||
| 1429 | + | keyPath = filepath.Join(certDir, domain+"+rsa") | |||
| 1430 | + | } | |||
| 1431 | + | ||||
| 1432 | + | // Add to cache if files exist | |||
| 1433 | + | if _, err := os.Stat(certPath); err == nil { | |||
| 1434 | + | if _, err := os.Stat(keyPath); err == nil { | |||
| 1435 | + | p.addCertificateToCache(domain, certPath, keyPath) | |||
| 1436 | + | } | |||
| 1437 | + | } | |||
| 1438 | + | } | |||
| 1439 | + | ||||
| 1103 | if testCertificateWithTLSConnection(domain, p.config.Port) { | 1440 | if testCertificateWithTLSConnection(domain, p.config.Port) { | ||
| 1104 | logger.Debug("Certificate verification succeeded via TLS connection", | 1441 | logger.Debug("Certificate verification succeeded via TLS connection", | ||
| 1105 | "domain", domain, | 1442 | "domain", domain, | ||
| @@ -1116,6 +1453,18 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||||
| 1116 | } | 1453 | } | ||
| 1117 | } | 1454 | } | ||
| 1118 | 1455 | ||||
| 1456 | + | // Additional rate limit safety check | |||
| 1457 | + | // Check if we've recently hit a rate limit for this domain | |||
| 1458 | + | // This is a temporary memory-only safeguard | |||
| 1459 | + | cacheKey = domain + "_ratelimit_history" | |||
| 1460 | + | rateLimitData, _ := p.certManager.Cache.Get(ctx, cacheKey) | |||
| 1461 | + | if len(rateLimitData) > 0 { | |||
| 1462 | + | logger.Warn("Rate limit history found for this domain, skipping certificate request", | |||
| 1463 | + | "domain", domain, | |||
| 1464 | + | "solution", "Wait for rate limit to expire") | |||
| 1465 | + | return | |||
| 1466 | + | } | |||
| 1467 | + | ||||
| 1119 | // Get the certificate directory path for naming the output files | 1468 | // Get the certificate directory path for naming the output files | ||
| 1120 | certDir := p.config.CertDir | 1469 | certDir := p.config.CertDir | ||
| 1121 | if certDir == "" { | 1470 | if certDir == "" { | ||
| @@ -1146,7 +1495,7 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||||
| 1146 | "timeout", "1 minute") | 1495 | "timeout", "1 minute") | ||
| 1147 | 1496 | ||||
| 1148 | // Create a context with timeout for the initial certificate request | 1497 | // Create a context with timeout for the initial certificate request | ||
| 1149 | - | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) | 1498 | + | ctx, cancel = context.WithTimeout(context.Background(), 1*time.Minute) |
| 1150 | defer cancel() | 1499 | defer cancel() | ||
| 1151 | 1500 | ||||
| 1152 | // Try to obtain the certificate with timeout | 1501 | // Try to obtain the certificate with timeout | ||
| @@ -1190,7 +1539,10 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||||
| 1190 | re := regexp.MustCompile(`retry after ([^:]+)`) | 1539 | re := regexp.MustCompile(`retry after ([^:]+)`) | ||
| 1191 | matches := re.FindStringSubmatch(certErr.Error()) | 1540 | matches := re.FindStringSubmatch(certErr.Error()) | ||
| 1192 | if len(matches) > 1 { | 1541 | if len(matches) > 1 { | ||
| 1193 | - | retryAfterStr = matches[1] | 1542 | + | retryAfterStr = strings.TrimSpace(matches[1]) |
| 1543 | + | if idx := strings.Index(retryAfterStr, ":"); idx > 0 { | |||
| 1544 | + | retryAfterStr = retryAfterStr[:idx] | |||
| 1545 | + | } | |||
| 1194 | } | 1546 | } | ||
| 1195 | 1547 | ||||
| 1196 | logger.Error("Let's Encrypt rate limit reached", | 1548 | logger.Error("Let's Encrypt rate limit reached", | ||
| @@ -1199,6 +1551,27 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||||
| 1199 | "retry_after", retryAfterStr, | 1551 | "retry_after", retryAfterStr, | ||
| 1200 | "solution", "Wait until the rate limit expires, then restart the server") | 1552 | "solution", "Wait until the rate limit expires, then restart the server") | ||
| 1201 | 1553 | ||||
| 1554 | + | // Store rate limit history in cache to prevent further requests | |||
| 1555 | + | rlCacheKey := domain + "_ratelimit_history" | |||
| 1556 | + | rlCtx, rlCancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 1557 | + | defer rlCancel() | |||
| 1558 | + | ||||
| 1559 | + | // Store the error message and timestamp in the cache | |||
| 1560 | + | rlData := []byte(fmt.Sprintf("Rate limited at %s: %s", | |||
| 1561 | + | time.Now().Format(time.RFC3339), certErr.Error())) | |||
| 1562 | + | ||||
| 1563 | + | if p.certManager != nil && p.certManager.Cache != nil { | |||
| 1564 | + | // Store with a long expiration - Let's Encrypt rate limits are typically for a week | |||
| 1565 | + | err := p.certManager.Cache.Put(rlCtx, rlCacheKey, rlData) | |||
| 1566 | + | if err != nil { | |||
| 1567 | + | logger.Debug("Failed to store rate limit history in cache", | |||
| 1568 | + | "domain", domain, "error", err) | |||
| 1569 | + | } else { | |||
| 1570 | + | logger.Info("Stored rate limit history in cache to prevent further requests", | |||
| 1571 | + | "domain", domain, "retry_after", retryAfterStr) | |||
| 1572 | + | } | |||
| 1573 | + | } | |||
| 1574 | + | ||||
| 1202 | // Don't retry on rate limits | 1575 | // Don't retry on rate limits | ||
| 1203 | logger.Info("Skipping certificate request retries due to rate limiting", | 1576 | logger.Info("Skipping certificate request retries due to rate limiting", | ||
| 1204 | "domain", domain) | 1577 | "domain", domain) | ||
| @@ -1262,6 +1635,8 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||||
| 1262 | "from", oldKeyPath, | 1635 | "from", oldKeyPath, | ||
| 1263 | "to", newKeyPath) | 1636 | "to", newKeyPath) | ||
| 1264 | } | 1637 | } | ||
| 1638 | + | } else { | |||
| 1639 | + | logger.Warn("Old key file not found", "path", oldKeyPath, "error", err) | |||
| 1265 | } | 1640 | } | ||
| 1266 | } | 1641 | } | ||
| 1267 | } | 1642 | } | ||
| @@ -1393,6 +1768,8 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa | |||||
| 1393 | "from", oldKeyPath, | 1768 | "from", oldKeyPath, | ||
| 1394 | "to", newKeyPath) | 1769 | "to", newKeyPath) | ||
| 1395 | } | 1770 | } | ||
| 1771 | + | } else { | |||
| 1772 | + | logger.Warn("Old key file not found", "path", oldKeyPath, "error", err) | |||
| 1396 | } | 1773 | } | ||
| 1397 | } | 1774 | } | ||
| 1398 | } | 1775 | } | ||
| @@ -1585,3 +1962,319 @@ func generateFallbackCertificates(domains []string) (*tls.Certificate, error) { | |||||
| 1585 | 1962 | ||||
| 1586 | return &cert, nil | 1963 | return &cert, nil | ||
| 1587 | } | 1964 | } | ||
| 1965 | + | ||||
| 1966 | + | // preloadCertificates scans the certificate directory and loads all valid certificates | |||
| 1967 | + | // into the autocert cache to prevent unnecessary requests to Let's Encrypt | |||
| 1968 | + | func (p *Proxy) preloadCertificates() { | |||
| 1969 | + | if p.certManager == nil || p.certManager.Cache == nil { | |||
| 1970 | + | logger.Warn("Cannot preload certificates: certificate manager or cache not initialized") | |||
| 1971 | + | return | |||
| 1972 | + | } | |||
| 1973 | + | ||||
| 1974 | + | // Get the certificate directory path | |||
| 1975 | + | certDir := p.config.CertDir | |||
| 1976 | + | if certDir == "" { | |||
| 1977 | + | certDir = p.app.GetConfig().General.StorageDir + "/certs" | |||
| 1978 | + | } | |||
| 1979 | + | ||||
| 1980 | + | logger.Info("Preloading certificates from directory", "dir", certDir) | |||
| 1981 | + | ||||
| 1982 | + | // Ensure directory exists | |||
| 1983 | + | if _, err := os.Stat(certDir); os.IsNotExist(err) { | |||
| 1984 | + | logger.Warn("Certificate directory does not exist, creating it", "dir", certDir) | |||
| 1985 | + | if err := os.MkdirAll(certDir, 0755); err != nil { | |||
| 1986 | + | logger.Error("Failed to create certificate directory", "dir", certDir, "error", err) | |||
| 1987 | + | return | |||
| 1988 | + | } | |||
| 1989 | + | } | |||
| 1990 | + | ||||
| 1991 | + | // List all files in the certificate directory | |||
| 1992 | + | files, err := os.ReadDir(certDir) | |||
| 1993 | + | if err != nil { | |||
| 1994 | + | logger.Error("Failed to read certificate directory", "dir", certDir, "error", err) | |||
| 1995 | + | return | |||
| 1996 | + | } | |||
| 1997 | + | ||||
| 1998 | + | // Log all files in the directory to help troubleshoot | |||
| 1999 | + | var fileNames []string | |||
| 2000 | + | for _, file := range files { | |||
| 2001 | + | fileNames = append(fileNames, file.Name()) | |||
| 2002 | + | ||||
| 2003 | + | // Check file permissions and fix if too restrictive | |||
| 2004 | + | filePath := filepath.Join(certDir, file.Name()) | |||
| 2005 | + | info, err := os.Stat(filePath) | |||
| 2006 | + | if err != nil { | |||
| 2007 | + | logger.Warn("Could not stat file", "path", filePath, "error", err) | |||
| 2008 | + | continue | |||
| 2009 | + | } | |||
| 2010 | + | ||||
| 2011 | + | mode := info.Mode() | |||
| 2012 | + | if strings.HasSuffix(file.Name(), "+rsa") { | |||
| 2013 | + | // Key files should be 0600 (owner read/write only) | |||
| 2014 | + | if mode.Perm() != 0600 { | |||
| 2015 | + | logger.Info("Fixing key file permissions", "file", file.Name(), "current_mode", mode.String(), "target_mode", "0600") | |||
| 2016 | + | if err := os.Chmod(filePath, 0600); err != nil { | |||
| 2017 | + | logger.Warn("Failed to fix key file permissions", "file", file.Name(), "error", err) | |||
| 2018 | + | } | |||
| 2019 | + | } | |||
| 2020 | + | } else { | |||
| 2021 | + | // Certificate files should be 0644 (readable by all) | |||
| 2022 | + | if mode.Perm() != 0644 { | |||
| 2023 | + | logger.Info("Fixing certificate file permissions", "file", file.Name(), "current_mode", mode.String(), "target_mode", "0644") | |||
| 2024 | + | if err := os.Chmod(filePath, 0644); err != nil { | |||
| 2025 | + | logger.Warn("Failed to fix certificate file permissions", "file", file.Name(), "error", err) | |||
| 2026 | + | } | |||
| 2027 | + | } | |||
| 2028 | + | } | |||
| 2029 | + | } | |||
| 2030 | + | ||||
| 2031 | + | logger.Debug("Files in certificate directory", "dir", certDir, "files", fileNames) | |||
| 2032 | + | ||||
| 2033 | + | // Track the number of certificates loaded | |||
| 2034 | + | loadedCount := 0 | |||
| 2035 | + | ||||
| 2036 | + | // Create a context for cache operations | |||
| 2037 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 2038 | + | defer cancel() | |||
| 2039 | + | ||||
| 2040 | + | // Find and process certificate files (excluding key files with +rsa suffix) | |||
| 2041 | + | // Group them by domain for easier processing | |||
| 2042 | + | domainCerts := make(map[string][]string) | |||
| 2043 | + | ||||
| 2044 | + | for _, file := range files { | |||
| 2045 | + | name := file.Name() | |||
| 2046 | + | ||||
| 2047 | + | // Skip account keys and +rsa key files as we'll handle these separately | |||
| 2048 | + | if strings.HasSuffix(name, "+rsa") || strings.Contains(name, "acme_account") { | |||
| 2049 | + | continue | |||
| 2050 | + | } | |||
| 2051 | + | ||||
| 2052 | + | // Get associated key file name (file+rsa) | |||
| 2053 | + | keyName := name + "+rsa" | |||
| 2054 | + | keyPath := filepath.Join(certDir, keyName) | |||
| 2055 | + | ||||
| 2056 | + | // Skip certificates without key files | |||
| 2057 | + | if _, err := os.Stat(keyPath); os.IsNotExist(err) { | |||
| 2058 | + | logger.Debug("Skipping certificate without key file", "cert", name, "key", keyName) | |||
| 2059 | + | continue | |||
| 2060 | + | } | |||
| 2061 | + | ||||
| 2062 | + | // Try to determine the domain from the filename | |||
| 2063 | + | domain := name | |||
| 2064 | + | ||||
| 2065 | + | // Handle special patterns like domain_letsencrypt-production | |||
| 2066 | + | for _, pattern := range []string{"_letsencrypt-production", "_letsencrypt-staging", "_self-signed"} { | |||
| 2067 | + | if strings.HasSuffix(domain, pattern) { | |||
| 2068 | + | domain = strings.TrimSuffix(domain, pattern) | |||
| 2069 | + | break | |||
| 2070 | + | } | |||
| 2071 | + | } | |||
| 2072 | + | ||||
| 2073 | + | // Add to domain certificates map | |||
| 2074 | + | domainCerts[domain] = append(domainCerts[domain], name) | |||
| 2075 | + | } | |||
| 2076 | + | ||||
| 2077 | + | // Process certificates for each domain | |||
| 2078 | + | for domain, certFiles := range domainCerts { | |||
| 2079 | + | logger.Debug("Found certificate files for domain", "domain", domain, "files", certFiles) | |||
| 2080 | + | ||||
| 2081 | + | // Prioritize production certificates if multiple exist | |||
| 2082 | + | var bestCert string | |||
| 2083 | + | bestCertType := "" | |||
| 2084 | + | ||||
| 2085 | + | for _, certFile := range certFiles { | |||
| 2086 | + | certPath := filepath.Join(certDir, certFile) | |||
| 2087 | + | ||||
| 2088 | + | // Read certificate to check its validity | |||
| 2089 | + | certData, err := os.ReadFile(certPath) | |||
| 2090 | + | if err != nil { | |||
| 2091 | + | logger.Warn("Failed to read certificate file", "path", certPath, "error", err) | |||
| 2092 | + | continue | |||
| 2093 | + | } | |||
| 2094 | + | ||||
| 2095 | + | // Debug certificate content | |||
| 2096 | + | if len(certData) < 100 { | |||
| 2097 | + | logger.Warn("Certificate file too small, may be corrupted", | |||
| 2098 | + | "path", certPath, "size", len(certData)) | |||
| 2099 | + | continue | |||
| 2100 | + | } | |||
| 2101 | + | ||||
| 2102 | + | // Validate the certificate | |||
| 2103 | + | valid, expiresInDays, certType, _ := isCertificateValid(certData, domain) | |||
| 2104 | + | if !valid { | |||
| 2105 | + | logger.Debug("Invalid certificate found", "domain", domain, "file", certFile) | |||
| 2106 | + | continue | |||
| 2107 | + | } | |||
| 2108 | + | ||||
| 2109 | + | logger.Info("Found valid certificate", "domain", domain, "type", certType, | |||
| 2110 | + | "expires_in_days", expiresInDays, "file", certFile) | |||
| 2111 | + | ||||
| 2112 | + | // Prioritize production certificates over staging ones | |||
| 2113 | + | if bestCertType == "" || | |||
| 2114 | + | (bestCertType != "production" && certType == "production") { | |||
| 2115 | + | bestCert = certFile | |||
| 2116 | + | bestCertType = certType | |||
| 2117 | + | } | |||
| 2118 | + | } | |||
| 2119 | + | ||||
| 2120 | + | // If we found a valid certificate for this domain, add it to the cache | |||
| 2121 | + | if bestCert != "" { | |||
| 2122 | + | certPath := filepath.Join(certDir, bestCert) | |||
| 2123 | + | keyPath := filepath.Join(certDir, bestCert+"+rsa") | |||
| 2124 | + | ||||
| 2125 | + | // Read certificate and key data | |||
| 2126 | + | certData, err := os.ReadFile(certPath) | |||
| 2127 | + | if err != nil { | |||
| 2128 | + | logger.Warn("Failed to read certificate file", "path", certPath, "error", err) | |||
| 2129 | + | continue | |||
| 2130 | + | } | |||
| 2131 | + | ||||
| 2132 | + | keyData, err := os.ReadFile(keyPath) | |||
| 2133 | + | if err != nil { | |||
| 2134 | + | logger.Warn("Failed to read key file", "path", keyPath, "error", err) | |||
| 2135 | + | continue | |||
| 2136 | + | } | |||
| 2137 | + | ||||
| 2138 | + | // Add certificate to autocert cache | |||
| 2139 | + | cacheKey := "cert-" + domain | |||
| 2140 | + | err = p.certManager.Cache.Put(ctx, cacheKey, certData) | |||
| 2141 | + | if err != nil { | |||
| 2142 | + | logger.Error("Failed to add certificate to cache", "domain", domain, "error", err) | |||
| 2143 | + | continue | |||
| 2144 | + | } | |||
| 2145 | + | ||||
| 2146 | + | // Add key to cache | |||
| 2147 | + | keyKey := "key-" + domain | |||
| 2148 | + | err = p.certManager.Cache.Put(ctx, keyKey, keyData) | |||
| 2149 | + | if err != nil { | |||
| 2150 | + | logger.Error("Failed to add key to cache", "domain", domain, "error", err) | |||
| 2151 | + | continue | |||
| 2152 | + | } | |||
| 2153 | + | ||||
| 2154 | + | logger.Info("Preloaded certificate into cache", "domain", domain, "type", bestCertType) | |||
| 2155 | + | loadedCount++ | |||
| 2156 | + | } else { | |||
| 2157 | + | logger.Warn("No valid certificate found for domain after checking all files", | |||
| 2158 | + | "domain", domain, | |||
| 2159 | + | "files_checked", certFiles) | |||
| 2160 | + | } | |||
| 2161 | + | } | |||
| 2162 | + | ||||
| 2163 | + | logger.Info("Certificate preloading complete", "loaded_certificates", loadedCount) | |||
| 2164 | + | } | |||
| 2165 | + | ||||
| 2166 | + | // manuallyLoadCertificate is a fallback method to load certificates directly | |||
| 2167 | + | // when autocert cache is failing. This directly builds the tls.Certificate from files. | |||
| 2168 | + | func (p *Proxy) manuallyLoadCertificate(domain string) (*tls.Certificate, error) { | |||
| 2169 | + | // Get certificate directory | |||
| 2170 | + | certDir := p.config.CertDir | |||
| 2171 | + | if certDir == "" { | |||
| 2172 | + | certDir = p.app.GetConfig().General.StorageDir + "/certs" | |||
| 2173 | + | } | |||
| 2174 | + | ||||
| 2175 | + | // Check preferred file naming patterns | |||
| 2176 | + | filePatterns := []string{ | |||
| 2177 | + | // Try production certificate first | |||
| 2178 | + | fmt.Sprintf(LetsEncryptProdPattern, domain), | |||
| 2179 | + | // Then staging certificate | |||
| 2180 | + | fmt.Sprintf(LetsEncryptStagingPattern, domain), | |||
| 2181 | + | // Then self-signed | |||
| 2182 | + | fmt.Sprintf(SelfSignedCertPattern, domain), | |||
| 2183 | + | // Finally plain domain name | |||
| 2184 | + | domain, | |||
| 2185 | + | } | |||
| 2186 | + | ||||
| 2187 | + | for _, pattern := range filePatterns { | |||
| 2188 | + | certPath := filepath.Join(certDir, pattern) | |||
| 2189 | + | keyPath := filepath.Join(certDir, pattern+"+rsa") | |||
| 2190 | + | ||||
| 2191 | + | // Check if both files exist | |||
| 2192 | + | if _, err := os.Stat(certPath); os.IsNotExist(err) { | |||
| 2193 | + | continue | |||
| 2194 | + | } | |||
| 2195 | + | if _, err := os.Stat(keyPath); os.IsNotExist(err) { | |||
| 2196 | + | continue | |||
| 2197 | + | } | |||
| 2198 | + | ||||
| 2199 | + | // Try to load the certificate pair | |||
| 2200 | + | cert, err := tls.LoadX509KeyPair(certPath, keyPath) | |||
| 2201 | + | if err != nil { | |||
| 2202 | + | logger.Warn("Failed to load certificate key pair", | |||
| 2203 | + | "domain", domain, | |||
| 2204 | + | "certPath", certPath, | |||
| 2205 | + | "keyPath", keyPath, | |||
| 2206 | + | "error", err) | |||
| 2207 | + | ||||
| 2208 | + | // Additional diagnostics | |||
| 2209 | + | if data, readErr := os.ReadFile(certPath); readErr == nil { | |||
| 2210 | + | logger.Debug("Certificate file content preview", | |||
| 2211 | + | "path", certPath, | |||
| 2212 | + | "size", len(data), | |||
| 2213 | + | "preview", string(data[:min(100, len(data))])) | |||
| 2214 | + | ||||
| 2215 | + | // Try to parse the certificate - this may help diagnose format issues | |||
| 2216 | + | block, _ := pem.Decode(data) | |||
| 2217 | + | if block == nil { | |||
| 2218 | + | logger.Error("Certificate file does not contain valid PEM data", | |||
| 2219 | + | "path", certPath) | |||
| 2220 | + | } else { | |||
| 2221 | + | logger.Debug("Certificate PEM block info", | |||
| 2222 | + | "type", block.Type, | |||
| 2223 | + | "headers", block.Headers) | |||
| 2224 | + | } | |||
| 2225 | + | } | |||
| 2226 | + | ||||
| 2227 | + | continue | |||
| 2228 | + | } | |||
| 2229 | + | ||||
| 2230 | + | // Success! | |||
| 2231 | + | logger.Info("Manually loaded certificate from filesystem", | |||
| 2232 | + | "domain", domain, | |||
| 2233 | + | "certPath", certPath) | |||
| 2234 | + | return &cert, nil | |||
| 2235 | + | } | |||
| 2236 | + | ||||
| 2237 | + | // Also try with glob pattern | |||
| 2238 | + | filePaths, err := filepath.Glob(filepath.Join(certDir, domain+"*")) | |||
| 2239 | + | if err != nil { | |||
| 2240 | + | return nil, fmt.Errorf("glob error: %w", err) | |||
| 2241 | + | } | |||
| 2242 | + | ||||
| 2243 | + | for _, certPath := range filePaths { | |||
| 2244 | + | // Skip if it's an RSA key | |||
| 2245 | + | if strings.HasSuffix(certPath, "+rsa") { | |||
| 2246 | + | continue | |||
| 2247 | + | } | |||
| 2248 | + | ||||
| 2249 | + | // Check if key exists | |||
| 2250 | + | keyPath := certPath + "+rsa" | |||
| 2251 | + | if _, err := os.Stat(keyPath); os.IsNotExist(err) { | |||
| 2252 | + | continue | |||
| 2253 | + | } | |||
| 2254 | + | ||||
| 2255 | + | // Try to load | |||
| 2256 | + | cert, err := tls.LoadX509KeyPair(certPath, keyPath) | |||
| 2257 | + | if err != nil { | |||
| 2258 | + | logger.Debug("Failed to load certificate from glob match", | |||
| 2259 | + | "domain", domain, | |||
| 2260 | + | "certPath", certPath, | |||
| 2261 | + | "error", err) | |||
| 2262 | + | continue | |||
| 2263 | + | } | |||
| 2264 | + | ||||
| 2265 | + | logger.Info("Manually loaded certificate from glob search", | |||
| 2266 | + | "domain", domain, | |||
| 2267 | + | "certPath", certPath) | |||
| 2268 | + | return &cert, nil | |||
| 2269 | + | } | |||
| 2270 | + | ||||
| 2271 | + | return nil, fmt.Errorf("no valid certificate found for domain %s", domain) | |||
| 2272 | + | } | |||
| 2273 | + | ||||
| 2274 | + | // min returns the smaller of x or y. | |||
| 2275 | + | func min(x, y int) int { | |||
| 2276 | + | if x < y { | |||
| 2277 | + | return x | |||
| 2278 | + | } | |||
| 2279 | + | return y | |||
| 2280 | + | } | |||
M internal/proxy/core.go +52 -39
| @@ -1,8 +1,7 @@ | |||
| 1 | 1 | package proxy | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "os" | |
| 5 | - | "strings" | |
| 4 | + | "net" | |
| 6 | 5 | "sync" | |
| 7 | 6 | "time" | |
| 8 | 7 | ||
| @@ -108,58 +107,72 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||
| 108 | 107 | httpServer := echo.New() | |
| 109 | 108 | httpServer.HideBanner = true | |
| 110 | 109 | ||
| 111 | - | // Detect and store our container ID (if we're running in a container) | |
| 112 | - | var ourContainerID string | |
| 113 | - | if docker.IsRunningInContainer() { | |
| 114 | - | // Get our hostname which should be the container ID in Docker/Podman | |
| 115 | - | hostname := os.Getenv("HOSTNAME") | |
| 116 | - | if hostname != "" { | |
| 117 | - | containers, err := docker.ListRunningContainers() | |
| 118 | - | if err == nil { | |
| 119 | - | for _, container := range containers { | |
| 120 | - | // Check if this container is us by comparing hostname with container ID | |
| 121 | - | if strings.HasPrefix(hostname, container.ID) { | |
| 122 | - | // Store our container ID for future reference | |
| 123 | - | ourContainerID = container.ID | |
| 124 | - | containerName := strings.TrimLeft(container.Names[0], "/") | |
| 125 | - | logger.Info("Gordon identity established", | |
| 126 | - | "container_id", ourContainerID, | |
| 127 | - | "container_name", containerName) | |
| 128 | - | break | |
| 129 | - | } | |
| 130 | - | } | |
| 131 | - | } | |
| 132 | - | } | |
| 110 | + | // Initialize container storage and cooldown settings | |
| 111 | + | recentContainers := make(map[string]time.Time) | |
| 112 | + | containerCooldownPeriod := time.Duration(10) * time.Second | |
| 113 | + | // Use a fixed cooldown period of 10 seconds (currently no config option for this) | |
| 114 | + | ||
| 115 | + | // Set up DB retry configuration | |
| 116 | + | dbMaxRetries := 3 | |
| 117 | + | dbRetryBaseDelay := time.Duration(500) * time.Millisecond | |
| 118 | + | ||
| 119 | + | // Initialize the reverseProxyClient with reasonable timeouts | |
| 120 | + | reverseProxyClient := &http.Client{ | |
| 121 | + | Timeout: 30 * time.Second, | |
| 122 | + | Transport: &http.Transport{ | |
| 123 | + | TLSClientConfig: &tls.Config{ | |
| 124 | + | InsecureSkipVerify: true, | |
| 125 | + | }, | |
| 126 | + | // Add more detailed timeout settings | |
| 127 | + | TLSHandshakeTimeout: 10 * time.Second, | |
| 128 | + | ResponseHeaderTimeout: 10 * time.Second, | |
| 129 | + | ExpectContinueTimeout: 5 * time.Second, | |
| 130 | + | // Add a custom dialer with explicit timeouts | |
| 131 | + | DialContext: (&net.Dialer{ | |
| 132 | + | Timeout: 10 * time.Second, | |
| 133 | + | KeepAlive: 30 * time.Second, | |
| 134 | + | DualStack: true, | |
| 135 | + | }).DialContext, | |
| 136 | + | MaxIdleConns: 100, | |
| 137 | + | MaxIdleConnsPerHost: 10, | |
| 138 | + | IdleConnTimeout: 90 * time.Second, | |
| 139 | + | ForceAttemptHTTP2: true, | |
| 140 | + | }, | |
| 133 | 141 | } | |
| 134 | 142 | ||
| 135 | - | // Initialize routes map | |
| 136 | - | routes := make(map[string]*ProxyRouteInfo) | |
| 137 | - | ||
| 138 | - | // Initialize SQL queries | |
| 143 | + | // Initialize the proxy queries | |
| 139 | 144 | proxyQueries := queries.NewProxyQueries() | |
| 140 | 145 | ||
| 141 | - | // Create the proxy | |
| 146 | + | // Create the proxy instance | |
| 142 | 147 | p := &Proxy{ | |
| 143 | 148 | config: config, | |
| 144 | 149 | app: app, | |
| 145 | 150 | httpsServer: httpsServer, | |
| 146 | 151 | httpServer: httpServer, | |
| 147 | - | routes: routes, | |
| 148 | - | serverStarted: false, | |
| 149 | - | gordonContainerID: ourContainerID, | |
| 152 | + | routes: make(map[string]*ProxyRouteInfo), | |
| 153 | + | reverseProxyClient: reverseProxyClient, | |
| 154 | + | dbMaxRetries: dbMaxRetries, | |
| 155 | + | dbRetryBaseDelay: dbRetryBaseDelay, | |
| 156 | + | recentContainers: recentContainers, | |
| 157 | + | containerCooldownPeriod: containerCooldownPeriod, | |
| 150 | 158 | shutdown: make(chan struct{}), | |
| 151 | - | recentContainers: make(map[string]time.Time), | |
| 152 | - | containerCooldownPeriod: 10 * time.Second, // Default 10 second cooldown period | |
| 153 | 159 | queries: proxyQueries, | |
| 154 | 160 | } | |
| 155 | 161 | ||
| 156 | - | // Now add the middleware with the proxy reference | |
| 157 | - | p.setupMiddleware() | |
| 158 | - | ||
| 159 | - | // Set up the certificate manager | |
| 162 | + | // Setup the certificate manager for HTTPS | |
| 160 | 163 | p.setupCertManager() | |
| 161 | 164 | ||
| 162 | - | logger.Debug("Reverse proxy initialized") | |
| 165 | + | // Verify the certificate manager was properly initialized | |
| 166 | + | if p.certManager == nil { | |
| 167 | + | logger.Error("Certificate manager initialization failed", | |
| 168 | + | "solution", "Check certificate directory permissions and configuration") | |
| 169 | + | } else { | |
| 170 | + | logger.Debug("Certificate manager successfully initialized") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // Detect our container ID for later use | |
| 174 | + | p.detectGordonContainer() | |
| 175 | + | ||
| 163 | 176 | return p, nil | |
| 164 | 177 | } | |
| 165 | 178 | ||
| @@ -1,8 +1,7 @@ | |||||
| 1 | package proxy | 1 | package proxy | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "os" | 4 | + | "net" |
| 5 | - | "strings" | |||
| 6 | "sync" | 5 | "sync" | ||
| 7 | "time" | 6 | "time" | ||
| 8 | 7 | ||||
| @@ -108,58 +107,72 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||||
| 108 | httpServer := echo.New() | 107 | httpServer := echo.New() | ||
| 109 | httpServer.HideBanner = true | 108 | httpServer.HideBanner = true | ||
| 110 | 109 | ||||
| 111 | - | // Detect and store our container ID (if we're running in a container) | 110 | + | // Initialize container storage and cooldown settings |
| 112 | - | var ourContainerID string | 111 | + | recentContainers := make(map[string]time.Time) |
| 113 | - | if docker.IsRunningInContainer() { | 112 | + | containerCooldownPeriod := time.Duration(10) * time.Second |
| 114 | - | // Get our hostname which should be the container ID in Docker/Podman | 113 | + | // Use a fixed cooldown period of 10 seconds (currently no config option for this) |
| 115 | - | hostname := os.Getenv("HOSTNAME") | 114 | + | |
| 116 | - | if hostname != "" { | 115 | + | // Set up DB retry configuration |
| 117 | - | containers, err := docker.ListRunningContainers() | 116 | + | dbMaxRetries := 3 |
| 118 | - | if err == nil { | 117 | + | dbRetryBaseDelay := time.Duration(500) * time.Millisecond |
| 119 | - | for _, container := range containers { | 118 | + | |
| 120 | - | // Check if this container is us by comparing hostname with container ID | 119 | + | // Initialize the reverseProxyClient with reasonable timeouts |
| 121 | - | if strings.HasPrefix(hostname, container.ID) { | 120 | + | reverseProxyClient := &http.Client{ |
| 122 | - | // Store our container ID for future reference | 121 | + | Timeout: 30 * time.Second, |
| 123 | - | ourContainerID = container.ID | 122 | + | Transport: &http.Transport{ |
| 124 | - | containerName := strings.TrimLeft(container.Names[0], "/") | 123 | + | TLSClientConfig: &tls.Config{ |
| 125 | - | logger.Info("Gordon identity established", | 124 | + | InsecureSkipVerify: true, |
| 126 | - | "container_id", ourContainerID, | 125 | + | }, |
| 127 | - | "container_name", containerName) | 126 | + | // Add more detailed timeout settings |
| 128 | - | break | 127 | + | TLSHandshakeTimeout: 10 * time.Second, |
| 129 | - | } | 128 | + | ResponseHeaderTimeout: 10 * time.Second, |
| 130 | - | } | 129 | + | ExpectContinueTimeout: 5 * time.Second, |
| 131 | - | } | 130 | + | // Add a custom dialer with explicit timeouts |
| 132 | - | } | 131 | + | DialContext: (&net.Dialer{ |
| 132 | + | Timeout: 10 * time.Second, | |||
| 133 | + | KeepAlive: 30 * time.Second, | |||
| 134 | + | DualStack: true, | |||
| 135 | + | }).DialContext, | |||
| 136 | + | MaxIdleConns: 100, | |||
| 137 | + | MaxIdleConnsPerHost: 10, | |||
| 138 | + | IdleConnTimeout: 90 * time.Second, | |||
| 139 | + | ForceAttemptHTTP2: true, | |||
| 140 | + | }, | |||
| 133 | } | 141 | } | ||
| 134 | 142 | ||||
| 135 | - | // Initialize routes map | 143 | + | // Initialize the proxy queries |
| 136 | - | routes := make(map[string]*ProxyRouteInfo) | |||
| 137 | - | ||||
| 138 | - | // Initialize SQL queries | |||
| 139 | proxyQueries := queries.NewProxyQueries() | 144 | proxyQueries := queries.NewProxyQueries() | ||
| 140 | 145 | ||||
| 141 | - | // Create the proxy | 146 | + | // Create the proxy instance |
| 142 | p := &Proxy{ | 147 | p := &Proxy{ | ||
| 143 | config: config, | 148 | config: config, | ||
| 144 | app: app, | 149 | app: app, | ||
| 145 | httpsServer: httpsServer, | 150 | httpsServer: httpsServer, | ||
| 146 | httpServer: httpServer, | 151 | httpServer: httpServer, | ||
| 147 | - | routes: routes, | 152 | + | routes: make(map[string]*ProxyRouteInfo), |
| 148 | - | serverStarted: false, | 153 | + | reverseProxyClient: reverseProxyClient, |
| 149 | - | gordonContainerID: ourContainerID, | 154 | + | dbMaxRetries: dbMaxRetries, |
| 155 | + | dbRetryBaseDelay: dbRetryBaseDelay, | |||
| 156 | + | recentContainers: recentContainers, | |||
| 157 | + | containerCooldownPeriod: containerCooldownPeriod, | |||
| 150 | shutdown: make(chan struct{}), | 158 | shutdown: make(chan struct{}), | ||
| 151 | - | recentContainers: make(map[string]time.Time), | |||
| 152 | - | containerCooldownPeriod: 10 * time.Second, // Default 10 second cooldown period | |||
| 153 | queries: proxyQueries, | 159 | queries: proxyQueries, | ||
| 154 | } | 160 | } | ||
| 155 | 161 | ||||
| 156 | - | // Now add the middleware with the proxy reference | 162 | + | // Setup the certificate manager for HTTPS |
| 157 | - | p.setupMiddleware() | |||
| 158 | - | ||||
| 159 | - | // Set up the certificate manager | |||
| 160 | p.setupCertManager() | 163 | p.setupCertManager() | ||
| 161 | 164 | ||||
| 162 | - | logger.Debug("Reverse proxy initialized") | 165 | + | // Verify the certificate manager was properly initialized |
| 166 | + | if p.certManager == nil { | |||
| 167 | + | logger.Error("Certificate manager initialization failed", | |||
| 168 | + | "solution", "Check certificate directory permissions and configuration") | |||
| 169 | + | } else { | |||
| 170 | + | logger.Debug("Certificate manager successfully initialized") | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // Detect our container ID for later use | |||
| 174 | + | p.detectGordonContainer() | |||
| 175 | + | ||||
| 163 | return p, nil | 176 | return p, nil | ||
| 164 | } | 177 | } | ||
| 165 | 178 | ||||
M internal/proxy/middleware.go +6 -6
| @@ -31,7 +31,7 @@ func (p *Proxy) setupMiddleware() { | |||
| 31 | 31 | p.httpServer.Use(p.createRequestIDMiddleware()) | |
| 32 | 32 | ||
| 33 | 33 | // Check if rate limiting is disabled via configuration | |
| 34 | - | if !p.config.DisableRateLimit { | |
| 34 | + | if p.config.EnableRateLimit { | |
| 35 | 35 | // Create a new Starskey rate limiter store for HTTPS server | |
| 36 | 36 | httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore( | |
| 37 | 37 | "./data/ratelimiter/https", // Path to store rate limit data | |
| @@ -98,12 +98,12 @@ func (p *Proxy) setupMiddleware() { | |||
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | 100 | // Add custom logger middleware with request ID if enabled in config | |
| 101 | - | if p.config.EnableLogs { | |
| 101 | + | if p.config.EnableHttpLogs { | |
| 102 | 102 | p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |
| 103 | 103 | 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}}` + "\n", | |
| 104 | 104 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | |
| 105 | 105 | Skipper: func(c echo.Context) bool { | |
| 106 | - | return !p.config.EnableLogs | |
| 106 | + | return !p.config.EnableHttpLogs | |
| 107 | 107 | }, | |
| 108 | 108 | })) | |
| 109 | 109 | logger.Debug("HTTP request logging enabled for HTTPS server") | |
| @@ -112,7 +112,7 @@ func (p *Proxy) setupMiddleware() { | |||
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | 114 | // Check if rate limiting is disabled via configuration | |
| 115 | - | if !p.config.DisableRateLimit { | |
| 115 | + | if p.config.EnableRateLimit { | |
| 116 | 116 | // Create a new Starskey rate limiter store for HTTP server | |
| 117 | 117 | httpRateLimiter, err := kv.NewStarskeyRateLimiterStore( | |
| 118 | 118 | "./data/ratelimiter/http", // Path to store rate limit data | |
| @@ -179,12 +179,12 @@ func (p *Proxy) setupMiddleware() { | |||
| 179 | 179 | } | |
| 180 | 180 | ||
| 181 | 181 | // Add custom logger middleware with request ID for HTTP server if enabled in config | |
| 182 | - | if p.config.EnableLogs { | |
| 182 | + | if p.config.EnableHttpLogs { | |
| 183 | 183 | p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |
| 184 | 184 | 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}}` + "\n", | |
| 185 | 185 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | |
| 186 | 186 | Skipper: func(c echo.Context) bool { | |
| 187 | - | return !p.config.EnableLogs | |
| 187 | + | return !p.config.EnableHttpLogs | |
| 188 | 188 | }, | |
| 189 | 189 | })) | |
| 190 | 190 | logger.Debug("HTTP request logging enabled for HTTP server") | |
| @@ -31,7 +31,7 @@ func (p *Proxy) setupMiddleware() { | |||||
| 31 | p.httpServer.Use(p.createRequestIDMiddleware()) | 31 | p.httpServer.Use(p.createRequestIDMiddleware()) | ||
| 32 | 32 | ||||
| 33 | // Check if rate limiting is disabled via configuration | 33 | // Check if rate limiting is disabled via configuration | ||
| 34 | - | if !p.config.DisableRateLimit { | 34 | + | if p.config.EnableRateLimit { |
| 35 | // Create a new Starskey rate limiter store for HTTPS server | 35 | // Create a new Starskey rate limiter store for HTTPS server | ||
| 36 | httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore( | 36 | httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore( | ||
| 37 | "./data/ratelimiter/https", // Path to store rate limit data | 37 | "./data/ratelimiter/https", // Path to store rate limit data | ||
| @@ -98,12 +98,12 @@ func (p *Proxy) setupMiddleware() { | |||||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | // Add custom logger middleware with request ID if enabled in config | 100 | // Add custom logger middleware with request ID if enabled in config | ||
| 101 | - | if p.config.EnableLogs { | 101 | + | if p.config.EnableHttpLogs { |
| 102 | p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | 102 | p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | ||
| 103 | 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}}` + "\n", | 103 | 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}}` + "\n", | ||
| 104 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | 104 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | ||
| 105 | Skipper: func(c echo.Context) bool { | 105 | Skipper: func(c echo.Context) bool { | ||
| 106 | - | return !p.config.EnableLogs | 106 | + | return !p.config.EnableHttpLogs |
| 107 | }, | 107 | }, | ||
| 108 | })) | 108 | })) | ||
| 109 | logger.Debug("HTTP request logging enabled for HTTPS server") | 109 | logger.Debug("HTTP request logging enabled for HTTPS server") | ||
| @@ -112,7 +112,7 @@ func (p *Proxy) setupMiddleware() { | |||||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | // Check if rate limiting is disabled via configuration | 114 | // Check if rate limiting is disabled via configuration | ||
| 115 | - | if !p.config.DisableRateLimit { | 115 | + | if p.config.EnableRateLimit { |
| 116 | // Create a new Starskey rate limiter store for HTTP server | 116 | // Create a new Starskey rate limiter store for HTTP server | ||
| 117 | httpRateLimiter, err := kv.NewStarskeyRateLimiterStore( | 117 | httpRateLimiter, err := kv.NewStarskeyRateLimiterStore( | ||
| 118 | "./data/ratelimiter/http", // Path to store rate limit data | 118 | "./data/ratelimiter/http", // Path to store rate limit data | ||
| @@ -179,12 +179,12 @@ func (p *Proxy) setupMiddleware() { | |||||
| 179 | } | 179 | } | ||
| 180 | 180 | ||||
| 181 | // Add custom logger middleware with request ID for HTTP server if enabled in config | 181 | // Add custom logger middleware with request ID for HTTP server if enabled in config | ||
| 182 | - | if p.config.EnableLogs { | 182 | + | if p.config.EnableHttpLogs { |
| 183 | p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | 183 | p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | ||
| 184 | 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}}` + "\n", | 184 | 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}}` + "\n", | ||
| 185 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | 185 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | ||
| 186 | Skipper: func(c echo.Context) bool { | 186 | Skipper: func(c echo.Context) bool { | ||
| 187 | - | return !p.config.EnableLogs | 187 | + | return !p.config.EnableHttpLogs |
| 188 | }, | 188 | }, | ||
| 189 | })) | 189 | })) | ||
| 190 | logger.Debug("HTTP request logging enabled for HTTP server") | 190 | logger.Debug("HTTP request logging enabled for HTTP server") | ||
M internal/proxy/routes.go +148 -71
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package proxy | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "crypto/tls" | |
| 4 | 5 | "database/sql" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "net" | |
| @@ -13,6 +14,7 @@ import ( | |||
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/gordon/pkg/docker" | |
| 15 | 16 | "github.com/bnema/gordon/pkg/logger" | |
| 17 | + | "github.com/google/uuid" | |
| 16 | 18 | "github.com/labstack/echo/v4" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| @@ -157,78 +159,14 @@ func (p *Proxy) configureRoutes() { | |||
| 157 | 159 | logger.Debug("Admin domain route exists", "domain", adminDomain) | |
| 158 | 160 | } | |
| 159 | 161 | ||
| 160 | - | // Handler function - will be assigned to both HTTP and HTTPS servers | |
| 161 | - | handler := echo.HandlerFunc(func(c echo.Context) error { | |
| 162 | - | host := c.Request().Host | |
| 163 | - | ||
| 164 | - | // Strip port if present in the host | |
| 165 | - | if hostParts := strings.Split(host, ":"); len(hostParts) > 1 { | |
| 166 | - | host = hostParts[0] | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | // Normalize host (trim trailing dot, convert to lowercase) | |
| 170 | - | host = strings.TrimSuffix(strings.ToLower(host), ".") | |
| 171 | - | ||
| 172 | - | // Check if this is the admin domain | |
| 173 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 174 | - | if strings.EqualFold(host, adminDomain) { | |
| 175 | - | // Always prioritize admin domain | |
| 176 | - | p.mu.RLock() | |
| 177 | - | adminRoute, adminOk := p.routes[adminDomain] | |
| 178 | - | p.mu.RUnlock() | |
| 179 | - | ||
| 180 | - | if adminOk { | |
| 181 | - | return p.proxyRequest(c, adminRoute) | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | // Important: Get the route freshly from the map for every request | |
| 186 | - | // This ensures we always use the most up-to-date IP | |
| 187 | - | p.mu.RLock() | |
| 188 | - | route, ok := p.routes[host] | |
| 189 | - | p.mu.RUnlock() | |
| 190 | - | ||
| 191 | - | if !ok { | |
| 192 | - | // Check if the host is an IP address - silently handle without logging warnings | |
| 193 | - | if net.ParseIP(host) != nil { | |
| 194 | - | // For IP-based requests, just return a 404 without logging warnings | |
| 195 | - | return c.String(http.StatusNotFound, "Domain not found") | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | // Create list of available domains for debugging | |
| 199 | - | availableDomains := make([]string, 0, len(p.routes)) | |
| 200 | - | p.mu.RLock() | |
| 201 | - | for d := range p.routes { | |
| 202 | - | availableDomains = append(availableDomains, d) | |
| 203 | - | } | |
| 204 | - | p.mu.RUnlock() | |
| 205 | - | ||
| 206 | - | // Log warning for non-IP hosts that aren't configured | |
| 207 | - | logger.Warn("Request with unknown host", | |
| 208 | - | "requested_host", host, | |
| 209 | - | "client_ip", c.RealIP(), | |
| 210 | - | "available_domains", strings.Join(availableDomains, ", ")) | |
| 211 | - | return c.String(http.StatusNotFound, "Domain not found") | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | // Check if the route is active | |
| 215 | - | if !route.Active { | |
| 216 | - | logger.Warn("Route is not active", | |
| 217 | - | "domain", host, | |
| 218 | - | "client_ip", c.RealIP()) | |
| 219 | - | return c.String(http.StatusServiceUnavailable, "Route is not active") | |
| 220 | - | } | |
| 221 | - | return p.proxyRequest(c, route) | |
| 222 | - | }) | |
| 223 | - | ||
| 224 | 162 | // Add the handler to the HTTPS server | |
| 225 | 163 | if p.httpsServer != nil { | |
| 226 | - | p.httpsServer.Any("/*", handler) | |
| 164 | + | p.httpsServer.Any("/*", p.proxyRequest) | |
| 227 | 165 | } | |
| 228 | 166 | ||
| 229 | 167 | // Also add the handler to the HTTP server for HTTP-01 challenges | |
| 230 | 168 | if p.httpServer != nil { | |
| 231 | - | p.httpServer.Any("/*", handler) | |
| 169 | + | p.httpServer.Any("/*", p.proxyRequest) | |
| 232 | 170 | } | |
| 233 | 171 | } | |
| 234 | 172 | ||
| @@ -308,7 +246,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 308 | 246 | if err != nil { | |
| 309 | 247 | if err == sql.ErrNoRows { | |
| 310 | 248 | // Domain doesn't exist, create it | |
| 311 | - | domainID = proxyGenerateUUID() | |
| 249 | + | domainID = uuid.New().String() | |
| 312 | 250 | now := time.Now().Format(time.RFC3339) | |
| 313 | 251 | _, err = txExecWithRetry(tx, | |
| 314 | 252 | p.queries.InsertDomain, | |
| @@ -338,7 +276,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 338 | 276 | if err != nil { | |
| 339 | 277 | if err == sql.ErrNoRows { | |
| 340 | 278 | // Domain part doesn't exist, create it | |
| 341 | - | domainPartID = proxyGenerateUUID() | |
| 279 | + | domainPartID = uuid.New().String() | |
| 342 | 280 | _, err = txExecWithRetry(tx, | |
| 343 | 281 | p.queries.InsertDomain, | |
| 344 | 282 | domainPartID, domainPart, accountID, now, now, | |
| @@ -408,7 +346,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 408 | 346 | } | |
| 409 | 347 | } else if err == sql.ErrNoRows { | |
| 410 | 348 | // Route doesn't exist, create it | |
| 411 | - | routeID := proxyGenerateUUID() | |
| 349 | + | routeID := uuid.New().String() | |
| 412 | 350 | now := time.Now().Format(time.RFC3339) | |
| 413 | 351 | _, err = txExecWithRetry(tx, | |
| 414 | 352 | p.queries.InsertRoute, | |
| @@ -1835,13 +1773,76 @@ func (p *Proxy) detectGordonContainer() string { | |||
| 1835 | 1773 | } | |
| 1836 | 1774 | ||
| 1837 | 1775 | // proxyRequest proxies an HTTP request to a container | |
| 1838 | - | func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |
| 1776 | + | func (p *Proxy) proxyRequest(c echo.Context) error { | |
| 1777 | + | host := c.Request().Host | |
| 1778 | + | ||
| 1779 | + | // Strip port if present in the host | |
| 1780 | + | if hostParts := strings.Split(host, ":"); len(hostParts) > 1 { | |
| 1781 | + | host = hostParts[0] | |
| 1782 | + | } | |
| 1783 | + | ||
| 1784 | + | // Normalize host (trim trailing dot, convert to lowercase) | |
| 1785 | + | host = strings.TrimSuffix(strings.ToLower(host), ".") | |
| 1786 | + | ||
| 1787 | + | // Debug log the incoming request | |
| 1788 | + | logger.Debug("Proxy request received", | |
| 1789 | + | "host", host, | |
| 1790 | + | "method", c.Request().Method, | |
| 1791 | + | "path", c.Request().URL.Path, | |
| 1792 | + | "client_ip", c.RealIP()) | |
| 1793 | + | ||
| 1794 | + | // Get the route for this host | |
| 1795 | + | p.mu.RLock() | |
| 1796 | + | route, ok := p.routes[host] | |
| 1797 | + | p.mu.RUnlock() | |
| 1798 | + | ||
| 1799 | + | if !ok { | |
| 1800 | + | // Check if the host is an IP address | |
| 1801 | + | if net.ParseIP(host) != nil { | |
| 1802 | + | // For IP-based requests, just return a 404 without logging warnings | |
| 1803 | + | return c.String(http.StatusNotFound, "Domain not found") | |
| 1804 | + | } | |
| 1805 | + | ||
| 1806 | + | // Log available domains for debugging | |
| 1807 | + | availableDomains := make([]string, 0, len(p.routes)) | |
| 1808 | + | p.mu.RLock() | |
| 1809 | + | for d := range p.routes { | |
| 1810 | + | availableDomains = append(availableDomains, d) | |
| 1811 | + | } | |
| 1812 | + | p.mu.RUnlock() | |
| 1813 | + | ||
| 1814 | + | logger.Warn("Request with unknown host", | |
| 1815 | + | "requested_host", host, | |
| 1816 | + | "client_ip", c.RealIP(), | |
| 1817 | + | "available_domains", strings.Join(availableDomains, ", ")) | |
| 1818 | + | return c.String(http.StatusNotFound, "Domain not found") | |
| 1819 | + | } | |
| 1820 | + | ||
| 1821 | + | // Check if the route is active | |
| 1822 | + | if !route.Active { | |
| 1823 | + | logger.Warn("Route is not active", | |
| 1824 | + | "domain", host, | |
| 1825 | + | "client_ip", c.RealIP()) | |
| 1826 | + | return c.String(http.StatusServiceUnavailable, "Route is not active") | |
| 1827 | + | } | |
| 1828 | + | ||
| 1829 | + | return p.doProxyRequest(c, route) | |
| 1830 | + | } | |
| 1831 | + | ||
| 1832 | + | func (p *Proxy) doProxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |
| 1839 | 1833 | host := c.Request().Host | |
| 1840 | 1834 | containerID := route.ContainerID | |
| 1841 | 1835 | containerIP := route.ContainerIP | |
| 1842 | 1836 | containerPort := route.ContainerPort | |
| 1843 | 1837 | targetProtocol := route.Protocol | |
| 1844 | 1838 | ||
| 1839 | + | logger.Debug("Starting proxy request", | |
| 1840 | + | "host", host, | |
| 1841 | + | "container_id", containerID, | |
| 1842 | + | "container_ip", containerIP, | |
| 1843 | + | "container_port", containerPort, | |
| 1844 | + | "protocol", targetProtocol) | |
| 1845 | + | ||
| 1845 | 1846 | // Check if this is the admin domain | |
| 1846 | 1847 | isAdminDomain := host == p.app.GetConfig().Http.FullDomain() | |
| 1847 | 1848 | ||
| @@ -1879,7 +1880,7 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1879 | 1880 | ||
| 1880 | 1881 | // Create the target URL | |
| 1881 | 1882 | targetURL := &url.URL{ | |
| 1882 | - | Scheme: targetProtocol, | |
| 1883 | + | Scheme: "http", // Always use HTTP internally, regardless of external protocol | |
| 1883 | 1884 | } | |
| 1884 | 1885 | ||
| 1885 | 1886 | // Format host properly for both IPv4 and IPv6 | |
| @@ -1889,12 +1890,37 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1889 | 1890 | } | |
| 1890 | 1891 | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | |
| 1891 | 1892 | ||
| 1893 | + | logger.Debug("Proxying to backend", | |
| 1894 | + | "target_url", targetURL.String(), | |
| 1895 | + | "original_host", host) | |
| 1896 | + | ||
| 1892 | 1897 | // Create a reverse proxy | |
| 1893 | 1898 | proxy := httputil.NewSingleHostReverseProxy(targetURL) | |
| 1894 | 1899 | ||
| 1895 | 1900 | // Use our custom transport with longer timeouts | |
| 1896 | 1901 | if p.reverseProxyClient != nil { | |
| 1897 | 1902 | proxy.Transport = p.reverseProxyClient.Transport | |
| 1903 | + | } else { | |
| 1904 | + | // Create a transport with reasonable timeouts if none exists | |
| 1905 | + | proxy.Transport = &http.Transport{ | |
| 1906 | + | TLSClientConfig: &tls.Config{ | |
| 1907 | + | InsecureSkipVerify: true, | |
| 1908 | + | }, | |
| 1909 | + | // Use shorter timeouts for internal network | |
| 1910 | + | TLSHandshakeTimeout: 5 * time.Second, | |
| 1911 | + | ResponseHeaderTimeout: 10 * time.Second, | |
| 1912 | + | ExpectContinueTimeout: 3 * time.Second, | |
| 1913 | + | // Add a custom dialer with explicit timeouts | |
| 1914 | + | DialContext: (&net.Dialer{ | |
| 1915 | + | Timeout: 5 * time.Second, | |
| 1916 | + | KeepAlive: 30 * time.Second, | |
| 1917 | + | DualStack: true, | |
| 1918 | + | }).DialContext, | |
| 1919 | + | MaxIdleConns: 100, | |
| 1920 | + | MaxIdleConnsPerHost: 10, | |
| 1921 | + | IdleConnTimeout: 90 * time.Second, | |
| 1922 | + | ForceAttemptHTTP2: true, | |
| 1923 | + | } | |
| 1898 | 1924 | } | |
| 1899 | 1925 | ||
| 1900 | 1926 | // Set up request director to modify the request before it's sent | |
| @@ -1917,6 +1943,11 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1917 | 1943 | ||
| 1918 | 1944 | // Set X-Forwarded-Host to the original host | |
| 1919 | 1945 | req.Header.Set("X-Forwarded-Host", host) | |
| 1946 | + | ||
| 1947 | + | logger.Debug("Modified outgoing proxy request", | |
| 1948 | + | "req_host", req.Host, | |
| 1949 | + | "req_url", req.URL.String(), | |
| 1950 | + | "x_forwarded_proto", req.Header.Get("X-Forwarded-Proto")) | |
| 1920 | 1951 | } | |
| 1921 | 1952 | ||
| 1922 | 1953 | // Log errors that occur during proxying | |
| @@ -1958,8 +1989,54 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1958 | 1989 | c.Response().Write([]byte("Bad Gateway: Container is unreachable")) | |
| 1959 | 1990 | } | |
| 1960 | 1991 | ||
| 1992 | + | // For admin domain, ensure the connection succeeds | |
| 1993 | + | if isAdminDomain { | |
| 1994 | + | // Do a pre-check to make sure the admin service is reachable | |
| 1995 | + | testURL := fmt.Sprintf("http://%s:%s/admin/ping", containerIP, containerPort) | |
| 1996 | + | client := &http.Client{ | |
| 1997 | + | Timeout: 2 * time.Second, | |
| 1998 | + | Transport: &http.Transport{ | |
| 1999 | + | DialContext: (&net.Dialer{ | |
| 2000 | + | Timeout: 1 * time.Second, | |
| 2001 | + | }).DialContext, | |
| 2002 | + | }, | |
| 2003 | + | } | |
| 2004 | + | ||
| 2005 | + | logger.Debug("Testing admin connection before proxying", | |
| 2006 | + | "test_url", testURL) | |
| 2007 | + | ||
| 2008 | + | _, err := client.Get(testURL) | |
| 2009 | + | if err != nil { | |
| 2010 | + | logger.Warn("Admin service pre-check failed, trying alternative connection methods", | |
| 2011 | + | "error", err) | |
| 2012 | + | ||
| 2013 | + | // Try alternative IPs for the admin domain if the container IP isn't working | |
| 2014 | + | altIPs := []string{"localhost", "127.0.0.1", p.app.GetConfig().Http.SubDomain} | |
| 2015 | + | ||
| 2016 | + | for _, altIP := range altIPs { | |
| 2017 | + | testURL = fmt.Sprintf("http://%s:%s/admin/ping", altIP, containerPort) | |
| 2018 | + | _, err := client.Get(testURL) | |
| 2019 | + | if err == nil { | |
| 2020 | + | logger.Info("Found working admin connection with alternative IP", | |
| 2021 | + | "ip", altIP) | |
| 2022 | + | containerIP = altIP | |
| 2023 | + | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | |
| 2024 | + | break | |
| 2025 | + | } | |
| 2026 | + | } | |
| 2027 | + | } | |
| 2028 | + | } | |
| 2029 | + | ||
| 1961 | 2030 | // Serve the request using the reverse proxy | |
| 2031 | + | logger.Debug("Serving proxy request", | |
| 2032 | + | "target", targetURL.String()) | |
| 1962 | 2033 | proxy.ServeHTTP(c.Response(), c.Request()) | |
| 2034 | + | ||
| 2035 | + | logger.Debug("Proxy request completed", | |
| 2036 | + | "host", host, | |
| 2037 | + | "target", targetURL.String(), | |
| 2038 | + | "status", c.Response().Status) | |
| 2039 | + | ||
| 1963 | 2040 | return nil | |
| 1964 | 2041 | } | |
| 1965 | 2042 | ||
| @@ -1,6 +1,7 @@ | |||||
| 1 | package proxy | 1 | package proxy | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "crypto/tls" | |||
| 4 | "database/sql" | 5 | "database/sql" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "net" | 7 | "net" | ||
| @@ -13,6 +14,7 @@ import ( | |||||
| 13 | 14 | ||||
| 14 | "github.com/bnema/gordon/pkg/docker" | 15 | "github.com/bnema/gordon/pkg/docker" | ||
| 15 | "github.com/bnema/gordon/pkg/logger" | 16 | "github.com/bnema/gordon/pkg/logger" | ||
| 17 | + | "github.com/google/uuid" | |||
| 16 | "github.com/labstack/echo/v4" | 18 | "github.com/labstack/echo/v4" | ||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| @@ -157,78 +159,14 @@ func (p *Proxy) configureRoutes() { | |||||
| 157 | logger.Debug("Admin domain route exists", "domain", adminDomain) | 159 | logger.Debug("Admin domain route exists", "domain", adminDomain) | ||
| 158 | } | 160 | } | ||
| 159 | 161 | ||||
| 160 | - | // Handler function - will be assigned to both HTTP and HTTPS servers | |||
| 161 | - | handler := echo.HandlerFunc(func(c echo.Context) error { | |||
| 162 | - | host := c.Request().Host | |||
| 163 | - | ||||
| 164 | - | // Strip port if present in the host | |||
| 165 | - | if hostParts := strings.Split(host, ":"); len(hostParts) > 1 { | |||
| 166 | - | host = hostParts[0] | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | - | // Normalize host (trim trailing dot, convert to lowercase) | |||
| 170 | - | host = strings.TrimSuffix(strings.ToLower(host), ".") | |||
| 171 | - | ||||
| 172 | - | // Check if this is the admin domain | |||
| 173 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 174 | - | if strings.EqualFold(host, adminDomain) { | |||
| 175 | - | // Always prioritize admin domain | |||
| 176 | - | p.mu.RLock() | |||
| 177 | - | adminRoute, adminOk := p.routes[adminDomain] | |||
| 178 | - | p.mu.RUnlock() | |||
| 179 | - | ||||
| 180 | - | if adminOk { | |||
| 181 | - | return p.proxyRequest(c, adminRoute) | |||
| 182 | - | } | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | // Important: Get the route freshly from the map for every request | |||
| 186 | - | // This ensures we always use the most up-to-date IP | |||
| 187 | - | p.mu.RLock() | |||
| 188 | - | route, ok := p.routes[host] | |||
| 189 | - | p.mu.RUnlock() | |||
| 190 | - | ||||
| 191 | - | if !ok { | |||
| 192 | - | // Check if the host is an IP address - silently handle without logging warnings | |||
| 193 | - | if net.ParseIP(host) != nil { | |||
| 194 | - | // For IP-based requests, just return a 404 without logging warnings | |||
| 195 | - | return c.String(http.StatusNotFound, "Domain not found") | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | // Create list of available domains for debugging | |||
| 199 | - | availableDomains := make([]string, 0, len(p.routes)) | |||
| 200 | - | p.mu.RLock() | |||
| 201 | - | for d := range p.routes { | |||
| 202 | - | availableDomains = append(availableDomains, d) | |||
| 203 | - | } | |||
| 204 | - | p.mu.RUnlock() | |||
| 205 | - | ||||
| 206 | - | // Log warning for non-IP hosts that aren't configured | |||
| 207 | - | logger.Warn("Request with unknown host", | |||
| 208 | - | "requested_host", host, | |||
| 209 | - | "client_ip", c.RealIP(), | |||
| 210 | - | "available_domains", strings.Join(availableDomains, ", ")) | |||
| 211 | - | return c.String(http.StatusNotFound, "Domain not found") | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | // Check if the route is active | |||
| 215 | - | if !route.Active { | |||
| 216 | - | logger.Warn("Route is not active", | |||
| 217 | - | "domain", host, | |||
| 218 | - | "client_ip", c.RealIP()) | |||
| 219 | - | return c.String(http.StatusServiceUnavailable, "Route is not active") | |||
| 220 | - | } | |||
| 221 | - | return p.proxyRequest(c, route) | |||
| 222 | - | }) | |||
| 223 | - | ||||
| 224 | // Add the handler to the HTTPS server | 162 | // Add the handler to the HTTPS server | ||
| 225 | if p.httpsServer != nil { | 163 | if p.httpsServer != nil { | ||
| 226 | - | p.httpsServer.Any("/*", handler) | 164 | + | p.httpsServer.Any("/*", p.proxyRequest) |
| 227 | } | 165 | } | ||
| 228 | 166 | ||||
| 229 | // Also add the handler to the HTTP server for HTTP-01 challenges | 167 | // Also add the handler to the HTTP server for HTTP-01 challenges | ||
| 230 | if p.httpServer != nil { | 168 | if p.httpServer != nil { | ||
| 231 | - | p.httpServer.Any("/*", handler) | 169 | + | p.httpServer.Any("/*", p.proxyRequest) |
| 232 | } | 170 | } | ||
| 233 | } | 171 | } | ||
| 234 | 172 | ||||
| @@ -308,7 +246,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 308 | if err != nil { | 246 | if err != nil { | ||
| 309 | if err == sql.ErrNoRows { | 247 | if err == sql.ErrNoRows { | ||
| 310 | // Domain doesn't exist, create it | 248 | // Domain doesn't exist, create it | ||
| 311 | - | domainID = proxyGenerateUUID() | 249 | + | domainID = uuid.New().String() |
| 312 | now := time.Now().Format(time.RFC3339) | 250 | now := time.Now().Format(time.RFC3339) | ||
| 313 | _, err = txExecWithRetry(tx, | 251 | _, err = txExecWithRetry(tx, | ||
| 314 | p.queries.InsertDomain, | 252 | p.queries.InsertDomain, | ||
| @@ -338,7 +276,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 338 | if err != nil { | 276 | if err != nil { | ||
| 339 | if err == sql.ErrNoRows { | 277 | if err == sql.ErrNoRows { | ||
| 340 | // Domain part doesn't exist, create it | 278 | // Domain part doesn't exist, create it | ||
| 341 | - | domainPartID = proxyGenerateUUID() | 279 | + | domainPartID = uuid.New().String() |
| 342 | _, err = txExecWithRetry(tx, | 280 | _, err = txExecWithRetry(tx, | ||
| 343 | p.queries.InsertDomain, | 281 | p.queries.InsertDomain, | ||
| 344 | domainPartID, domainPart, accountID, now, now, | 282 | domainPartID, domainPart, accountID, now, now, | ||
| @@ -408,7 +346,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 408 | } | 346 | } | ||
| 409 | } else if err == sql.ErrNoRows { | 347 | } else if err == sql.ErrNoRows { | ||
| 410 | // Route doesn't exist, create it | 348 | // Route doesn't exist, create it | ||
| 411 | - | routeID := proxyGenerateUUID() | 349 | + | routeID := uuid.New().String() |
| 412 | now := time.Now().Format(time.RFC3339) | 350 | now := time.Now().Format(time.RFC3339) | ||
| 413 | _, err = txExecWithRetry(tx, | 351 | _, err = txExecWithRetry(tx, | ||
| 414 | p.queries.InsertRoute, | 352 | p.queries.InsertRoute, | ||
| @@ -1835,13 +1773,76 @@ func (p *Proxy) detectGordonContainer() string { | |||||
| 1835 | } | 1773 | } | ||
| 1836 | 1774 | ||||
| 1837 | // proxyRequest proxies an HTTP request to a container | 1775 | // proxyRequest proxies an HTTP request to a container | ||
| 1838 | - | func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | 1776 | + | func (p *Proxy) proxyRequest(c echo.Context) error { |
| 1777 | + | host := c.Request().Host | |||
| 1778 | + | ||||
| 1779 | + | // Strip port if present in the host | |||
| 1780 | + | if hostParts := strings.Split(host, ":"); len(hostParts) > 1 { | |||
| 1781 | + | host = hostParts[0] | |||
| 1782 | + | } | |||
| 1783 | + | ||||
| 1784 | + | // Normalize host (trim trailing dot, convert to lowercase) | |||
| 1785 | + | host = strings.TrimSuffix(strings.ToLower(host), ".") | |||
| 1786 | + | ||||
| 1787 | + | // Debug log the incoming request | |||
| 1788 | + | logger.Debug("Proxy request received", | |||
| 1789 | + | "host", host, | |||
| 1790 | + | "method", c.Request().Method, | |||
| 1791 | + | "path", c.Request().URL.Path, | |||
| 1792 | + | "client_ip", c.RealIP()) | |||
| 1793 | + | ||||
| 1794 | + | // Get the route for this host | |||
| 1795 | + | p.mu.RLock() | |||
| 1796 | + | route, ok := p.routes[host] | |||
| 1797 | + | p.mu.RUnlock() | |||
| 1798 | + | ||||
| 1799 | + | if !ok { | |||
| 1800 | + | // Check if the host is an IP address | |||
| 1801 | + | if net.ParseIP(host) != nil { | |||
| 1802 | + | // For IP-based requests, just return a 404 without logging warnings | |||
| 1803 | + | return c.String(http.StatusNotFound, "Domain not found") | |||
| 1804 | + | } | |||
| 1805 | + | ||||
| 1806 | + | // Log available domains for debugging | |||
| 1807 | + | availableDomains := make([]string, 0, len(p.routes)) | |||
| 1808 | + | p.mu.RLock() | |||
| 1809 | + | for d := range p.routes { | |||
| 1810 | + | availableDomains = append(availableDomains, d) | |||
| 1811 | + | } | |||
| 1812 | + | p.mu.RUnlock() | |||
| 1813 | + | ||||
| 1814 | + | logger.Warn("Request with unknown host", | |||
| 1815 | + | "requested_host", host, | |||
| 1816 | + | "client_ip", c.RealIP(), | |||
| 1817 | + | "available_domains", strings.Join(availableDomains, ", ")) | |||
| 1818 | + | return c.String(http.StatusNotFound, "Domain not found") | |||
| 1819 | + | } | |||
| 1820 | + | ||||
| 1821 | + | // Check if the route is active | |||
| 1822 | + | if !route.Active { | |||
| 1823 | + | logger.Warn("Route is not active", | |||
| 1824 | + | "domain", host, | |||
| 1825 | + | "client_ip", c.RealIP()) | |||
| 1826 | + | return c.String(http.StatusServiceUnavailable, "Route is not active") | |||
| 1827 | + | } | |||
| 1828 | + | ||||
| 1829 | + | return p.doProxyRequest(c, route) | |||
| 1830 | + | } | |||
| 1831 | + | ||||
| 1832 | + | func (p *Proxy) doProxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1839 | host := c.Request().Host | 1833 | host := c.Request().Host | ||
| 1840 | containerID := route.ContainerID | 1834 | containerID := route.ContainerID | ||
| 1841 | containerIP := route.ContainerIP | 1835 | containerIP := route.ContainerIP | ||
| 1842 | containerPort := route.ContainerPort | 1836 | containerPort := route.ContainerPort | ||
| 1843 | targetProtocol := route.Protocol | 1837 | targetProtocol := route.Protocol | ||
| 1844 | 1838 | ||||
| 1839 | + | logger.Debug("Starting proxy request", | |||
| 1840 | + | "host", host, | |||
| 1841 | + | "container_id", containerID, | |||
| 1842 | + | "container_ip", containerIP, | |||
| 1843 | + | "container_port", containerPort, | |||
| 1844 | + | "protocol", targetProtocol) | |||
| 1845 | + | ||||
| 1845 | // Check if this is the admin domain | 1846 | // Check if this is the admin domain | ||
| 1846 | isAdminDomain := host == p.app.GetConfig().Http.FullDomain() | 1847 | isAdminDomain := host == p.app.GetConfig().Http.FullDomain() | ||
| 1847 | 1848 | ||||
| @@ -1879,7 +1880,7 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||||
| 1879 | 1880 | ||||
| 1880 | // Create the target URL | 1881 | // Create the target URL | ||
| 1881 | targetURL := &url.URL{ | 1882 | targetURL := &url.URL{ | ||
| 1882 | - | Scheme: targetProtocol, | 1883 | + | Scheme: "http", // Always use HTTP internally, regardless of external protocol |
| 1883 | } | 1884 | } | ||
| 1884 | 1885 | ||||
| 1885 | // Format host properly for both IPv4 and IPv6 | 1886 | // Format host properly for both IPv4 and IPv6 | ||
| @@ -1889,12 +1890,37 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||||
| 1889 | } | 1890 | } | ||
| 1890 | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | 1891 | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | ||
| 1891 | 1892 | ||||
| 1893 | + | logger.Debug("Proxying to backend", | |||
| 1894 | + | "target_url", targetURL.String(), | |||
| 1895 | + | "original_host", host) | |||
| 1896 | + | ||||
| 1892 | // Create a reverse proxy | 1897 | // Create a reverse proxy | ||
| 1893 | proxy := httputil.NewSingleHostReverseProxy(targetURL) | 1898 | proxy := httputil.NewSingleHostReverseProxy(targetURL) | ||
| 1894 | 1899 | ||||
| 1895 | // Use our custom transport with longer timeouts | 1900 | // Use our custom transport with longer timeouts | ||
| 1896 | if p.reverseProxyClient != nil { | 1901 | if p.reverseProxyClient != nil { | ||
| 1897 | proxy.Transport = p.reverseProxyClient.Transport | 1902 | proxy.Transport = p.reverseProxyClient.Transport | ||
| 1903 | + | } else { | |||
| 1904 | + | // Create a transport with reasonable timeouts if none exists | |||
| 1905 | + | proxy.Transport = &http.Transport{ | |||
| 1906 | + | TLSClientConfig: &tls.Config{ | |||
| 1907 | + | InsecureSkipVerify: true, | |||
| 1908 | + | }, | |||
| 1909 | + | // Use shorter timeouts for internal network | |||
| 1910 | + | TLSHandshakeTimeout: 5 * time.Second, | |||
| 1911 | + | ResponseHeaderTimeout: 10 * time.Second, | |||
| 1912 | + | ExpectContinueTimeout: 3 * time.Second, | |||
| 1913 | + | // Add a custom dialer with explicit timeouts | |||
| 1914 | + | DialContext: (&net.Dialer{ | |||
| 1915 | + | Timeout: 5 * time.Second, | |||
| 1916 | + | KeepAlive: 30 * time.Second, | |||
| 1917 | + | DualStack: true, | |||
| 1918 | + | }).DialContext, | |||
| 1919 | + | MaxIdleConns: 100, | |||
| 1920 | + | MaxIdleConnsPerHost: 10, | |||
| 1921 | + | IdleConnTimeout: 90 * time.Second, | |||
| 1922 | + | ForceAttemptHTTP2: true, | |||
| 1923 | + | } | |||
| 1898 | } | 1924 | } | ||
| 1899 | 1925 | ||||
| 1900 | // Set up request director to modify the request before it's sent | 1926 | // Set up request director to modify the request before it's sent | ||
| @@ -1917,6 +1943,11 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||||
| 1917 | 1943 | ||||
| 1918 | // Set X-Forwarded-Host to the original host | 1944 | // Set X-Forwarded-Host to the original host | ||
| 1919 | req.Header.Set("X-Forwarded-Host", host) | 1945 | req.Header.Set("X-Forwarded-Host", host) | ||
| 1946 | + | ||||
| 1947 | + | logger.Debug("Modified outgoing proxy request", | |||
| 1948 | + | "req_host", req.Host, | |||
| 1949 | + | "req_url", req.URL.String(), | |||
| 1950 | + | "x_forwarded_proto", req.Header.Get("X-Forwarded-Proto")) | |||
| 1920 | } | 1951 | } | ||
| 1921 | 1952 | ||||
| 1922 | // Log errors that occur during proxying | 1953 | // Log errors that occur during proxying | ||
| @@ -1958,8 +1989,54 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||||
| 1958 | c.Response().Write([]byte("Bad Gateway: Container is unreachable")) | 1989 | c.Response().Write([]byte("Bad Gateway: Container is unreachable")) | ||
| 1959 | } | 1990 | } | ||
| 1960 | 1991 | ||||
| 1992 | + | // For admin domain, ensure the connection succeeds | |||
| 1993 | + | if isAdminDomain { | |||
| 1994 | + | // Do a pre-check to make sure the admin service is reachable | |||
| 1995 | + | testURL := fmt.Sprintf("http://%s:%s/admin/ping", containerIP, containerPort) | |||
| 1996 | + | client := &http.Client{ | |||
| 1997 | + | Timeout: 2 * time.Second, | |||
| 1998 | + | Transport: &http.Transport{ | |||
| 1999 | + | DialContext: (&net.Dialer{ | |||
| 2000 | + | Timeout: 1 * time.Second, | |||
| 2001 | + | }).DialContext, | |||
| 2002 | + | }, | |||
| 2003 | + | } | |||
| 2004 | + | ||||
| 2005 | + | logger.Debug("Testing admin connection before proxying", | |||
| 2006 | + | "test_url", testURL) | |||
| 2007 | + | ||||
| 2008 | + | _, err := client.Get(testURL) | |||
| 2009 | + | if err != nil { | |||
| 2010 | + | logger.Warn("Admin service pre-check failed, trying alternative connection methods", | |||
| 2011 | + | "error", err) | |||
| 2012 | + | ||||
| 2013 | + | // Try alternative IPs for the admin domain if the container IP isn't working | |||
| 2014 | + | altIPs := []string{"localhost", "127.0.0.1", p.app.GetConfig().Http.SubDomain} | |||
| 2015 | + | ||||
| 2016 | + | for _, altIP := range altIPs { | |||
| 2017 | + | testURL = fmt.Sprintf("http://%s:%s/admin/ping", altIP, containerPort) | |||
| 2018 | + | _, err := client.Get(testURL) | |||
| 2019 | + | if err == nil { | |||
| 2020 | + | logger.Info("Found working admin connection with alternative IP", | |||
| 2021 | + | "ip", altIP) | |||
| 2022 | + | containerIP = altIP | |||
| 2023 | + | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | |||
| 2024 | + | break | |||
| 2025 | + | } | |||
| 2026 | + | } | |||
| 2027 | + | } | |||
| 2028 | + | } | |||
| 2029 | + | ||||
| 1961 | // Serve the request using the reverse proxy | 2030 | // Serve the request using the reverse proxy | ||
| 2031 | + | logger.Debug("Serving proxy request", | |||
| 2032 | + | "target", targetURL.String()) | |||
| 1962 | proxy.ServeHTTP(c.Response(), c.Request()) | 2033 | proxy.ServeHTTP(c.Response(), c.Request()) | ||
| 2034 | + | ||||
| 2035 | + | logger.Debug("Proxy request completed", | |||
| 2036 | + | "host", host, | |||
| 2037 | + | "target", targetURL.String(), | |||
| 2038 | + | "status", c.Response().Status) | |||
| 2039 | + | ||||
| 1963 | return nil | 2040 | return nil | ||
| 1964 | } | 2041 | } | ||
| 1965 | 2042 | ||||
M internal/proxy/server.go +182 -13
| @@ -4,7 +4,9 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/tls" | |
| 6 | 6 | "fmt" | |
| 7 | + | "net" | |
| 7 | 8 | "net/http" | |
| 9 | + | "strings" | |
| 8 | 10 | "time" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/gordon/pkg/docker" | |
| @@ -167,15 +169,126 @@ func (p *Proxy) Start() error { | |||
| 167 | 169 | "server_name", hello.ServerName) | |
| 168 | 170 | } | |
| 169 | 171 | ||
| 172 | + | // Verify certificate manager is properly initialized | |
| 173 | + | if p.certManager == nil { | |
| 174 | + | logger.Error("TLS certificate manager is nil during handshake - certificate initialization failed", | |
| 175 | + | "server_name", hello.ServerName, | |
| 176 | + | "solution", "Check certificate configuration and logs for setup errors") | |
| 177 | + | ||
| 178 | + | // Use fallback certificate if we have one | |
| 179 | + | if p.fallbackCert != nil { | |
| 180 | + | logger.Warn("Using fallback self-signed certificate due to missing certificate manager", | |
| 181 | + | "server_name", hello.ServerName) | |
| 182 | + | return p.fallbackCert, nil | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | return nil, fmt.Errorf("certificate manager not initialized") | |
| 186 | + | } | |
| 187 | + | ||
| 170 | 188 | // Try to get the certificate from the autocert manager | |
| 189 | + | logger.Debug("Attempting to get certificate from manager", | |
| 190 | + | "server_name", hello.ServerName, | |
| 191 | + | "has_fallback", p.fallbackCert != nil) | |
| 192 | + | ||
| 193 | + | // First try checking if we have a valid certificate in cache | |
| 194 | + | cacheKey := "cert-" + hello.ServerName | |
| 195 | + | certCache := p.certManager.Cache | |
| 196 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 197 | + | defer cancel() | |
| 198 | + | ||
| 199 | + | if certCache != nil { | |
| 200 | + | certData, err := certCache.Get(ctx, cacheKey) | |
| 201 | + | if err == nil && len(certData) > 0 { | |
| 202 | + | logger.Debug("Found certificate in cache", "domain", hello.ServerName) | |
| 203 | + | // Use directly if found in cache - skip the potentially slower autocert.Manager GetCertificate | |
| 204 | + | keyKey := "key-" + hello.ServerName | |
| 205 | + | keyData, keyErr := certCache.Get(ctx, keyKey) | |
| 206 | + | if keyErr == nil && len(keyData) > 0 { | |
| 207 | + | cert, err := tls.X509KeyPair(certData, keyData) | |
| 208 | + | if err == nil { | |
| 209 | + | logger.Debug("Successfully loaded certificate from cache", | |
| 210 | + | "domain", hello.ServerName) | |
| 211 | + | return &cert, nil | |
| 212 | + | } | |
| 213 | + | logger.Error("Failed to create X509KeyPair from cache", | |
| 214 | + | "domain", hello.ServerName, | |
| 215 | + | "error", err) | |
| 216 | + | } else { | |
| 217 | + | logger.Error("Certificate found in cache but key is missing or invalid", | |
| 218 | + | "domain", hello.ServerName, | |
| 219 | + | "key_error", keyErr) | |
| 220 | + | } | |
| 221 | + | } else if err != nil { | |
| 222 | + | logger.Debug("Certificate not found in cache during TLS handshake", | |
| 223 | + | "domain", hello.ServerName, | |
| 224 | + | "error", err) | |
| 225 | + | ||
| 226 | + | // Check if we have it on disk but not in cache (unlikely after preloading) | |
| 227 | + | hasOnDisk, certType := p.checkCertificateInFilesystem(hello.ServerName) | |
| 228 | + | if hasOnDisk { | |
| 229 | + | logger.Warn("Certificate found on disk but not in cache - this should not happen after preloading", | |
| 230 | + | "domain", hello.ServerName, | |
| 231 | + | "type", certType, | |
| 232 | + | "solution", "Check preloadCertificates implementation") | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // If not in cache, use the standard GetCertificate method | |
| 238 | + | // Let's Encrypt may be contacted if no certificate exists | |
| 171 | 239 | cert, err := p.certManager.GetCertificate(hello) | |
| 172 | 240 | ||
| 173 | - | // If we can't get a certificate and we have a fallback, use it for the admin domain | |
| 174 | - | if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil { | |
| 175 | - | logger.Debug("Using fallback certificate for admin domain", | |
| 176 | - | "domain", adminDomain, | |
| 241 | + | if err != nil { | |
| 242 | + | logger.Error("Error getting certificate from manager", | |
| 243 | + | "server_name", hello.ServerName, | |
| 177 | 244 | "error", err) | |
| 178 | - | return p.fallbackCert, nil | |
| 245 | + | ||
| 246 | + | // Check for rate limit errors | |
| 247 | + | if strings.Contains(err.Error(), "too many certificates") || | |
| 248 | + | strings.Contains(err.Error(), "rateLimited") { | |
| 249 | + | logger.Error("Let's Encrypt rate limit hit during TLS handshake", | |
| 250 | + | "domain", hello.ServerName, | |
| 251 | + | "solution", "Wait for rate limit to expire or manually add certificate") | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // Try manually loading certificate files directly as a fallback before using fallback cert | |
| 255 | + | if manualCert, manualErr := p.manuallyLoadCertificate(hello.ServerName); manualErr == nil { | |
| 256 | + | logger.Info("Successfully loaded certificate manually as fallback", | |
| 257 | + | "domain", hello.ServerName) | |
| 258 | + | return manualCert, nil | |
| 259 | + | } else { | |
| 260 | + | logger.Debug("Manual certificate loading also failed", | |
| 261 | + | "domain", hello.ServerName, | |
| 262 | + | "error", manualErr) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // If we can't get a certificate and we have a fallback, use it for the admin domain | |
| 266 | + | if hello.ServerName == p.app.GetConfig().Http.FullDomain() && p.fallbackCert != nil { | |
| 267 | + | logger.Debug("Using fallback certificate for admin domain", | |
| 268 | + | "domain", hello.ServerName, | |
| 269 | + | "error", err) | |
| 270 | + | return p.fallbackCert, nil | |
| 271 | + | } | |
| 272 | + | } else if cert == nil { | |
| 273 | + | logger.Error("Certificate manager returned nil certificate without error", | |
| 274 | + | "server_name", hello.ServerName) | |
| 275 | + | ||
| 276 | + | // Try manually loading certificate files directly as a fallback | |
| 277 | + | if manualCert, manualErr := p.manuallyLoadCertificate(hello.ServerName); manualErr == nil { | |
| 278 | + | logger.Info("Successfully loaded certificate manually when autocert returned nil", | |
| 279 | + | "domain", hello.ServerName) | |
| 280 | + | return manualCert, nil | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // If we can't get a certificate and we have a fallback, use it for the admin domain | |
| 284 | + | if hello.ServerName == p.app.GetConfig().Http.FullDomain() && p.fallbackCert != nil { | |
| 285 | + | logger.Debug("Using fallback certificate for admin domain", | |
| 286 | + | "domain", hello.ServerName) | |
| 287 | + | return p.fallbackCert, nil | |
| 288 | + | } | |
| 289 | + | } else { | |
| 290 | + | logger.Debug("Successfully obtained certificate from manager", | |
| 291 | + | "server_name", hello.ServerName) | |
| 179 | 292 | } | |
| 180 | 293 | ||
| 181 | 294 | return cert, err | |
| @@ -186,12 +299,16 @@ func (p *Proxy) Start() error { | |||
| 186 | 299 | // Add support for TLS-ALPN-01 challenges | |
| 187 | 300 | NextProtos: []string{acme.ALPNProto}, | |
| 188 | 301 | }, | |
| 189 | - | // Add timeouts to prevent hanging connections | |
| 190 | - | ReadTimeout: 30 * time.Second, | |
| 191 | - | WriteTimeout: 30 * time.Second, | |
| 192 | - | IdleTimeout: 120 * time.Second, | |
| 302 | + | // Add increased timeouts to prevent hanging connections | |
| 303 | + | ReadTimeout: 60 * time.Second, | |
| 304 | + | WriteTimeout: 60 * time.Second, | |
| 305 | + | IdleTimeout: 120 * time.Second, | |
| 306 | + | ReadHeaderTimeout: 30 * time.Second, // Add header timeout | |
| 193 | 307 | } | |
| 194 | 308 | ||
| 309 | + | // Store the HTTPS server for later use in Stop() | |
| 310 | + | p.httpsServer.Server = httpsServer | |
| 311 | + | ||
| 195 | 312 | // Create a custom HTTP server | |
| 196 | 313 | httpServer := &http.Server{ | |
| 197 | 314 | Addr: fmt.Sprintf(":%s", p.config.HttpPort), | |
| @@ -203,12 +320,24 @@ func (p *Proxy) Start() error { | |||
| 203 | 320 | ||
| 204 | 321 | p.serverStarted = true | |
| 205 | 322 | ||
| 206 | - | // Start HTTPS server | |
| 323 | + | // Start HTTPS server with more error details | |
| 207 | 324 | go func() { | |
| 208 | 325 | logger.Info("Starting HTTPS server", "address", httpsServer.Addr) | |
| 209 | 326 | // Using empty strings for cert and key files since we're using GetCertificate | |
| 210 | 327 | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | |
| 211 | - | logger.Error("HTTPS server failed", "error", err) | |
| 328 | + | logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr) | |
| 329 | + | ||
| 330 | + | // Print more diagnostic information | |
| 331 | + | if strings.Contains(err.Error(), "bind: address already in use") { | |
| 332 | + | logger.Error("Port 443 is already in use by another process. Check if another web server is running.", | |
| 333 | + | "solution", "Stop other services using port 443 or configure Gordon to use a different HTTPS port") | |
| 334 | + | } else if strings.Contains(err.Error(), "permission denied") { | |
| 335 | + | logger.Error("Permission denied when binding to port 443. This often happens when running without root privileges.", | |
| 336 | + | "solution", "Use setcap to allow binding to privileged ports or run in a container with proper port mapping") | |
| 337 | + | } else if strings.Contains(err.Error(), "certificate") { | |
| 338 | + | logger.Error("Certificate-related error when starting HTTPS server", | |
| 339 | + | "solution", "Check certificate files and permissions in the certificate directory") | |
| 340 | + | } | |
| 212 | 341 | } | |
| 213 | 342 | }() | |
| 214 | 343 | ||
| @@ -385,24 +514,64 @@ func (p *Proxy) checkExternalPortAccess() { | |||
| 385 | 514 | ||
| 386 | 515 | // Test HTTPS port 443 | |
| 387 | 516 | httpsClient := &http.Client{ | |
| 388 | - | Timeout: 5 * time.Second, | |
| 517 | + | Timeout: 10 * time.Second, // Increased timeout for more reliable testing | |
| 389 | 518 | Transport: &http.Transport{ | |
| 390 | 519 | TLSClientConfig: &tls.Config{ | |
| 391 | 520 | InsecureSkipVerify: true, | |
| 392 | 521 | }, | |
| 522 | + | // Add more detailed timeout settings | |
| 523 | + | TLSHandshakeTimeout: 5 * time.Second, | |
| 524 | + | ResponseHeaderTimeout: 5 * time.Second, | |
| 525 | + | ExpectContinueTimeout: 1 * time.Second, | |
| 526 | + | // Add a custom dialer with explicit timeouts | |
| 527 | + | DialContext: (&net.Dialer{ | |
| 528 | + | Timeout: 5 * time.Second, | |
| 529 | + | KeepAlive: 30 * time.Second, | |
| 530 | + | DualStack: true, | |
| 531 | + | }).DialContext, | |
| 393 | 532 | }, | |
| 394 | 533 | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |
| 395 | 534 | return http.ErrUseLastResponse | |
| 396 | 535 | }, | |
| 397 | 536 | } | |
| 398 | 537 | ||
| 538 | + | // Verify TLS settings are consistent | |
| 539 | + | if p.certManager == nil { | |
| 540 | + | logger.Error("Certificate manager is nil - TLS will not work correctly", | |
| 541 | + | "solution", "Check setupCertManager implementation") | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | // First try a basic TCP connection to port 443 to test if it's open | |
| 545 | + | logger.Info("Testing TCP connectivity to HTTPS port 443...") | |
| 546 | + | conn, tcpErr := net.DialTimeout("tcp", adminDomain+":443", 5*time.Second) | |
| 547 | + | if tcpErr != nil { | |
| 548 | + | logger.Warn("TCP connection to port 443 failed - HTTPS will not work", | |
| 549 | + | "domain", adminDomain, | |
| 550 | + | "error", tcpErr.Error()) | |
| 551 | + | } else { | |
| 552 | + | conn.Close() | |
| 553 | + | logger.Info("TCP connection to port 443 successful") | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | // Then try the full HTTPS request | |
| 399 | 557 | httpsURL := fmt.Sprintf("https://%s/", adminDomain) | |
| 558 | + | logger.Info("Testing HTTPS connection with SNI...", "url", httpsURL) | |
| 400 | 559 | _, httpsErr := httpsClient.Get(httpsURL) | |
| 401 | 560 | if httpsErr != nil { | |
| 402 | 561 | logger.Warn("External HTTPS port 443 might not be accessible", | |
| 403 | 562 | "error", httpsErr.Error()) | |
| 563 | + | ||
| 564 | + | // Provide more detailed troubleshooting info | |
| 565 | + | if strings.Contains(httpsErr.Error(), "certificate") { | |
| 566 | + | logger.Warn("TLS certificate issue detected", | |
| 567 | + | "hint", "Certificate verification failed but connection succeeded") | |
| 568 | + | } else if strings.Contains(httpsErr.Error(), "connection refused") { | |
| 569 | + | logger.Warn("Connection refused - firewall may be blocking port 443") | |
| 570 | + | } else if strings.Contains(httpsErr.Error(), "i/o timeout") || strings.Contains(httpsErr.Error(), "deadline exceeded") { | |
| 571 | + | logger.Warn("Connection timeout - port 443 may be filtered or not properly forwarded") | |
| 572 | + | } | |
| 404 | 573 | } else { | |
| 405 | - | logger.Info("External HTTPS port 443 is accessible") | |
| 574 | + | logger.Info("External HTTPS port 443 is accessible - TLS handshake successful") | |
| 406 | 575 | } | |
| 407 | 576 | } | |
| 408 | 577 | ||
| @@ -4,7 +4,9 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/tls" | 5 | "crypto/tls" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "net" | |||
| 7 | "net/http" | 8 | "net/http" | ||
| 9 | + | "strings" | |||
| 8 | "time" | 10 | "time" | ||
| 9 | 11 | ||||
| 10 | "github.com/bnema/gordon/pkg/docker" | 12 | "github.com/bnema/gordon/pkg/docker" | ||
| @@ -167,15 +169,126 @@ func (p *Proxy) Start() error { | |||||
| 167 | "server_name", hello.ServerName) | 169 | "server_name", hello.ServerName) | ||
| 168 | } | 170 | } | ||
| 169 | 171 | ||||
| 172 | + | // Verify certificate manager is properly initialized | |||
| 173 | + | if p.certManager == nil { | |||
| 174 | + | logger.Error("TLS certificate manager is nil during handshake - certificate initialization failed", | |||
| 175 | + | "server_name", hello.ServerName, | |||
| 176 | + | "solution", "Check certificate configuration and logs for setup errors") | |||
| 177 | + | ||||
| 178 | + | // Use fallback certificate if we have one | |||
| 179 | + | if p.fallbackCert != nil { | |||
| 180 | + | logger.Warn("Using fallback self-signed certificate due to missing certificate manager", | |||
| 181 | + | "server_name", hello.ServerName) | |||
| 182 | + | return p.fallbackCert, nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | return nil, fmt.Errorf("certificate manager not initialized") | |||
| 186 | + | } | |||
| 187 | + | ||||
| 170 | // Try to get the certificate from the autocert manager | 188 | // Try to get the certificate from the autocert manager | ||
| 189 | + | logger.Debug("Attempting to get certificate from manager", | |||
| 190 | + | "server_name", hello.ServerName, | |||
| 191 | + | "has_fallback", p.fallbackCert != nil) | |||
| 192 | + | ||||
| 193 | + | // First try checking if we have a valid certificate in cache | |||
| 194 | + | cacheKey := "cert-" + hello.ServerName | |||
| 195 | + | certCache := p.certManager.Cache | |||
| 196 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 197 | + | defer cancel() | |||
| 198 | + | ||||
| 199 | + | if certCache != nil { | |||
| 200 | + | certData, err := certCache.Get(ctx, cacheKey) | |||
| 201 | + | if err == nil && len(certData) > 0 { | |||
| 202 | + | logger.Debug("Found certificate in cache", "domain", hello.ServerName) | |||
| 203 | + | // Use directly if found in cache - skip the potentially slower autocert.Manager GetCertificate | |||
| 204 | + | keyKey := "key-" + hello.ServerName | |||
| 205 | + | keyData, keyErr := certCache.Get(ctx, keyKey) | |||
| 206 | + | if keyErr == nil && len(keyData) > 0 { | |||
| 207 | + | cert, err := tls.X509KeyPair(certData, keyData) | |||
| 208 | + | if err == nil { | |||
| 209 | + | logger.Debug("Successfully loaded certificate from cache", | |||
| 210 | + | "domain", hello.ServerName) | |||
| 211 | + | return &cert, nil | |||
| 212 | + | } | |||
| 213 | + | logger.Error("Failed to create X509KeyPair from cache", | |||
| 214 | + | "domain", hello.ServerName, | |||
| 215 | + | "error", err) | |||
| 216 | + | } else { | |||
| 217 | + | logger.Error("Certificate found in cache but key is missing or invalid", | |||
| 218 | + | "domain", hello.ServerName, | |||
| 219 | + | "key_error", keyErr) | |||
| 220 | + | } | |||
| 221 | + | } else if err != nil { | |||
| 222 | + | logger.Debug("Certificate not found in cache during TLS handshake", | |||
| 223 | + | "domain", hello.ServerName, | |||
| 224 | + | "error", err) | |||
| 225 | + | ||||
| 226 | + | // Check if we have it on disk but not in cache (unlikely after preloading) | |||
| 227 | + | hasOnDisk, certType := p.checkCertificateInFilesystem(hello.ServerName) | |||
| 228 | + | if hasOnDisk { | |||
| 229 | + | logger.Warn("Certificate found on disk but not in cache - this should not happen after preloading", | |||
| 230 | + | "domain", hello.ServerName, | |||
| 231 | + | "type", certType, | |||
| 232 | + | "solution", "Check preloadCertificates implementation") | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // If not in cache, use the standard GetCertificate method | |||
| 238 | + | // Let's Encrypt may be contacted if no certificate exists | |||
| 171 | cert, err := p.certManager.GetCertificate(hello) | 239 | cert, err := p.certManager.GetCertificate(hello) | ||
| 172 | 240 | ||||
| 173 | - | // If we can't get a certificate and we have a fallback, use it for the admin domain | 241 | + | if err != nil { |
| 174 | - | if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil { | 242 | + | logger.Error("Error getting certificate from manager", |
| 175 | - | logger.Debug("Using fallback certificate for admin domain", | 243 | + | "server_name", hello.ServerName, |
| 176 | - | "domain", adminDomain, | |||
| 177 | "error", err) | 244 | "error", err) | ||
| 178 | - | return p.fallbackCert, nil | 245 | + | |
| 246 | + | // Check for rate limit errors | |||
| 247 | + | if strings.Contains(err.Error(), "too many certificates") || | |||
| 248 | + | strings.Contains(err.Error(), "rateLimited") { | |||
| 249 | + | logger.Error("Let's Encrypt rate limit hit during TLS handshake", | |||
| 250 | + | "domain", hello.ServerName, | |||
| 251 | + | "solution", "Wait for rate limit to expire or manually add certificate") | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // Try manually loading certificate files directly as a fallback before using fallback cert | |||
| 255 | + | if manualCert, manualErr := p.manuallyLoadCertificate(hello.ServerName); manualErr == nil { | |||
| 256 | + | logger.Info("Successfully loaded certificate manually as fallback", | |||
| 257 | + | "domain", hello.ServerName) | |||
| 258 | + | return manualCert, nil | |||
| 259 | + | } else { | |||
| 260 | + | logger.Debug("Manual certificate loading also failed", | |||
| 261 | + | "domain", hello.ServerName, | |||
| 262 | + | "error", manualErr) | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // If we can't get a certificate and we have a fallback, use it for the admin domain | |||
| 266 | + | if hello.ServerName == p.app.GetConfig().Http.FullDomain() && p.fallbackCert != nil { | |||
| 267 | + | logger.Debug("Using fallback certificate for admin domain", | |||
| 268 | + | "domain", hello.ServerName, | |||
| 269 | + | "error", err) | |||
| 270 | + | return p.fallbackCert, nil | |||
| 271 | + | } | |||
| 272 | + | } else if cert == nil { | |||
| 273 | + | logger.Error("Certificate manager returned nil certificate without error", | |||
| 274 | + | "server_name", hello.ServerName) | |||
| 275 | + | ||||
| 276 | + | // Try manually loading certificate files directly as a fallback | |||
| 277 | + | if manualCert, manualErr := p.manuallyLoadCertificate(hello.ServerName); manualErr == nil { | |||
| 278 | + | logger.Info("Successfully loaded certificate manually when autocert returned nil", | |||
| 279 | + | "domain", hello.ServerName) | |||
| 280 | + | return manualCert, nil | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // If we can't get a certificate and we have a fallback, use it for the admin domain | |||
| 284 | + | if hello.ServerName == p.app.GetConfig().Http.FullDomain() && p.fallbackCert != nil { | |||
| 285 | + | logger.Debug("Using fallback certificate for admin domain", | |||
| 286 | + | "domain", hello.ServerName) | |||
| 287 | + | return p.fallbackCert, nil | |||
| 288 | + | } | |||
| 289 | + | } else { | |||
| 290 | + | logger.Debug("Successfully obtained certificate from manager", | |||
| 291 | + | "server_name", hello.ServerName) | |||
| 179 | } | 292 | } | ||
| 180 | 293 | ||||
| 181 | return cert, err | 294 | return cert, err | ||
| @@ -186,12 +299,16 @@ func (p *Proxy) Start() error { | |||||
| 186 | // Add support for TLS-ALPN-01 challenges | 299 | // Add support for TLS-ALPN-01 challenges | ||
| 187 | NextProtos: []string{acme.ALPNProto}, | 300 | NextProtos: []string{acme.ALPNProto}, | ||
| 188 | }, | 301 | }, | ||
| 189 | - | // Add timeouts to prevent hanging connections | 302 | + | // Add increased timeouts to prevent hanging connections |
| 190 | - | ReadTimeout: 30 * time.Second, | 303 | + | ReadTimeout: 60 * time.Second, |
| 191 | - | WriteTimeout: 30 * time.Second, | 304 | + | WriteTimeout: 60 * time.Second, |
| 192 | - | IdleTimeout: 120 * time.Second, | 305 | + | IdleTimeout: 120 * time.Second, |
| 306 | + | ReadHeaderTimeout: 30 * time.Second, // Add header timeout | |||
| 193 | } | 307 | } | ||
| 194 | 308 | ||||
| 309 | + | // Store the HTTPS server for later use in Stop() | |||
| 310 | + | p.httpsServer.Server = httpsServer | |||
| 311 | + | ||||
| 195 | // Create a custom HTTP server | 312 | // Create a custom HTTP server | ||
| 196 | httpServer := &http.Server{ | 313 | httpServer := &http.Server{ | ||
| 197 | Addr: fmt.Sprintf(":%s", p.config.HttpPort), | 314 | Addr: fmt.Sprintf(":%s", p.config.HttpPort), | ||
| @@ -203,12 +320,24 @@ func (p *Proxy) Start() error { | |||||
| 203 | 320 | ||||
| 204 | p.serverStarted = true | 321 | p.serverStarted = true | ||
| 205 | 322 | ||||
| 206 | - | // Start HTTPS server | 323 | + | // Start HTTPS server with more error details |
| 207 | go func() { | 324 | go func() { | ||
| 208 | logger.Info("Starting HTTPS server", "address", httpsServer.Addr) | 325 | logger.Info("Starting HTTPS server", "address", httpsServer.Addr) | ||
| 209 | // Using empty strings for cert and key files since we're using GetCertificate | 326 | // Using empty strings for cert and key files since we're using GetCertificate | ||
| 210 | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | 327 | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | ||
| 211 | - | logger.Error("HTTPS server failed", "error", err) | 328 | + | logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr) |
| 329 | + | ||||
| 330 | + | // Print more diagnostic information | |||
| 331 | + | if strings.Contains(err.Error(), "bind: address already in use") { | |||
| 332 | + | logger.Error("Port 443 is already in use by another process. Check if another web server is running.", | |||
| 333 | + | "solution", "Stop other services using port 443 or configure Gordon to use a different HTTPS port") | |||
| 334 | + | } else if strings.Contains(err.Error(), "permission denied") { | |||
| 335 | + | logger.Error("Permission denied when binding to port 443. This often happens when running without root privileges.", | |||
| 336 | + | "solution", "Use setcap to allow binding to privileged ports or run in a container with proper port mapping") | |||
| 337 | + | } else if strings.Contains(err.Error(), "certificate") { | |||
| 338 | + | logger.Error("Certificate-related error when starting HTTPS server", | |||
| 339 | + | "solution", "Check certificate files and permissions in the certificate directory") | |||
| 340 | + | } | |||
| 212 | } | 341 | } | ||
| 213 | }() | 342 | }() | ||
| 214 | 343 | ||||
| @@ -385,24 +514,64 @@ func (p *Proxy) checkExternalPortAccess() { | |||||
| 385 | 514 | ||||
| 386 | // Test HTTPS port 443 | 515 | // Test HTTPS port 443 | ||
| 387 | httpsClient := &http.Client{ | 516 | httpsClient := &http.Client{ | ||
| 388 | - | Timeout: 5 * time.Second, | 517 | + | Timeout: 10 * time.Second, // Increased timeout for more reliable testing |
| 389 | Transport: &http.Transport{ | 518 | Transport: &http.Transport{ | ||
| 390 | TLSClientConfig: &tls.Config{ | 519 | TLSClientConfig: &tls.Config{ | ||
| 391 | InsecureSkipVerify: true, | 520 | InsecureSkipVerify: true, | ||
| 392 | }, | 521 | }, | ||
| 522 | + | // Add more detailed timeout settings | |||
| 523 | + | TLSHandshakeTimeout: 5 * time.Second, | |||
| 524 | + | ResponseHeaderTimeout: 5 * time.Second, | |||
| 525 | + | ExpectContinueTimeout: 1 * time.Second, | |||
| 526 | + | // Add a custom dialer with explicit timeouts | |||
| 527 | + | DialContext: (&net.Dialer{ | |||
| 528 | + | Timeout: 5 * time.Second, | |||
| 529 | + | KeepAlive: 30 * time.Second, | |||
| 530 | + | DualStack: true, | |||
| 531 | + | }).DialContext, | |||
| 393 | }, | 532 | }, | ||
| 394 | CheckRedirect: func(req *http.Request, via []*http.Request) error { | 533 | CheckRedirect: func(req *http.Request, via []*http.Request) error { | ||
| 395 | return http.ErrUseLastResponse | 534 | return http.ErrUseLastResponse | ||
| 396 | }, | 535 | }, | ||
| 397 | } | 536 | } | ||
| 398 | 537 | ||||
| 538 | + | // Verify TLS settings are consistent | |||
| 539 | + | if p.certManager == nil { | |||
| 540 | + | logger.Error("Certificate manager is nil - TLS will not work correctly", | |||
| 541 | + | "solution", "Check setupCertManager implementation") | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | // First try a basic TCP connection to port 443 to test if it's open | |||
| 545 | + | logger.Info("Testing TCP connectivity to HTTPS port 443...") | |||
| 546 | + | conn, tcpErr := net.DialTimeout("tcp", adminDomain+":443", 5*time.Second) | |||
| 547 | + | if tcpErr != nil { | |||
| 548 | + | logger.Warn("TCP connection to port 443 failed - HTTPS will not work", | |||
| 549 | + | "domain", adminDomain, | |||
| 550 | + | "error", tcpErr.Error()) | |||
| 551 | + | } else { | |||
| 552 | + | conn.Close() | |||
| 553 | + | logger.Info("TCP connection to port 443 successful") | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | // Then try the full HTTPS request | |||
| 399 | httpsURL := fmt.Sprintf("https://%s/", adminDomain) | 557 | httpsURL := fmt.Sprintf("https://%s/", adminDomain) | ||
| 558 | + | logger.Info("Testing HTTPS connection with SNI...", "url", httpsURL) | |||
| 400 | _, httpsErr := httpsClient.Get(httpsURL) | 559 | _, httpsErr := httpsClient.Get(httpsURL) | ||
| 401 | if httpsErr != nil { | 560 | if httpsErr != nil { | ||
| 402 | logger.Warn("External HTTPS port 443 might not be accessible", | 561 | logger.Warn("External HTTPS port 443 might not be accessible", | ||
| 403 | "error", httpsErr.Error()) | 562 | "error", httpsErr.Error()) | ||
| 563 | + | ||||
| 564 | + | // Provide more detailed troubleshooting info | |||
| 565 | + | if strings.Contains(httpsErr.Error(), "certificate") { | |||
| 566 | + | logger.Warn("TLS certificate issue detected", | |||
| 567 | + | "hint", "Certificate verification failed but connection succeeded") | |||
| 568 | + | } else if strings.Contains(httpsErr.Error(), "connection refused") { | |||
| 569 | + | logger.Warn("Connection refused - firewall may be blocking port 443") | |||
| 570 | + | } else if strings.Contains(httpsErr.Error(), "i/o timeout") || strings.Contains(httpsErr.Error(), "deadline exceeded") { | |||
| 571 | + | logger.Warn("Connection timeout - port 443 may be filtered or not properly forwarded") | |||
| 572 | + | } | |||
| 404 | } else { | 573 | } else { | ||
| 405 | - | logger.Info("External HTTPS port 443 is accessible") | 574 | + | logger.Info("External HTTPS port 443 is accessible - TLS handshake successful") |
| 406 | } | 575 | } | ||
| 407 | } | 576 | } | ||
| 408 | 577 | ||||
M internal/webui/public/assets/css/tailwind.css +0 -221
| @@ -1,170 +1,4 @@ | |||
| 1 | 1 | /*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */ | |
| 2 | - | <<<<<<< HEAD | |
| 3 | - | .visible { | |
| 4 | - | visibility: visible; | |
| 5 | - | } | |
| 6 | - | .absolute { | |
| 7 | - | position: absolute; | |
| 8 | - | } | |
| 9 | - | .fixed { | |
| 10 | - | position: fixed; | |
| 11 | - | } | |
| 12 | - | .relative { | |
| 13 | - | position: relative; | |
| 14 | - | } | |
| 15 | - | .static { | |
| 16 | - | position: static; | |
| 17 | - | } | |
| 18 | - | .z-50 { | |
| 19 | - | z-index: 50; | |
| 20 | - | } | |
| 21 | - | .container { | |
| 22 | - | width: 100%; | |
| 23 | - | } | |
| 24 | - | .block { | |
| 25 | - | display: block; | |
| 26 | - | } | |
| 27 | - | .contents { | |
| 28 | - | display: contents; | |
| 29 | - | } | |
| 30 | - | .flex { | |
| 31 | - | display: flex; | |
| 32 | - | } | |
| 33 | - | .hidden { | |
| 34 | - | display: none; | |
| 35 | - | } | |
| 36 | - | .inline { | |
| 37 | - | display: inline; | |
| 38 | - | } | |
| 39 | - | .inline-block { | |
| 40 | - | display: inline-block; | |
| 41 | - | } | |
| 42 | - | .table { | |
| 43 | - | display: table; | |
| 44 | - | } | |
| 45 | - | .h-full { | |
| 46 | - | height: 100%; | |
| 47 | - | } | |
| 48 | - | .w-1\/3 { | |
| 49 | - | width: calc(1/3 * 100%); | |
| 50 | - | } | |
| 51 | - | .w-1\/4 { | |
| 52 | - | width: calc(1/4 * 100%); | |
| 53 | - | } | |
| 54 | - | .w-2\/3 { | |
| 55 | - | width: calc(2/3 * 100%); | |
| 56 | - | } | |
| 57 | - | .w-5\/12 { | |
| 58 | - | width: calc(5/12 * 100%); | |
| 59 | - | } | |
| 60 | - | .w-full { | |
| 61 | - | width: 100%; | |
| 62 | - | } | |
| 63 | - | .flex-1 { | |
| 64 | - | flex: 1; | |
| 65 | - | } | |
| 66 | - | .flex-grow { | |
| 67 | - | flex-grow: 1; | |
| 68 | - | } | |
| 69 | - | .table-auto { | |
| 70 | - | table-layout: auto; | |
| 71 | - | } | |
| 72 | - | .cursor-pointer { | |
| 73 | - | cursor: pointer; | |
| 74 | - | } | |
| 75 | - | .resize { | |
| 76 | - | resize: both; | |
| 77 | - | } | |
| 78 | - | .list-disc { | |
| 79 | - | list-style-type: disc; | |
| 80 | - | } | |
| 81 | - | .flex-col { | |
| 82 | - | flex-direction: column; | |
| 83 | - | } | |
| 84 | - | .flex-row { | |
| 85 | - | flex-direction: row; | |
| 86 | - | } | |
| 87 | - | .content-center { | |
| 88 | - | align-content: center; | |
| 89 | - | } | |
| 90 | - | .items-center { | |
| 91 | - | align-items: center; | |
| 92 | - | } | |
| 93 | - | .justify-between { | |
| 94 | - | justify-content: space-between; | |
| 95 | - | } | |
| 96 | - | .justify-center { | |
| 97 | - | justify-content: center; | |
| 98 | - | } | |
| 99 | - | .justify-end { | |
| 100 | - | justify-content: flex-end; | |
| 101 | - | } | |
| 102 | - | .justify-evenly { | |
| 103 | - | justify-content: space-evenly; | |
| 104 | - | } | |
| 105 | - | .justify-start { | |
| 106 | - | justify-content: flex-start; | |
| 107 | - | } | |
| 108 | - | .rounded-full { | |
| 109 | - | border-radius: calc(infinity * 1px); | |
| 110 | - | } | |
| 111 | - | .border { | |
| 112 | - | border-style: var(--tw-border-style); | |
| 113 | - | border-width: 1px; | |
| 114 | - | } | |
| 115 | - | .border-2 { | |
| 116 | - | border-style: var(--tw-border-style); | |
| 117 | - | border-width: 2px; | |
| 118 | - | } | |
| 119 | - | .border-t-2 { | |
| 120 | - | border-top-style: var(--tw-border-style); | |
| 121 | - | border-top-width: 2px; | |
| 122 | - | } | |
| 123 | - | .bg-transparent { | |
| 124 | - | background-color: transparent; | |
| 125 | - | } | |
| 126 | - | .text-center { | |
| 127 | - | text-align: center; | |
| 128 | - | } | |
| 129 | - | .align-middle { | |
| 130 | - | vertical-align: middle; | |
| 131 | - | } | |
| 132 | - | .opacity-0 { | |
| 133 | - | opacity: 0%; | |
| 134 | - | } | |
| 135 | - | .filter { | |
| 136 | - | filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); | |
| 137 | - | } | |
| 138 | - | .transition { | |
| 139 | - | transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter; | |
| 140 | - | transition-timing-function: var(--tw-ease, ease); | |
| 141 | - | transition-duration: var(--tw-duration, 0s); | |
| 142 | - | } | |
| 143 | - | .transition-opacity { | |
| 144 | - | transition-property: opacity; | |
| 145 | - | transition-timing-function: var(--tw-ease, ease); | |
| 146 | - | transition-duration: var(--tw-duration, 0s); | |
| 147 | - | } | |
| 148 | - | .duration-150 { | |
| 149 | - | --tw-duration: 150ms; | |
| 150 | - | transition-duration: 150ms; | |
| 151 | - | } | |
| 152 | - | .duration-300 { | |
| 153 | - | --tw-duration: 300ms; | |
| 154 | - | transition-duration: 300ms; | |
| 155 | - | } | |
| 156 | - | .outline-none { | |
| 157 | - | --tw-outline-style: none; | |
| 158 | - | outline-style: none; | |
| 159 | - | } | |
| 160 | - | .\[image\:tag\] { | |
| 161 | - | image: tag; | |
| 162 | - | } | |
| 163 | - | .focus\:outline-none { | |
| 164 | - | &:focus { | |
| 165 | - | --tw-outline-style: none; | |
| 166 | - | outline-style: none; | |
| 167 | - | ======= | |
| 168 | 2 | @layer theme, base, components, utilities; | |
| 169 | 3 | @layer theme { | |
| 170 | 4 | :root, :host { | |
| @@ -6927,9 +6761,6 @@ | |||
| 6927 | 6761 | .text-white { | |
| 6928 | 6762 | color: var(--color-white); | |
| 6929 | 6763 | } | |
| 6930 | - | .lowercase { | |
| 6931 | - | text-transform: lowercase; | |
| 6932 | - | } | |
| 6933 | 6764 | .uppercase { | |
| 6934 | 6765 | text-transform: uppercase; | |
| 6935 | 6766 | } | |
| @@ -8036,7 +7867,6 @@ | |||
| 8036 | 7867 | } | |
| 8037 | 7868 | .text-subtitle { | |
| 8038 | 7869 | color: var(--color-indigo-500); | |
| 8039 | - | >>>>>>> feat/templ-integration | |
| 8040 | 7870 | } | |
| 8041 | 7871 | } | |
| 8042 | 7872 | @font-face { | |
| @@ -8125,56 +7955,6 @@ | |||
| 8125 | 7955 | .JetBrainsMonoBoldNF, .iconfb { | |
| 8126 | 7956 | font-family: "JetBrainsMono-Nerd-Font-Bold", sans-serif; | |
| 8127 | 7957 | } | |
| 8128 | - | <<<<<<< HEAD | |
| 8129 | - | @property --tw-border-style { | |
| 8130 | - | syntax: "*"; | |
| 8131 | - | inherits: false; | |
| 8132 | - | initial-value: solid; | |
| 8133 | - | } | |
| 8134 | - | @property --tw-blur { | |
| 8135 | - | syntax: "*"; | |
| 8136 | - | inherits: false; | |
| 8137 | - | } | |
| 8138 | - | @property --tw-brightness { | |
| 8139 | - | syntax: "*"; | |
| 8140 | - | inherits: false; | |
| 8141 | - | } | |
| 8142 | - | @property --tw-contrast { | |
| 8143 | - | syntax: "*"; | |
| 8144 | - | inherits: false; | |
| 8145 | - | } | |
| 8146 | - | @property --tw-grayscale { | |
| 8147 | - | syntax: "*"; | |
| 8148 | - | inherits: false; | |
| 8149 | - | } | |
| 8150 | - | @property --tw-hue-rotate { | |
| 8151 | - | syntax: "*"; | |
| 8152 | - | inherits: false; | |
| 8153 | - | } | |
| 8154 | - | @property --tw-invert { | |
| 8155 | - | syntax: "*"; | |
| 8156 | - | inherits: false; | |
| 8157 | - | } | |
| 8158 | - | @property --tw-opacity { | |
| 8159 | - | syntax: "*"; | |
| 8160 | - | inherits: false; | |
| 8161 | - | } | |
| 8162 | - | @property --tw-saturate { | |
| 8163 | - | syntax: "*"; | |
| 8164 | - | inherits: false; | |
| 8165 | - | } | |
| 8166 | - | @property --tw-sepia { | |
| 8167 | - | syntax: "*"; | |
| 8168 | - | inherits: false; | |
| 8169 | - | } | |
| 8170 | - | @property --tw-drop-shadow { | |
| 8171 | - | syntax: "*"; | |
| 8172 | - | inherits: false; | |
| 8173 | - | } | |
| 8174 | - | @property --tw-duration { | |
| 8175 | - | syntax: "*"; | |
| 8176 | - | inherits: false; | |
| 8177 | - | ======= | |
| 8178 | 7958 | @layer base { | |
| 8179 | 7959 | :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { | |
| 8180 | 7960 | color-scheme: light; | |
| @@ -8662,5 +8442,4 @@ | |||
| 8662 | 8442 | to { | |
| 8663 | 8443 | transform: rotate(360deg); | |
| 8664 | 8444 | } | |
| 8665 | - | >>>>>>> feat/templ-integration | |
| 8666 | 8445 | } | |
| @@ -1,170 +1,4 @@ | |||||
| 1 | /*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */ | 1 | /*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */ | ||
| 2 | - | <<<<<<< HEAD | |||
| 3 | - | .visible { | |||
| 4 | - | visibility: visible; | |||
| 5 | - | } | |||
| 6 | - | .absolute { | |||
| 7 | - | position: absolute; | |||
| 8 | - | } | |||
| 9 | - | .fixed { | |||
| 10 | - | position: fixed; | |||
| 11 | - | } | |||
| 12 | - | .relative { | |||
| 13 | - | position: relative; | |||
| 14 | - | } | |||
| 15 | - | .static { | |||
| 16 | - | position: static; | |||
| 17 | - | } | |||
| 18 | - | .z-50 { | |||
| 19 | - | z-index: 50; | |||
| 20 | - | } | |||
| 21 | - | .container { | |||
| 22 | - | width: 100%; | |||
| 23 | - | } | |||
| 24 | - | .block { | |||
| 25 | - | display: block; | |||
| 26 | - | } | |||
| 27 | - | .contents { | |||
| 28 | - | display: contents; | |||
| 29 | - | } | |||
| 30 | - | .flex { | |||
| 31 | - | display: flex; | |||
| 32 | - | } | |||
| 33 | - | .hidden { | |||
| 34 | - | display: none; | |||
| 35 | - | } | |||
| 36 | - | .inline { | |||
| 37 | - | display: inline; | |||
| 38 | - | } | |||
| 39 | - | .inline-block { | |||
| 40 | - | display: inline-block; | |||
| 41 | - | } | |||
| 42 | - | .table { | |||
| 43 | - | display: table; | |||
| 44 | - | } | |||
| 45 | - | .h-full { | |||
| 46 | - | height: 100%; | |||
| 47 | - | } | |||
| 48 | - | .w-1\/3 { | |||
| 49 | - | width: calc(1/3 * 100%); | |||
| 50 | - | } | |||
| 51 | - | .w-1\/4 { | |||
| 52 | - | width: calc(1/4 * 100%); | |||
| 53 | - | } | |||
| 54 | - | .w-2\/3 { | |||
| 55 | - | width: calc(2/3 * 100%); | |||
| 56 | - | } | |||
| 57 | - | .w-5\/12 { | |||
| 58 | - | width: calc(5/12 * 100%); | |||
| 59 | - | } | |||
| 60 | - | .w-full { | |||
| 61 | - | width: 100%; | |||
| 62 | - | } | |||
| 63 | - | .flex-1 { | |||
| 64 | - | flex: 1; | |||
| 65 | - | } | |||
| 66 | - | .flex-grow { | |||
| 67 | - | flex-grow: 1; | |||
| 68 | - | } | |||
| 69 | - | .table-auto { | |||
| 70 | - | table-layout: auto; | |||
| 71 | - | } | |||
| 72 | - | .cursor-pointer { | |||
| 73 | - | cursor: pointer; | |||
| 74 | - | } | |||
| 75 | - | .resize { | |||
| 76 | - | resize: both; | |||
| 77 | - | } | |||
| 78 | - | .list-disc { | |||
| 79 | - | list-style-type: disc; | |||
| 80 | - | } | |||
| 81 | - | .flex-col { | |||
| 82 | - | flex-direction: column; | |||
| 83 | - | } | |||
| 84 | - | .flex-row { | |||
| 85 | - | flex-direction: row; | |||
| 86 | - | } | |||
| 87 | - | .content-center { | |||
| 88 | - | align-content: center; | |||
| 89 | - | } | |||
| 90 | - | .items-center { | |||
| 91 | - | align-items: center; | |||
| 92 | - | } | |||
| 93 | - | .justify-between { | |||
| 94 | - | justify-content: space-between; | |||
| 95 | - | } | |||
| 96 | - | .justify-center { | |||
| 97 | - | justify-content: center; | |||
| 98 | - | } | |||
| 99 | - | .justify-end { | |||
| 100 | - | justify-content: flex-end; | |||
| 101 | - | } | |||
| 102 | - | .justify-evenly { | |||
| 103 | - | justify-content: space-evenly; | |||
| 104 | - | } | |||
| 105 | - | .justify-start { | |||
| 106 | - | justify-content: flex-start; | |||
| 107 | - | } | |||
| 108 | - | .rounded-full { | |||
| 109 | - | border-radius: calc(infinity * 1px); | |||
| 110 | - | } | |||
| 111 | - | .border { | |||
| 112 | - | border-style: var(--tw-border-style); | |||
| 113 | - | border-width: 1px; | |||
| 114 | - | } | |||
| 115 | - | .border-2 { | |||
| 116 | - | border-style: var(--tw-border-style); | |||
| 117 | - | border-width: 2px; | |||
| 118 | - | } | |||
| 119 | - | .border-t-2 { | |||
| 120 | - | border-top-style: var(--tw-border-style); | |||
| 121 | - | border-top-width: 2px; | |||
| 122 | - | } | |||
| 123 | - | .bg-transparent { | |||
| 124 | - | background-color: transparent; | |||
| 125 | - | } | |||
| 126 | - | .text-center { | |||
| 127 | - | text-align: center; | |||
| 128 | - | } | |||
| 129 | - | .align-middle { | |||
| 130 | - | vertical-align: middle; | |||
| 131 | - | } | |||
| 132 | - | .opacity-0 { | |||
| 133 | - | opacity: 0%; | |||
| 134 | - | } | |||
| 135 | - | .filter { | |||
| 136 | - | filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); | |||
| 137 | - | } | |||
| 138 | - | .transition { | |||
| 139 | - | transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter; | |||
| 140 | - | transition-timing-function: var(--tw-ease, ease); | |||
| 141 | - | transition-duration: var(--tw-duration, 0s); | |||
| 142 | - | } | |||
| 143 | - | .transition-opacity { | |||
| 144 | - | transition-property: opacity; | |||
| 145 | - | transition-timing-function: var(--tw-ease, ease); | |||
| 146 | - | transition-duration: var(--tw-duration, 0s); | |||
| 147 | - | } | |||
| 148 | - | .duration-150 { | |||
| 149 | - | --tw-duration: 150ms; | |||
| 150 | - | transition-duration: 150ms; | |||
| 151 | - | } | |||
| 152 | - | .duration-300 { | |||
| 153 | - | --tw-duration: 300ms; | |||
| 154 | - | transition-duration: 300ms; | |||
| 155 | - | } | |||
| 156 | - | .outline-none { | |||
| 157 | - | --tw-outline-style: none; | |||
| 158 | - | outline-style: none; | |||
| 159 | - | } | |||
| 160 | - | .\[image\:tag\] { | |||
| 161 | - | image: tag; | |||
| 162 | - | } | |||
| 163 | - | .focus\:outline-none { | |||
| 164 | - | &:focus { | |||
| 165 | - | --tw-outline-style: none; | |||
| 166 | - | outline-style: none; | |||
| 167 | - | ======= | |||
| 168 | @layer theme, base, components, utilities; | 2 | @layer theme, base, components, utilities; | ||
| 169 | @layer theme { | 3 | @layer theme { | ||
| 170 | :root, :host { | 4 | :root, :host { | ||
| @@ -6927,9 +6761,6 @@ | |||||
| 6927 | .text-white { | 6761 | .text-white { | ||
| 6928 | color: var(--color-white); | 6762 | color: var(--color-white); | ||
| 6929 | } | 6763 | } | ||
| 6930 | - | .lowercase { | |||
| 6931 | - | text-transform: lowercase; | |||
| 6932 | - | } | |||
| 6933 | .uppercase { | 6764 | .uppercase { | ||
| 6934 | text-transform: uppercase; | 6765 | text-transform: uppercase; | ||
| 6935 | } | 6766 | } | ||
| @@ -8036,7 +7867,6 @@ | |||||
| 8036 | } | 7867 | } | ||
| 8037 | .text-subtitle { | 7868 | .text-subtitle { | ||
| 8038 | color: var(--color-indigo-500); | 7869 | color: var(--color-indigo-500); | ||
| 8039 | - | >>>>>>> feat/templ-integration | |||
| 8040 | } | 7870 | } | ||
| 8041 | } | 7871 | } | ||
| 8042 | @font-face { | 7872 | @font-face { | ||
| @@ -8125,56 +7955,6 @@ | |||||
| 8125 | .JetBrainsMonoBoldNF, .iconfb { | 7955 | .JetBrainsMonoBoldNF, .iconfb { | ||
| 8126 | font-family: "JetBrainsMono-Nerd-Font-Bold", sans-serif; | 7956 | font-family: "JetBrainsMono-Nerd-Font-Bold", sans-serif; | ||
| 8127 | } | 7957 | } | ||
| 8128 | - | <<<<<<< HEAD | |||
| 8129 | - | @property --tw-border-style { | |||
| 8130 | - | syntax: "*"; | |||
| 8131 | - | inherits: false; | |||
| 8132 | - | initial-value: solid; | |||
| 8133 | - | } | |||
| 8134 | - | @property --tw-blur { | |||
| 8135 | - | syntax: "*"; | |||
| 8136 | - | inherits: false; | |||
| 8137 | - | } | |||
| 8138 | - | @property --tw-brightness { | |||
| 8139 | - | syntax: "*"; | |||
| 8140 | - | inherits: false; | |||
| 8141 | - | } | |||
| 8142 | - | @property --tw-contrast { | |||
| 8143 | - | syntax: "*"; | |||
| 8144 | - | inherits: false; | |||
| 8145 | - | } | |||
| 8146 | - | @property --tw-grayscale { | |||
| 8147 | - | syntax: "*"; | |||
| 8148 | - | inherits: false; | |||
| 8149 | - | } | |||
| 8150 | - | @property --tw-hue-rotate { | |||
| 8151 | - | syntax: "*"; | |||
| 8152 | - | inherits: false; | |||
| 8153 | - | } | |||
| 8154 | - | @property --tw-invert { | |||
| 8155 | - | syntax: "*"; | |||
| 8156 | - | inherits: false; | |||
| 8157 | - | } | |||
| 8158 | - | @property --tw-opacity { | |||
| 8159 | - | syntax: "*"; | |||
| 8160 | - | inherits: false; | |||
| 8161 | - | } | |||
| 8162 | - | @property --tw-saturate { | |||
| 8163 | - | syntax: "*"; | |||
| 8164 | - | inherits: false; | |||
| 8165 | - | } | |||
| 8166 | - | @property --tw-sepia { | |||
| 8167 | - | syntax: "*"; | |||
| 8168 | - | inherits: false; | |||
| 8169 | - | } | |||
| 8170 | - | @property --tw-drop-shadow { | |||
| 8171 | - | syntax: "*"; | |||
| 8172 | - | inherits: false; | |||
| 8173 | - | } | |||
| 8174 | - | @property --tw-duration { | |||
| 8175 | - | syntax: "*"; | |||
| 8176 | - | inherits: false; | |||
| 8177 | - | ======= | |||
| 8178 | @layer base { | 7958 | @layer base { | ||
| 8179 | :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { | 7959 | :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { | ||
| 8180 | color-scheme: light; | 7960 | color-scheme: light; | ||
| @@ -8662,5 +8442,4 @@ | |||||
| 8662 | to { | 8442 | to { | ||
| 8663 | transform: rotate(360deg); | 8443 | transform: rotate(360deg); | ||
| 8664 | } | 8444 | } | ||
| 8665 | - | >>>>>>> feat/templ-integration | |||
| 8666 | } | 8445 | } | ||