detached 2445c7c

feat: Enhance HTTPS proxy routing and certificate management

- Improve protocol detection for proxy routes using domain and container labels
- Add proactive Let's Encrypt certificate request for HTTPS routes
- Implement robust domain resolution and certificate request handling
- Add detailed logging for certificate request process
- Handle Let's Encrypt rate limiting and retry scenarios
- Extract hostname from domain names with protocol prefixes
commit
2445c7c8fef5ec98fe61f88c032544a38c76def3
author
bamen · · 2025-03-09 16:00 UTC
parent
5620434
signature
unsigned
2 files · +181 -4
M internal/httpserve/handlers/endpoint_deploy.go +25 -1
@@ -399,9 +399,32 @@ func addProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
399399 return fmt.Errorf("failed to create proxy: %w", err)
400400 }
401401
402- // Extract the protocol (default to http)
402+ // Extract the protocol from the target domain
403403 protocol := "http"
404404
405+ // Check if the target domain specifies HTTPS
406+ if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {
407+ protocol = "https"
408+ log.Debug("Using HTTPS protocol for proxy route based on target domain",
409+ "domain", targetDomain,
410+ "protocol", protocol)
411+ } else {
412+ // If target domain doesn't specify protocol, check the container labels
413+ containerInfo, err := docker.GetContainerInfo(containerID)
414+ if err == nil {
415+ // Check if gordon.proxy.ssl label is set to true
416+ if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
417+ (sslValue == "true" || sslValue == "1" || sslValue == "yes") {
418+ protocol = "https"
419+ log.Debug("Using HTTPS protocol for proxy route based on container labels",
420+ "domain", targetDomain,
421+ "containerID", containerID,
422+ "label_value", sslValue,
423+ "protocol", protocol)
424+ }
425+ }
426+ }
427+
405428 // Add the route
406429 if err := p.AddRoute(targetDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil {
407430 return fmt.Errorf("failed to add proxy route: %w", err)
@@ -411,6 +434,7 @@ func addProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
411434 "domain", targetDomain,
412435 "containerIP", containerIP,
413436 "containerPort", containerPort,
437+ "protocol", protocol,
414438 )
415439 return nil
416440 }
@@ -399,9 +399,32 @@ func addProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
399 return fmt.Errorf("failed to create proxy: %w", err)399 return fmt.Errorf("failed to create proxy: %w", err)
400 }400 }
401 401
402- // Extract the protocol (default to http)402+ // Extract the protocol from the target domain
403 protocol := "http"403 protocol := "http"
404 404
405+ // Check if the target domain specifies HTTPS
406+ if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {
407+ protocol = "https"
408+ log.Debug("Using HTTPS protocol for proxy route based on target domain",
409+ "domain", targetDomain,
410+ "protocol", protocol)
411+ } else {
412+ // If target domain doesn't specify protocol, check the container labels
413+ containerInfo, err := docker.GetContainerInfo(containerID)
414+ if err == nil {
415+ // Check if gordon.proxy.ssl label is set to true
416+ if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
417+ (sslValue == "true" || sslValue == "1" || sslValue == "yes") {
418+ protocol = "https"
419+ log.Debug("Using HTTPS protocol for proxy route based on container labels",
420+ "domain", targetDomain,
421+ "containerID", containerID,
422+ "label_value", sslValue,
423+ "protocol", protocol)
424+ }
425+ }
426+ }
427+
405 // Add the route428 // Add the route
406 if err := p.AddRoute(targetDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil {429 if err := p.AddRoute(targetDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil {
407 return fmt.Errorf("failed to add proxy route: %w", err)430 return fmt.Errorf("failed to add proxy route: %w", err)
@@ -411,6 +434,7 @@ func addProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
411 "domain", targetDomain,434 "domain", targetDomain,
412 "containerIP", containerIP,435 "containerIP", containerIP,
413 "containerPort", containerPort,436 "containerPort", containerPort,
437+ "protocol", protocol,
414 )438 )
415 return nil439 return nil
416 }440 }
M internal/proxy/proxy.go +156 -3
@@ -552,6 +552,141 @@ func (p *Proxy) requestAdminCertificate() {
552552 }
553553 }
554554
555+// requestDomainCertificate proactively requests a Let's Encrypt certificate
556+// for a container domain
557+func (p *Proxy) requestDomainCertificate(domain string) {
558+ if domain == "" {
559+ log.Warn("Empty domain provided to requestDomainCertificate")
560+ return
561+ }
562+
563+ // Extract hostname from domain, resolving it to see if it's publicly accessible
564+ ips, err := net.LookupIP(domain)
565+ if err != nil {
566+ log.Error("Could not resolve domain, Let's Encrypt will likely fail",
567+ "domain", domain,
568+ "error", err.Error(),
569+ "solution", "Check DNS settings and ensure domain points to this server")
570+ return
571+ }
572+
573+ var ipStrings []string
574+ for _, ip := range ips {
575+ ipStrings = append(ipStrings, ip.String())
576+ }
577+
578+ log.Info("Successfully resolved domain for certificate request",
579+ "domain", domain,
580+ "ips", ipStrings)
581+
582+ // Check if we already have a valid certificate in the cache
583+ if p.checkCertificateInCache(domain) {
584+ log.Info("Using existing certificate from cache",
585+ "domain", domain,
586+ "action", "skipping Let's Encrypt request to avoid rate limits")
587+ return
588+ }
589+
590+ // Check if environment is production
591+ mode := "staging"
592+ email := p.config.Email
593+
594+ if !p.app.IsDevEnvironment() && email != "" {
595+ mode = "production"
596+ }
597+
598+ // Log the certificate request intent
599+ log.Info("Initiating Let's Encrypt certificate request for container domain",
600+ "domain", domain,
601+ "email", email,
602+ "mode", mode)
603+
604+ log.Info("⏳ Waiting for Let's Encrypt to validate domain ownership",
605+ "domain", domain,
606+ "validation_method", "HTTP-01 challenge",
607+ "requirements", "Domain must be publicly accessible on port 80",
608+ "timeout", "1 minute")
609+
610+ // Create a context with timeout for the initial certificate request
611+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
612+ defer cancel()
613+
614+ // Try to obtain the certificate with timeout
615+ var certResult *tls.Certificate
616+ var certErr error
617+
618+ // Use a channel to handle the timeout
619+ certChan := make(chan struct {
620+ cert *tls.Certificate
621+ err error
622+ })
623+
624+ go func() {
625+ c, e := p.certManager.GetCertificate(&tls.ClientHelloInfo{
626+ ServerName: domain,
627+ })
628+ certChan <- struct {
629+ cert *tls.Certificate
630+ err error
631+ }{c, e}
632+ }()
633+
634+ // Wait for either the certificate request to complete or the timeout
635+ select {
636+ case result := <-certChan:
637+ certResult = result.cert
638+ certErr = result.err
639+ case <-ctx.Done():
640+ certErr = fmt.Errorf("certificate request timed out after 1 minute: %w", ctx.Err())
641+ log.Error("Let's Encrypt certificate request timed out",
642+ "domain", domain,
643+ "timeout", "1 minute",
644+ "error", certErr)
645+ }
646+
647+ if certErr != nil {
648+ // Check for rate limit errors
649+ if strings.Contains(strings.ToLower(certErr.Error()), "ratelimited") {
650+ // Extract the retry-after time if available in the error message
651+ retryAfterStr := ""
652+ re := regexp.MustCompile(`retry after ([^:]+)`)
653+ matches := re.FindStringSubmatch(certErr.Error())
654+ if len(matches) > 1 {
655+ retryAfterStr = matches[1]
656+ }
657+
658+ log.Error("Let's Encrypt rate limit reached",
659+ "domain", domain,
660+ "error", certErr,
661+ "retry_after", retryAfterStr,
662+ "solution", "Wait until the rate limit expires, then restart the server")
663+
664+ // Don't retry on rate limits
665+ log.Info("Skipping certificate request retries due to rate limiting",
666+ "domain", domain)
667+ } else {
668+ log.Error("Failed to obtain Let's Encrypt certificate",
669+ "domain", domain,
670+ "error", certErr)
671+
672+ log.Info("⏳ Retrying certificate request with exponential backoff",
673+ "domain", domain,
674+ "timeout", "2 minutes")
675+ // Implement retry logic with backoff
676+ go p.retryCertificateRequest(domain, 3, 10*time.Second)
677+ }
678+ } else if certResult != nil {
679+ log.Info("Successfully obtained Let's Encrypt certificate",
680+ "domain", domain)
681+ } else {
682+ log.Error("Unexpected error: received nil certificate but no error",
683+ "domain", domain,
684+ "timeout", "1 minute")
685+ // Implement retry logic with backoff
686+ go p.retryCertificateRequest(domain, 3, 10*time.Second)
687+ }
688+}
689+
555690 // retryCertificateRequest attempts to request a certificate with exponential backoff
556691 func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) {
557692 backoff := initialBackoff
@@ -1197,9 +1332,18 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
11971332 }
11981333 defer tx.Rollback()
11991334
1335+ // Extract the hostname from domainName if it contains a protocol
1336+ hostname := domainName
1337+ if strings.Contains(hostname, "://") {
1338+ parsedURL, err := url.Parse(hostname)
1339+ if err == nil && parsedURL.Host != "" {
1340+ hostname = parsedURL.Host
1341+ }
1342+ }
1343+
12001344 // Check if the domain exists
12011345 var domainID string
1202- err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID)
1346+ err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", hostname).Scan(&domainID)
12031347 if err != nil {
12041348 if err == sql.ErrNoRows {
12051349 // Domain doesn't exist, create it
@@ -1207,7 +1351,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
12071351 now := time.Now().Format(time.RFC3339)
12081352 _, err = tx.Exec(
12091353 "INSERT INTO domain (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
1210- domainID, domainName, now, now,
1354+ domainID, hostname, now, now,
12111355 )
12121356 if err != nil {
12131357 return fmt.Errorf("failed to insert domain: %w", err)
@@ -1268,10 +1412,19 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
12681412 }
12691413
12701414 log.Info("Added proxy route",
1271- "domain", domainName,
1415+ "domain", hostname,
12721416 "containerIP", containerIP,
12731417 "containerPort", containerPort,
12741418 )
1419+
1420+ // Request a certificate if this is an HTTPS route
1421+ if strings.ToLower(protocol) == "https" {
1422+ log.Info("Requesting Let's Encrypt certificate for new HTTPS route",
1423+ "domain", hostname)
1424+ // Run in a goroutine to avoid blocking
1425+ go p.requestDomainCertificate(hostname)
1426+ }
1427+
12751428 return nil
12761429 }
12771430
@@ -552,6 +552,141 @@ func (p *Proxy) requestAdminCertificate() {
552 }552 }
553 }553 }
554 554
555+// requestDomainCertificate proactively requests a Let's Encrypt certificate
556+// for a container domain
557+func (p *Proxy) requestDomainCertificate(domain string) {
558+ if domain == "" {
559+ log.Warn("Empty domain provided to requestDomainCertificate")
560+ return
561+ }
562+
563+ // Extract hostname from domain, resolving it to see if it's publicly accessible
564+ ips, err := net.LookupIP(domain)
565+ if err != nil {
566+ log.Error("Could not resolve domain, Let's Encrypt will likely fail",
567+ "domain", domain,
568+ "error", err.Error(),
569+ "solution", "Check DNS settings and ensure domain points to this server")
570+ return
571+ }
572+
573+ var ipStrings []string
574+ for _, ip := range ips {
575+ ipStrings = append(ipStrings, ip.String())
576+ }
577+
578+ log.Info("Successfully resolved domain for certificate request",
579+ "domain", domain,
580+ "ips", ipStrings)
581+
582+ // Check if we already have a valid certificate in the cache
583+ if p.checkCertificateInCache(domain) {
584+ log.Info("Using existing certificate from cache",
585+ "domain", domain,
586+ "action", "skipping Let's Encrypt request to avoid rate limits")
587+ return
588+ }
589+
590+ // Check if environment is production
591+ mode := "staging"
592+ email := p.config.Email
593+
594+ if !p.app.IsDevEnvironment() && email != "" {
595+ mode = "production"
596+ }
597+
598+ // Log the certificate request intent
599+ log.Info("Initiating Let's Encrypt certificate request for container domain",
600+ "domain", domain,
601+ "email", email,
602+ "mode", mode)
603+
604+ log.Info("⏳ Waiting for Let's Encrypt to validate domain ownership",
605+ "domain", domain,
606+ "validation_method", "HTTP-01 challenge",
607+ "requirements", "Domain must be publicly accessible on port 80",
608+ "timeout", "1 minute")
609+
610+ // Create a context with timeout for the initial certificate request
611+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
612+ defer cancel()
613+
614+ // Try to obtain the certificate with timeout
615+ var certResult *tls.Certificate
616+ var certErr error
617+
618+ // Use a channel to handle the timeout
619+ certChan := make(chan struct {
620+ cert *tls.Certificate
621+ err error
622+ })
623+
624+ go func() {
625+ c, e := p.certManager.GetCertificate(&tls.ClientHelloInfo{
626+ ServerName: domain,
627+ })
628+ certChan <- struct {
629+ cert *tls.Certificate
630+ err error
631+ }{c, e}
632+ }()
633+
634+ // Wait for either the certificate request to complete or the timeout
635+ select {
636+ case result := <-certChan:
637+ certResult = result.cert
638+ certErr = result.err
639+ case <-ctx.Done():
640+ certErr = fmt.Errorf("certificate request timed out after 1 minute: %w", ctx.Err())
641+ log.Error("Let's Encrypt certificate request timed out",
642+ "domain", domain,
643+ "timeout", "1 minute",
644+ "error", certErr)
645+ }
646+
647+ if certErr != nil {
648+ // Check for rate limit errors
649+ if strings.Contains(strings.ToLower(certErr.Error()), "ratelimited") {
650+ // Extract the retry-after time if available in the error message
651+ retryAfterStr := ""
652+ re := regexp.MustCompile(`retry after ([^:]+)`)
653+ matches := re.FindStringSubmatch(certErr.Error())
654+ if len(matches) > 1 {
655+ retryAfterStr = matches[1]
656+ }
657+
658+ log.Error("Let's Encrypt rate limit reached",
659+ "domain", domain,
660+ "error", certErr,
661+ "retry_after", retryAfterStr,
662+ "solution", "Wait until the rate limit expires, then restart the server")
663+
664+ // Don't retry on rate limits
665+ log.Info("Skipping certificate request retries due to rate limiting",
666+ "domain", domain)
667+ } else {
668+ log.Error("Failed to obtain Let's Encrypt certificate",
669+ "domain", domain,
670+ "error", certErr)
671+
672+ log.Info("⏳ Retrying certificate request with exponential backoff",
673+ "domain", domain,
674+ "timeout", "2 minutes")
675+ // Implement retry logic with backoff
676+ go p.retryCertificateRequest(domain, 3, 10*time.Second)
677+ }
678+ } else if certResult != nil {
679+ log.Info("Successfully obtained Let's Encrypt certificate",
680+ "domain", domain)
681+ } else {
682+ log.Error("Unexpected error: received nil certificate but no error",
683+ "domain", domain,
684+ "timeout", "1 minute")
685+ // Implement retry logic with backoff
686+ go p.retryCertificateRequest(domain, 3, 10*time.Second)
687+ }
688+}
689+
555 // retryCertificateRequest attempts to request a certificate with exponential backoff690 // retryCertificateRequest attempts to request a certificate with exponential backoff
556 func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) {691 func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) {
557 backoff := initialBackoff692 backoff := initialBackoff
@@ -1197,9 +1332,18 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
1197 }1332 }
1198 defer tx.Rollback()1333 defer tx.Rollback()
1199 1334
1335+ // Extract the hostname from domainName if it contains a protocol
1336+ hostname := domainName
1337+ if strings.Contains(hostname, "://") {
1338+ parsedURL, err := url.Parse(hostname)
1339+ if err == nil && parsedURL.Host != "" {
1340+ hostname = parsedURL.Host
1341+ }
1342+ }
1343+
1200 // Check if the domain exists1344 // Check if the domain exists
1201 var domainID string1345 var domainID string
1202- err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID)1346+ err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", hostname).Scan(&domainID)
1203 if err != nil {1347 if err != nil {
1204 if err == sql.ErrNoRows {1348 if err == sql.ErrNoRows {
1205 // Domain doesn't exist, create it1349 // Domain doesn't exist, create it
@@ -1207,7 +1351,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
1207 now := time.Now().Format(time.RFC3339)1351 now := time.Now().Format(time.RFC3339)
1208 _, err = tx.Exec(1352 _, err = tx.Exec(
1209 "INSERT INTO domain (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",1353 "INSERT INTO domain (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
1210- domainID, domainName, now, now,1354+ domainID, hostname, now, now,
1211 )1355 )
1212 if err != nil {1356 if err != nil {
1213 return fmt.Errorf("failed to insert domain: %w", err)1357 return fmt.Errorf("failed to insert domain: %w", err)
@@ -1268,10 +1412,19 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
1268 }1412 }
1269 1413
1270 log.Info("Added proxy route",1414 log.Info("Added proxy route",
1271- "domain", domainName,1415+ "domain", hostname,
1272 "containerIP", containerIP,1416 "containerIP", containerIP,
1273 "containerPort", containerPort,1417 "containerPort", containerPort,
1274 )1418 )
1419+
1420+ // Request a certificate if this is an HTTPS route
1421+ if strings.ToLower(protocol) == "https" {
1422+ log.Info("Requesting Let's Encrypt certificate for new HTTPS route",
1423+ "domain", hostname)
1424+ // Run in a goroutine to avoid blocking
1425+ go p.requestDomainCertificate(hostname)
1426+ }
1427+
1275 return nil1428 return nil
1276 }1429 }
1277 1430

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close