detached c6119d8

feat: Enhance configuration defaults and certificate handling

- Implement default storage directory based on container environment.
- Add default values for Admin and ContainerEngine configurations.
- Improve logging for applied configuration defaults.
- Enhance certificate handling with detailed logging for certificate types and modes.
- Refactor certificate existence checks to return type information.
- Introduce self-signed certificate generation as a fallback mechanism.
- Update CSS with TailwindCSS properties for improved styling consistency.
commit
c6119d8a8c750e801f64a7b671ca914366d86941
author
bamen · · 2025-03-24 07:24 UTC
parent
efe619a
signature
unsigned
4 files · +729 -1385
M internal/common/init_config.go +76 -0
@@ -113,6 +113,45 @@ func applyDefaultsToConfig(config *Config) bool {
113113 logger.Debug("Applied default value for General.LogLevel", "value", defaultLogLevel)
114114 defaultsApplied = true
115115 }
116+ if config.General.StorageDir == "" {
117+ // When running in container, use /data as the default storage location
118+ if docker.IsRunningInContainer() {
119+ config.General.StorageDir = "/data"
120+ } else {
121+ // Otherwise, use ~/.gordon
122+ homeDir, err := os.UserHomeDir()
123+ if err != nil {
124+ homeDir = "/tmp"
125+ }
126+ config.General.StorageDir = filepath.Join(homeDir, ".gordon")
127+ }
128+ logger.Debug("Applied default value for General.StorageDir", "value", config.General.StorageDir)
129+ defaultsApplied = true
130+ }
131+
132+ // Apply defaults to Admin config
133+ if config.Admin.Path == "" {
134+ config.Admin.Path = "/admin"
135+ logger.Debug("Applied default value for Admin.Path", "value", "/admin")
136+ defaultsApplied = true
137+ }
138+
139+ // Apply defaults to ContainerEngine config
140+ if config.ContainerEngine.Sock == "" {
141+ config.ContainerEngine.Sock = sock
142+ logger.Debug("Applied default value for ContainerEngine.Sock", "value", sock)
143+ defaultsApplied = true
144+ }
145+ if config.ContainerEngine.PodmanSock == "" {
146+ config.ContainerEngine.PodmanSock = podmansock
147+ logger.Debug("Applied default value for ContainerEngine.PodmanSock", "value", podmansock)
148+ defaultsApplied = true
149+ }
150+ if config.ContainerEngine.Network == "" {
151+ config.ContainerEngine.Network = "gordon"
152+ logger.Debug("Applied default value for ContainerEngine.Network", "value", "gordon")
153+ defaultsApplied = true
154+ }
116155
117156 // Apply defaults to ReverseProxy config
118157 if config.ReverseProxy.Port == "" {
@@ -447,6 +486,17 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
447486 "socket", podmanSocket)
448487 }
449488 }
489+
490+ // If we're in a container, check for special socket naming patterns
491+ if docker.IsRunningInContainer() && fileExists(config.ContainerEngine.Sock) {
492+ // Check if socket path contains "podman" which indicates it might be a podman socket
493+ if strings.Contains(config.ContainerEngine.Sock, "podman") {
494+ config.ContainerEngine.Podman = true
495+ if printLogs {
496+ logger.Info("Socket path contains 'podman', enabling Podman mode", "path", config.ContainerEngine.Sock)
497+ }
498+ }
499+ }
450500 }
451501 if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" {
452502 config.ContainerEngine.Network = val
@@ -463,6 +513,32 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
463513 }
464514 }
465515
516+ // Additional debug info for container sockets
517+ if docker.IsRunningInContainer() {
518+ logger.Debug("Container socket configuration",
519+ "sock", config.ContainerEngine.Sock,
520+ "podmansock", config.ContainerEngine.PodmanSock,
521+ "podman", config.ContainerEngine.Podman,
522+ "sock_exists", fileExists(config.ContainerEngine.Sock),
523+ "podmansock_exists", fileExists(config.ContainerEngine.PodmanSock))
524+
525+ // Special handling for container environment to ensure sockets are properly set
526+ // If no socket is configured but default socket path exists, use it
527+ if config.ContainerEngine.Sock == "" && fileExists(sock) {
528+ config.ContainerEngine.Sock = sock
529+ logger.Info("Found default Docker socket in container, using it", "path", sock)
530+ }
531+
532+ // If podman socket exists but podman mode is not enabled, check if we should enable it
533+ if fileExists(podmansock) && !config.ContainerEngine.Podman {
534+ // If the main socket path contains "podman", enable podman mode
535+ if strings.Contains(config.ContainerEngine.Sock, "podman") {
536+ config.ContainerEngine.Podman = true
537+ logger.Info("Socket path suggests Podman usage, enabling Podman mode", "path", config.ContainerEngine.Sock)
538+ }
539+ }
540+ }
541+
466542 // Reverse Proxy Configuration
467543 if val := os.Getenv("GORDON_PROXY_PORT"); val != "" {
468544 config.ReverseProxy.Port = val
@@ -113,6 +113,45 @@ func applyDefaultsToConfig(config *Config) bool {
113 logger.Debug("Applied default value for General.LogLevel", "value", defaultLogLevel)113 logger.Debug("Applied default value for General.LogLevel", "value", defaultLogLevel)
114 defaultsApplied = true114 defaultsApplied = true
115 }115 }
116+ if config.General.StorageDir == "" {
117+ // When running in container, use /data as the default storage location
118+ if docker.IsRunningInContainer() {
119+ config.General.StorageDir = "/data"
120+ } else {
121+ // Otherwise, use ~/.gordon
122+ homeDir, err := os.UserHomeDir()
123+ if err != nil {
124+ homeDir = "/tmp"
125+ }
126+ config.General.StorageDir = filepath.Join(homeDir, ".gordon")
127+ }
128+ logger.Debug("Applied default value for General.StorageDir", "value", config.General.StorageDir)
129+ defaultsApplied = true
130+ }
131+
132+ // Apply defaults to Admin config
133+ if config.Admin.Path == "" {
134+ config.Admin.Path = "/admin"
135+ logger.Debug("Applied default value for Admin.Path", "value", "/admin")
136+ defaultsApplied = true
137+ }
138+
139+ // Apply defaults to ContainerEngine config
140+ if config.ContainerEngine.Sock == "" {
141+ config.ContainerEngine.Sock = sock
142+ logger.Debug("Applied default value for ContainerEngine.Sock", "value", sock)
143+ defaultsApplied = true
144+ }
145+ if config.ContainerEngine.PodmanSock == "" {
146+ config.ContainerEngine.PodmanSock = podmansock
147+ logger.Debug("Applied default value for ContainerEngine.PodmanSock", "value", podmansock)
148+ defaultsApplied = true
149+ }
150+ if config.ContainerEngine.Network == "" {
151+ config.ContainerEngine.Network = "gordon"
152+ logger.Debug("Applied default value for ContainerEngine.Network", "value", "gordon")
153+ defaultsApplied = true
154+ }
116 155
117 // Apply defaults to ReverseProxy config156 // Apply defaults to ReverseProxy config
118 if config.ReverseProxy.Port == "" {157 if config.ReverseProxy.Port == "" {
@@ -447,6 +486,17 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
447 "socket", podmanSocket)486 "socket", podmanSocket)
448 }487 }
449 }488 }
489+
490+ // If we're in a container, check for special socket naming patterns
491+ if docker.IsRunningInContainer() && fileExists(config.ContainerEngine.Sock) {
492+ // Check if socket path contains "podman" which indicates it might be a podman socket
493+ if strings.Contains(config.ContainerEngine.Sock, "podman") {
494+ config.ContainerEngine.Podman = true
495+ if printLogs {
496+ logger.Info("Socket path contains 'podman', enabling Podman mode", "path", config.ContainerEngine.Sock)
497+ }
498+ }
499+ }
450 }500 }
451 if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" {501 if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" {
452 config.ContainerEngine.Network = val502 config.ContainerEngine.Network = val
@@ -463,6 +513,32 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
463 }513 }
464 }514 }
465 515
516+ // Additional debug info for container sockets
517+ if docker.IsRunningInContainer() {
518+ logger.Debug("Container socket configuration",
519+ "sock", config.ContainerEngine.Sock,
520+ "podmansock", config.ContainerEngine.PodmanSock,
521+ "podman", config.ContainerEngine.Podman,
522+ "sock_exists", fileExists(config.ContainerEngine.Sock),
523+ "podmansock_exists", fileExists(config.ContainerEngine.PodmanSock))
524+
525+ // Special handling for container environment to ensure sockets are properly set
526+ // If no socket is configured but default socket path exists, use it
527+ if config.ContainerEngine.Sock == "" && fileExists(sock) {
528+ config.ContainerEngine.Sock = sock
529+ logger.Info("Found default Docker socket in container, using it", "path", sock)
530+ }
531+
532+ // If podman socket exists but podman mode is not enabled, check if we should enable it
533+ if fileExists(podmansock) && !config.ContainerEngine.Podman {
534+ // If the main socket path contains "podman", enable podman mode
535+ if strings.Contains(config.ContainerEngine.Sock, "podman") {
536+ config.ContainerEngine.Podman = true
537+ logger.Info("Socket path suggests Podman usage, enabling Podman mode", "path", config.ContainerEngine.Sock)
538+ }
539+ }
540+ }
541+
466 // Reverse Proxy Configuration542 // Reverse Proxy Configuration
467 if val := os.Getenv("GORDON_PROXY_PORT"); val != "" {543 if val := os.Getenv("GORDON_PROXY_PORT"); val != "" {
468 config.ReverseProxy.Port = val544 config.ReverseProxy.Port = val
M internal/proxy/certificates.go +516 -132
@@ -24,6 +24,13 @@ import (
2424 "golang.org/x/crypto/acme/autocert"
2525 )
2626
27+// Certificate naming patterns
28+const (
29+ SelfSignedCertPattern = "%s_self-signed"
30+ LetsEncryptStagingPattern = "%s_letsencrypt-staging"
31+ LetsEncryptProdPattern = "%s_letsencrypt-production"
32+)
33+
2734 // setupCertManager configures the autocert manager for Let's Encrypt
2835 func (p *Proxy) setupCertManager() {
2936 // Create a cache directory if it doesn't exist
@@ -129,9 +136,9 @@ func (p *Proxy) setupCertManager() {
129136
130137 // checkCertificateInCache checks if a valid certificate for the given domain exists in the cache
131138 // Returns true if a valid certificate exists, false otherwise
132-func (p *Proxy) checkCertificateInCache(domain string) bool {
139+func (p *Proxy) checkCertificateInCache(domain string) (bool, string) {
133140 if p.certManager == nil || p.certManager.Cache == nil {
134- return false
141+ return false, ""
135142 }
136143
137144 // The cache key format used by autocert is "cert-" + domain
@@ -148,31 +155,91 @@ func (p *Proxy) checkCertificateInCache(domain string) bool {
148155 logger.Debug("Certificate not found in cache",
149156 "domain", domain,
150157 "error", err)
151- return false
158+ return false, ""
152159 }
153160
154161 // Validate the certificate data
155- validCert, expiresInDays := isCertificateValid(certData, domain)
162+ validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)
156163 if !validCert {
157- return false
164+ return false, certType
158165 }
159166
160167 logger.Info("Valid certificate found in cache",
161168 "domain", domain,
162169 "expires_in", expiresInDays,
163- "days")
164- return true
170+ "days",
171+ "type", certType)
172+
173+ // Check if certificate matches current mode
174+ if p.config.LetsEncryptMode == "production" && certType == "staging" {
175+ logger.Info("Found staging certificate but production mode is enabled",
176+ "domain", domain,
177+ "action", "will request new production certificate")
178+ return false, certType
179+ } else if p.config.LetsEncryptMode == "staging" && certType == "production" {
180+ // We're in staging mode, but we already have a production certificate
181+ // That's fine - keep using the production certificate
182+ logger.Info("Found production certificate while in staging mode",
183+ "domain", domain,
184+ "action", "using existing production certificate")
185+ }
186+
187+ return true, certType
188+}
189+
190+// getCertificateFileName returns the appropriate filename based on certificate type
191+func getCertificateFileName(domain string, certType string) string {
192+ switch certType {
193+ case "self-signed":
194+ return fmt.Sprintf(SelfSignedCertPattern, domain)
195+ case "staging":
196+ return fmt.Sprintf(LetsEncryptStagingPattern, domain)
197+ case "production":
198+ return fmt.Sprintf(LetsEncryptProdPattern, domain)
199+ default:
200+ // Fallback to just the domain name for backward compatibility
201+ return domain
202+ }
203+}
204+
205+// getCertificateType determines the type of certificate by examining its contents
206+func getCertificateType(cert *x509.Certificate) string {
207+ // Check issuer to determine the certificate type
208+ issuer := cert.Issuer.CommonName
209+
210+ // Check for self-signed certificate
211+ if cert.Issuer.CommonName == cert.Subject.CommonName {
212+ return "self-signed"
213+ }
214+
215+ // Check for Let's Encrypt staging certificate
216+ if strings.Contains(issuer, "STAGING") ||
217+ strings.Contains(issuer, "Fake LE") ||
218+ strings.Contains(issuer, "Counterfeit") ||
219+ strings.Contains(issuer, "False Fennel") {
220+ return "staging"
221+ }
222+
223+ // Check for Let's Encrypt production certificate
224+ if strings.Contains(issuer, "Let's Encrypt") ||
225+ strings.Contains(issuer, "R3") ||
226+ strings.Contains(issuer, "E1") {
227+ return "production"
228+ }
229+
230+ // Unknown issuer
231+ return "unknown"
165232 }
166233
167234 // isCertificateValid checks if a certificate PEM data is valid and not expired
168-// Returns whether it's valid and how many days until expiration
169-func isCertificateValid(certData []byte, domain string) (bool, float64) {
235+// Returns whether it's valid and how many days until expiration, and the certificate type
236+func isCertificateValid(certData []byte, domain string) (bool, float64, string, *x509.Certificate) {
170237 // Parse the certificate to check its validity
171238 block, _ := pem.Decode(certData)
172239 if block == nil || block.Type != "CERTIFICATE" {
173240 logger.Warn("Invalid certificate data",
174241 "domain", domain)
175- return false, 0
242+ return false, 0, "unknown", nil
176243 }
177244
178245 cert, err := x509.ParseCertificate(block.Bytes)
@@ -180,17 +247,21 @@ func isCertificateValid(certData []byte, domain string) (bool, float64) {
180247 logger.Warn("Failed to parse certificate",
181248 "domain", domain,
182249 "error", err)
183- return false, 0
250+ return false, 0, "unknown", nil
184251 }
185252
253+ // Determine the certificate type
254+ certType := getCertificateType(cert)
255+
186256 // Check if the certificate is still valid
187257 now := time.Now()
188258 if now.After(cert.NotAfter) || now.Before(cert.NotBefore) {
189259 logger.Info("Certificate has expired or is not yet valid",
190260 "domain", domain,
191261 "not_before", cert.NotBefore,
192- "not_after", cert.NotAfter)
193- return false, 0
262+ "not_after", cert.NotAfter,
263+ "type", certType)
264+ return false, 0, certType, cert
194265 }
195266
196267 // Calculate days until expiration
@@ -201,19 +272,20 @@ func isCertificateValid(certData []byte, domain string) (bool, float64) {
201272 logger.Info("Certificate is valid but will expire soon",
202273 "domain", domain,
203274 "expires_in", expiresInDays,
204- "days")
275+ "days",
276+ "type", certType)
205277 // Return false to trigger renewal if it's about to expire
206- return false, expiresInDays
278+ return false, expiresInDays, certType, cert
207279 }
208280
209- return true, expiresInDays
281+ return true, expiresInDays, certType, cert
210282 }
211283
212284 // checkCertificateInFilesystem checks if valid certificate files exist directly in the certs directory
213285 // This is useful when the database record might be missing but valid certificate files exist
214-func (p *Proxy) checkCertificateInFilesystem(domain string) bool {
286+func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
215287 if p.certManager == nil {
216- return false
288+ return false, ""
217289 }
218290
219291 // Get the certificate directory path
@@ -222,60 +294,112 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) bool {
222294 certDir = p.app.GetConfig().General.StorageDir + "/certs"
223295 }
224296
225- // Check for the certificate file and its corresponding private key
226- certFile := filepath.Join(certDir, domain)
227- keyFile := filepath.Join(certDir, domain+"+rsa")
228-
229- // Check if both files exist
230- if _, err := os.Stat(certFile); os.IsNotExist(err) {
231- logger.Debug("Certificate file not found in filesystem",
232- "domain", domain,
233- "file", certFile)
234- return false
297+ // Check for certificates using different naming patterns
298+ certFiles := []string{
299+ // Standard autocert naming
300+ domain,
301+ // Our naming patterns
302+ getCertificateFileName(domain, "self-signed"),
303+ getCertificateFileName(domain, "staging"),
304+ getCertificateFileName(domain, "production"),
235305 }
236-
237- if _, err := os.Stat(keyFile); os.IsNotExist(err) {
238- logger.Debug("Certificate key file not found in filesystem",
306+
307+ // Check each possible certificate file
308+ for _, baseName := range certFiles {
309+ certFile := filepath.Join(certDir, baseName)
310+ keyFile := filepath.Join(certDir, baseName+"+rsa")
311+
312+ // Skip if either file doesn't exist
313+ if _, err := os.Stat(certFile); os.IsNotExist(err) {
314+ continue
315+ }
316+
317+ if _, err := os.Stat(keyFile); os.IsNotExist(err) {
318+ continue
319+ }
320+
321+ // If we found both files, validate the certificate
322+ certData, err := os.ReadFile(certFile)
323+ if err != nil {
324+ logger.Warn("Failed to read certificate file",
325+ "domain", domain,
326+ "file", certFile,
327+ "error", err)
328+ continue
329+ }
330+
331+ // Validate the certificate data
332+ validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)
333+ if !validCert {
334+ continue
335+ }
336+
337+ logger.Info("Valid certificate found in filesystem",
239338 "domain", domain,
240- "file", keyFile)
241- return false
339+ "expires_in", expiresInDays,
340+ "days",
341+ "type", certType,
342+ "not_in_cache", true)
343+
344+ // Check if certificate matches current mode
345+ if p.config.LetsEncryptMode == "production" && certType == "staging" {
346+ logger.Info("Found staging certificate but production mode is enabled",
347+ "domain", domain,
348+ "action", "will request new production certificate")
349+ // Delete the staging certificate
350+ deleteCertificateFiles(certDir, baseName)
351+ return false, certType
352+ } else if p.config.LetsEncryptMode == "staging" && certType == "production" {
353+ // We're in staging mode, but we already have a production certificate
354+ // That's fine - keep using the production certificate
355+ logger.Info("Found production certificate while in staging mode",
356+ "domain", domain,
357+ "action", "using existing production certificate")
358+ }
359+
360+ // Log some debug information about the certificate files
361+ certFileInfo, _ := os.Stat(certFile)
362+ keyFileInfo, _ := os.Stat(keyFile)
363+ logger.Debug("Certificate file details",
364+ "domain", domain,
365+ "cert_file", certFile,
366+ "cert_size", certFileInfo.Size(),
367+ "cert_modified", certFileInfo.ModTime(),
368+ "key_file", keyFile,
369+ "key_size", keyFileInfo.Size(),
370+ "key_modified", keyFileInfo.ModTime())
371+
372+ return true, certType
242373 }
243374
244- // If we found both files, validate the certificate
245- certData, err := os.ReadFile(certFile)
246- if err != nil {
247- logger.Warn("Failed to read certificate file",
248- "domain", domain,
375+ logger.Debug("No valid certificate files found in filesystem",
376+ "domain", domain,
377+ "searched_patterns", certFiles)
378+ return false, ""
379+}
380+
381+// deleteCertificateFiles removes certificate files from the filesystem
382+func deleteCertificateFiles(certDir string, baseName string) {
383+ certFile := filepath.Join(certDir, baseName)
384+ keyFile := filepath.Join(certDir, baseName+"+rsa")
385+
386+ // Delete certificate file
387+ if err := os.Remove(certFile); err != nil && !os.IsNotExist(err) {
388+ logger.Warn("Failed to delete certificate file",
249389 "file", certFile,
250390 "error", err)
251- return false
391+ } else if !os.IsNotExist(err) {
392+ logger.Info("Deleted certificate file", "file", certFile)
252393 }
253-
254- // Validate the certificate data
255- validCert, expiresInDays := isCertificateValid(certData, domain)
256- if !validCert {
257- return false
394+
395+ // Delete key file
396+ if err := os.Remove(keyFile); err != nil && !os.IsNotExist(err) {
397+ logger.Warn("Failed to delete certificate key file",
398+ "file", keyFile,
399+ "error", err)
400+ } else if !os.IsNotExist(err) {
401+ logger.Info("Deleted certificate key file", "file", keyFile)
258402 }
259-
260- logger.Info("Valid certificate found in filesystem",
261- "domain", domain,
262- "expires_in", expiresInDays,
263- "days",
264- "not_in_cache", true)
265-
266- // Log some debug information about the certificate files
267- certFileInfo, _ := os.Stat(certFile)
268- keyFileInfo, _ := os.Stat(keyFile)
269- logger.Debug("Certificate file details",
270- "domain", domain,
271- "cert_file", certFile,
272- "cert_size", certFileInfo.Size(),
273- "cert_modified", certFileInfo.ModTime(),
274- "key_file", keyFile,
275- "key_size", keyFileInfo.Size(),
276- "key_modified", keyFileInfo.ModTime())
277-
278- return true
279403 }
280404
281405 // testCertificateWithTLSConnection attempts to establish a TLS connection to verify the certificate
@@ -352,23 +476,30 @@ func testCertificateWithTLSConnection(domain string, port string) bool {
352476 }
353477
354478 // checkCertificateExists is a helper function that checks if a valid certificate exists
355-// either in the cache or the filesystem. Returns true if a valid certificate is found.
356-func (p *Proxy) checkCertificateExists(domain string) bool {
479+// either in the cache or the filesystem. Returns true if a valid certificate is found,
480+// along with the certificate type.
481+func (p *Proxy) checkCertificateExists(domain string) (bool, string) {
357482 // First check in the cache
358- if p.checkCertificateInCache(domain) {
359- logger.Debug("Certificate exists in cache", "domain", domain)
360- return true
483+ validInCache, cacheType := p.checkCertificateInCache(domain)
484+ if validInCache {
485+ logger.Debug("Certificate exists in cache",
486+ "domain", domain,
487+ "type", cacheType)
488+ return true, cacheType
361489 }
362490
363491 // If not in cache, check the filesystem
364- if p.checkCertificateInFilesystem(domain) {
365- logger.Debug("Certificate exists in filesystem but not in cache", "domain", domain)
366- return true
492+ validInFS, fsType := p.checkCertificateInFilesystem(domain)
493+ if validInFS {
494+ logger.Debug("Certificate exists in filesystem but not in cache",
495+ "domain", domain,
496+ "type", fsType)
497+ return true, fsType
367498 }
368499
369500 // No valid certificate found
370501 logger.Debug("No valid certificate found for domain", "domain", domain)
371- return false
502+ return false, ""
372503 }
373504
374505 // requestAdminCertificate preemptively requests a Let's Encrypt certificate
@@ -406,40 +537,63 @@ func (p *Proxy) requestAdminCertificate() {
406537 "ips", ipStrings)
407538
408539 // Check if we already have a valid certificate (cache or filesystem)
409- if p.checkCertificateExists(adminDomain) {
410- logger.Info("Using existing certificate",
411- "domain", adminDomain,
412- "action", "skipping Let's Encrypt request to avoid rate limits")
413-
414- // If a valid certificate was found in the filesystem but not in the cache,
415- // try to verify it with a TLS connection (non-blocking)
416- go func() {
417- // Only attempt TLS verification if certificate was found in filesystem
418- // and not in cache (to avoid unnecessary check for cached certificates)
419- if !p.checkCertificateInCache(adminDomain) && p.checkCertificateInFilesystem(adminDomain) {
420- if testCertificateWithTLSConnection(adminDomain, p.config.Port) {
421- logger.Debug("Certificate verification succeeded via TLS connection",
422- "domain", adminDomain,
423- "note", "Certificate is trusted and working properly")
424- } else {
425- logger.Warn("Certificate verification via TLS connection failed",
426- "domain", adminDomain,
427- "note", "Certificate exists but might not be trusted or properly configured")
540+ hasValidCert, certType := p.checkCertificateExists(adminDomain)
541+
542+ // If we have a valid cert that matches our mode, use it
543+ if hasValidCert {
544+ // Check for mode mismatch
545+ if p.config.LetsEncryptMode == "production" && certType == "staging" {
546+ logger.Info("Found staging certificate but production mode is enabled",
547+ "domain", adminDomain,
548+ "action", "requesting new production certificate")
549+ // Continue to request a new certificate
550+ } else {
551+ logger.Info("Using existing certificate",
552+ "domain", adminDomain,
553+ "type", certType,
554+ "action", "skipping Let's Encrypt request to avoid rate limits")
555+
556+ // If a valid certificate was found in the filesystem but not in the cache,
557+ // try to verify it with a TLS connection (non-blocking)
558+ go func() {
559+ // Only attempt TLS verification if certificate was found in filesystem
560+ // and not in cache (to avoid unnecessary check for cached certificates)
561+ validInCache, _ := p.checkCertificateInCache(adminDomain)
562+ validInFS, _ := p.checkCertificateInFilesystem(adminDomain)
563+ if !validInCache && validInFS {
564+ if testCertificateWithTLSConnection(adminDomain, p.config.Port) {
565+ logger.Debug("Certificate verification succeeded via TLS connection",
566+ "domain", adminDomain,
567+ "note", "Certificate is trusted and working properly")
568+ } else {
569+ logger.Warn("Certificate verification via TLS connection failed",
570+ "domain", adminDomain,
571+ "note", "Certificate exists but might not be trusted or properly configured")
572+ }
428573 }
429- }
430- }()
574+ }()
431575
432- return
576+ return
577+ }
578+ }
579+
580+ // Get the certificate directory path for naming the output files
581+ certDir := p.config.CertDir
582+ if certDir == "" {
583+ certDir = p.app.GetConfig().General.StorageDir + "/certs"
584+ }
585+
586+ // Ensure certificate directory exists
587+ if err := os.MkdirAll(certDir, 0755); err != nil {
588+ logger.Warn("Failed to create certificate directory",
589+ "dir", certDir,
590+ "error", err)
433591 }
434592
435593 // Check if environment is production
436- mode := "staging"
594+ mode := p.config.LetsEncryptMode
437595 email := p.config.Email
438596
439- if !p.app.IsDevEnvironment() && email != "" {
440- mode = "production"
441- }
442-
443597 // Log the certificate request intent
444598 logger.Info("Initiating Let's Encrypt certificate request for admin domain",
445599 "domain", adminDomain,
@@ -521,8 +675,61 @@ func (p *Proxy) requestAdminCertificate() {
521675 go p.retryCertificateRequest(adminDomain, 3, 10*time.Second)
522676 }
523677 } else if certResult != nil {
678+ // Rename the certificate files based on the mode
679+ certType := "production"
680+ if p.config.LetsEncryptMode == "staging" {
681+ certType = "staging"
682+ }
683+
684+ // Try to rename the certificate files
685+ oldCertPath := filepath.Join(certDir, adminDomain)
686+ oldKeyPath := filepath.Join(certDir, adminDomain+"+rsa")
687+
688+ newName := getCertificateFileName(adminDomain, certType)
689+ newCertPath := filepath.Join(certDir, newName)
690+ newKeyPath := filepath.Join(certDir, newName+"+rsa")
691+
692+ // Only rename if the files exist and the new names are different
693+ if oldCertPath != newCertPath {
694+ // Check if old cert exists
695+ if _, err := os.Stat(oldCertPath); err == nil {
696+ // Rename cert
697+ if err := os.Rename(oldCertPath, newCertPath); err != nil {
698+ logger.Warn("Failed to rename certificate file",
699+ "domain", adminDomain,
700+ "from", oldCertPath,
701+ "to", newCertPath,
702+ "error", err)
703+ } else {
704+ logger.Info("Renamed certificate file with appropriate type",
705+ "domain", adminDomain,
706+ "type", certType,
707+ "from", oldCertPath,
708+ "to", newCertPath)
709+ }
710+
711+ // Rename key
712+ if _, err := os.Stat(oldKeyPath); err == nil {
713+ if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
714+ logger.Warn("Failed to rename certificate key file",
715+ "domain", adminDomain,
716+ "from", oldKeyPath,
717+ "to", newKeyPath,
718+ "error", err)
719+ } else {
720+ logger.Info("Renamed certificate key file with appropriate type",
721+ "domain", adminDomain,
722+ "type", certType,
723+ "from", oldKeyPath,
724+ "to", newKeyPath)
725+ }
726+ }
727+ }
728+ }
729+
524730 logger.Info("Successfully obtained Let's Encrypt certificate",
525- "domain", adminDomain)
731+ "domain", adminDomain,
732+ "type", certType)
526733 } else {
527734 logger.Error("Unexpected error: received nil certificate but no error",
528735 "domain", adminDomain,
@@ -560,40 +767,63 @@ func (p *Proxy) requestDomainCertificate(domain string) {
560767 "ips", ipStrings)
561768
562769 // Check if we already have a valid certificate (cache or filesystem)
563- if p.checkCertificateExists(domain) {
564- logger.Info("Using existing certificate",
565- "domain", domain,
566- "action", "skipping Let's Encrypt request to avoid rate limits")
567-
568- // If a valid certificate was found in the filesystem but not in the cache,
569- // try to verify it with a TLS connection (non-blocking)
570- go func() {
571- // Only attempt TLS verification if certificate was found in filesystem
572- // and not in cache (to avoid unnecessary check for cached certificates)
573- if !p.checkCertificateInCache(domain) && p.checkCertificateInFilesystem(domain) {
574- if testCertificateWithTLSConnection(domain, p.config.Port) {
575- logger.Debug("Certificate verification succeeded via TLS connection",
576- "domain", domain,
577- "note", "Certificate is trusted and working properly")
578- } else {
579- logger.Warn("Certificate verification via TLS connection failed",
580- "domain", domain,
581- "note", "Certificate exists but might not be trusted or properly configured")
770+ hasValidCert, certType := p.checkCertificateExists(domain)
771+
772+ // If we have a valid cert that matches our mode, use it
773+ if hasValidCert {
774+ // Check for mode mismatch
775+ if p.config.LetsEncryptMode == "production" && certType == "staging" {
776+ logger.Info("Found staging certificate but production mode is enabled",
777+ "domain", domain,
778+ "action", "requesting new production certificate")
779+ // Continue to request a new certificate
780+ } else {
781+ logger.Info("Using existing certificate",
782+ "domain", domain,
783+ "type", certType,
784+ "action", "skipping Let's Encrypt request to avoid rate limits")
785+
786+ // If a valid certificate was found in the filesystem but not in the cache,
787+ // try to verify it with a TLS connection (non-blocking)
788+ go func() {
789+ // Only attempt TLS verification if certificate was found in filesystem
790+ // and not in cache (to avoid unnecessary check for cached certificates)
791+ validInCache, _ := p.checkCertificateInCache(domain)
792+ validInFS, _ := p.checkCertificateInFilesystem(domain)
793+ if !validInCache && validInFS {
794+ if testCertificateWithTLSConnection(domain, p.config.Port) {
795+ logger.Debug("Certificate verification succeeded via TLS connection",
796+ "domain", domain,
797+ "note", "Certificate is trusted and working properly")
798+ } else {
799+ logger.Warn("Certificate verification via TLS connection failed",
800+ "domain", domain,
801+ "note", "Certificate exists but might not be trusted or properly configured")
802+ }
582803 }
583- }
584- }()
804+ }()
585805
586- return
806+ return
807+ }
587808 }
588809
589- // Check if environment is production
590- mode := "staging"
591- email := p.config.Email
592-
593- if !p.app.IsDevEnvironment() && email != "" {
594- mode = "production"
810+ // Get the certificate directory path for naming the output files
811+ certDir := p.config.CertDir
812+ if certDir == "" {
813+ certDir = p.app.GetConfig().General.StorageDir + "/certs"
814+ }
815+
816+ // Ensure certificate directory exists
817+ if err := os.MkdirAll(certDir, 0755); err != nil {
818+ logger.Warn("Failed to create certificate directory",
819+ "dir", certDir,
820+ "error", err)
595821 }
596822
823+ // Use the configured mode
824+ mode := p.config.LetsEncryptMode
825+ email := p.config.Email
826+
597827 // Log the certificate request intent
598828 logger.Info("Initiating Let's Encrypt certificate request for container domain",
599829 "domain", domain,
@@ -675,8 +905,61 @@ func (p *Proxy) requestDomainCertificate(domain string) {
675905 go p.retryCertificateRequest(domain, 3, 10*time.Second)
676906 }
677907 } else if certResult != nil {
908+ // Rename the certificate files based on the mode
909+ certType := "production"
910+ if p.config.LetsEncryptMode == "staging" {
911+ certType = "staging"
912+ }
913+
914+ // Try to rename the certificate files
915+ oldCertPath := filepath.Join(certDir, domain)
916+ oldKeyPath := filepath.Join(certDir, domain+"+rsa")
917+
918+ newName := getCertificateFileName(domain, certType)
919+ newCertPath := filepath.Join(certDir, newName)
920+ newKeyPath := filepath.Join(certDir, newName+"+rsa")
921+
922+ // Only rename if the files exist and the new names are different
923+ if oldCertPath != newCertPath {
924+ // Check if old cert exists
925+ if _, err := os.Stat(oldCertPath); err == nil {
926+ // Rename cert
927+ if err := os.Rename(oldCertPath, newCertPath); err != nil {
928+ logger.Warn("Failed to rename certificate file",
929+ "domain", domain,
930+ "from", oldCertPath,
931+ "to", newCertPath,
932+ "error", err)
933+ } else {
934+ logger.Info("Renamed certificate file with appropriate type",
935+ "domain", domain,
936+ "type", certType,
937+ "from", oldCertPath,
938+ "to", newCertPath)
939+ }
940+
941+ // Rename key
942+ if _, err := os.Stat(oldKeyPath); err == nil {
943+ if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
944+ logger.Warn("Failed to rename certificate key file",
945+ "domain", domain,
946+ "from", oldKeyPath,
947+ "to", newKeyPath,
948+ "error", err)
949+ } else {
950+ logger.Info("Renamed certificate key file with appropriate type",
951+ "domain", domain,
952+ "type", certType,
953+ "from", oldKeyPath,
954+ "to", newKeyPath)
955+ }
956+ }
957+ }
958+ }
959+
678960 logger.Info("Successfully obtained Let's Encrypt certificate",
679- "domain", domain)
961+ "domain", domain,
962+ "type", certType)
680963 } else {
681964 logger.Error("Unexpected error: received nil certificate but no error",
682965 "domain", domain,
@@ -690,6 +973,12 @@ func (p *Proxy) requestDomainCertificate(domain string) {
690973 func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) {
691974 backoff := initialBackoff
692975
976+ // Get the certificate directory for renaming
977+ certDir := p.config.CertDir
978+ if certDir == "" {
979+ certDir = p.app.GetConfig().General.StorageDir + "/certs"
980+ }
981+
693982 for attempt := 1; attempt <= maxRetries; attempt++ {
694983 // Wait for backoff period
695984 logger.Info("Retrying Let's Encrypt certificate request",
@@ -747,9 +1036,62 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
7471036
7481037 // Check result
7491038 if err == nil {
1039+ // Determine certificate type based on mode
1040+ certType := "production"
1041+ if p.config.LetsEncryptMode == "staging" {
1042+ certType = "staging"
1043+ }
1044+
1045+ // Rename the certificate files to include the type
1046+ oldCertPath := filepath.Join(certDir, domain)
1047+ oldKeyPath := filepath.Join(certDir, domain+"+rsa")
1048+
1049+ newName := getCertificateFileName(domain, certType)
1050+ newCertPath := filepath.Join(certDir, newName)
1051+ newKeyPath := filepath.Join(certDir, newName+"+rsa")
1052+
1053+ // Only rename if the files exist and the new names are different
1054+ if oldCertPath != newCertPath {
1055+ // Check if old cert exists
1056+ if _, err := os.Stat(oldCertPath); err == nil {
1057+ // Rename cert
1058+ if err := os.Rename(oldCertPath, newCertPath); err != nil {
1059+ logger.Warn("Failed to rename certificate file",
1060+ "domain", domain,
1061+ "from", oldCertPath,
1062+ "to", newCertPath,
1063+ "error", err)
1064+ } else {
1065+ logger.Info("Renamed certificate file with appropriate type",
1066+ "domain", domain,
1067+ "type", certType,
1068+ "from", oldCertPath,
1069+ "to", newCertPath)
1070+ }
1071+
1072+ // Rename key
1073+ if _, err := os.Stat(oldKeyPath); err == nil {
1074+ if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
1075+ logger.Warn("Failed to rename certificate key file",
1076+ "domain", domain,
1077+ "from", oldKeyPath,
1078+ "to", newKeyPath,
1079+ "error", err)
1080+ } else {
1081+ logger.Info("Renamed certificate key file with appropriate type",
1082+ "domain", domain,
1083+ "type", certType,
1084+ "from", oldKeyPath,
1085+ "to", newKeyPath)
1086+ }
1087+ }
1088+ }
1089+ }
1090+
7501091 logger.Info("Successfully obtained Let's Encrypt certificate on retry",
7511092 "domain", domain,
752- "attempt", attempt)
1093+ "attempt", attempt,
1094+ "type", certType)
7531095 return
7541096 }
7551097
@@ -796,10 +1138,52 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
7961138 backoff *= 2
7971139 }
7981140
1141+ // Generate a self-signed certificate as a fallback
7991142 logger.Error("All Let's Encrypt certificate request retries failed",
8001143 "domain", domain,
8011144 "max_retries", maxRetries,
802- "fallback", "Using self-signed certificate")
1145+ "fallback", "Generating self-signed certificate")
1146+
1147+ // Generate a self-signed certificate for this domain
1148+ fallbackCert, err := generateFallbackCertificates([]string{domain})
1149+ if err != nil {
1150+ logger.Error("Failed to generate self-signed fallback certificate",
1151+ "domain", domain,
1152+ "error", err)
1153+ } else {
1154+ // Save the self-signed certificate to disk with appropriate naming
1155+ selfSignedName := getCertificateFileName(domain, "self-signed")
1156+ certPath := filepath.Join(certDir, selfSignedName)
1157+ keyPath := filepath.Join(certDir, selfSignedName+"+rsa")
1158+
1159+ // Save the certificate and key
1160+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fallbackCert.Certificate[0]})
1161+ keyBytes, err := x509.MarshalPKCS8PrivateKey(fallbackCert.PrivateKey)
1162+ if err == nil {
1163+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
1164+
1165+ // Write files
1166+ if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
1167+ logger.Error("Failed to write self-signed certificate to disk",
1168+ "domain", domain,
1169+ "path", certPath,
1170+ "error", err)
1171+ }
1172+
1173+ if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
1174+ logger.Error("Failed to write self-signed key to disk",
1175+ "domain", domain,
1176+ "path", keyPath,
1177+ "error", err)
1178+ }
1179+
1180+ logger.Info("Generated and saved self-signed fallback certificate",
1181+ "domain", domain,
1182+ "cert_path", certPath,
1183+ "key_path", keyPath,
1184+ "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05"))
1185+ }
1186+ }
8031187
8041188 // At this point, all retries have failed, so we'll rely on the fallback self-signed certificate
8051189 // But let's log this prominently for debugging
@@ -24,6 +24,13 @@ import (
24 "golang.org/x/crypto/acme/autocert"24 "golang.org/x/crypto/acme/autocert"
25 )25 )
26 26
27+// Certificate naming patterns
28+const (
29+ SelfSignedCertPattern = "%s_self-signed"
30+ LetsEncryptStagingPattern = "%s_letsencrypt-staging"
31+ LetsEncryptProdPattern = "%s_letsencrypt-production"
32+)
33+
27 // setupCertManager configures the autocert manager for Let's Encrypt34 // setupCertManager configures the autocert manager for Let's Encrypt
28 func (p *Proxy) setupCertManager() {35 func (p *Proxy) setupCertManager() {
29 // Create a cache directory if it doesn't exist36 // Create a cache directory if it doesn't exist
@@ -129,9 +136,9 @@ func (p *Proxy) setupCertManager() {
129 136
130 // checkCertificateInCache checks if a valid certificate for the given domain exists in the cache137 // checkCertificateInCache checks if a valid certificate for the given domain exists in the cache
131 // Returns true if a valid certificate exists, false otherwise138 // Returns true if a valid certificate exists, false otherwise
132-func (p *Proxy) checkCertificateInCache(domain string) bool {139+func (p *Proxy) checkCertificateInCache(domain string) (bool, string) {
133 if p.certManager == nil || p.certManager.Cache == nil {140 if p.certManager == nil || p.certManager.Cache == nil {
134- return false141+ return false, ""
135 }142 }
136 143
137 // The cache key format used by autocert is "cert-" + domain144 // The cache key format used by autocert is "cert-" + domain
@@ -148,31 +155,91 @@ func (p *Proxy) checkCertificateInCache(domain string) bool {
148 logger.Debug("Certificate not found in cache",155 logger.Debug("Certificate not found in cache",
149 "domain", domain,156 "domain", domain,
150 "error", err)157 "error", err)
151- return false158+ return false, ""
152 }159 }
153 160
154 // Validate the certificate data161 // Validate the certificate data
155- validCert, expiresInDays := isCertificateValid(certData, domain)162+ validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)
156 if !validCert {163 if !validCert {
157- return false164+ return false, certType
158 }165 }
159 166
160 logger.Info("Valid certificate found in cache",167 logger.Info("Valid certificate found in cache",
161 "domain", domain,168 "domain", domain,
162 "expires_in", expiresInDays,169 "expires_in", expiresInDays,
163- "days")170+ "days",
164- return true171+ "type", certType)
172+
173+ // Check if certificate matches current mode
174+ if p.config.LetsEncryptMode == "production" && certType == "staging" {
175+ logger.Info("Found staging certificate but production mode is enabled",
176+ "domain", domain,
177+ "action", "will request new production certificate")
178+ return false, certType
179+ } else if p.config.LetsEncryptMode == "staging" && certType == "production" {
180+ // We're in staging mode, but we already have a production certificate
181+ // That's fine - keep using the production certificate
182+ logger.Info("Found production certificate while in staging mode",
183+ "domain", domain,
184+ "action", "using existing production certificate")
185+ }
186+
187+ return true, certType
188+}
189+
190+// getCertificateFileName returns the appropriate filename based on certificate type
191+func getCertificateFileName(domain string, certType string) string {
192+ switch certType {
193+ case "self-signed":
194+ return fmt.Sprintf(SelfSignedCertPattern, domain)
195+ case "staging":
196+ return fmt.Sprintf(LetsEncryptStagingPattern, domain)
197+ case "production":
198+ return fmt.Sprintf(LetsEncryptProdPattern, domain)
199+ default:
200+ // Fallback to just the domain name for backward compatibility
201+ return domain
202+ }
203+}
204+
205+// getCertificateType determines the type of certificate by examining its contents
206+func getCertificateType(cert *x509.Certificate) string {
207+ // Check issuer to determine the certificate type
208+ issuer := cert.Issuer.CommonName
209+
210+ // Check for self-signed certificate
211+ if cert.Issuer.CommonName == cert.Subject.CommonName {
212+ return "self-signed"
213+ }
214+
215+ // Check for Let's Encrypt staging certificate
216+ if strings.Contains(issuer, "STAGING") ||
217+ strings.Contains(issuer, "Fake LE") ||
218+ strings.Contains(issuer, "Counterfeit") ||
219+ strings.Contains(issuer, "False Fennel") {
220+ return "staging"
221+ }
222+
223+ // Check for Let's Encrypt production certificate
224+ if strings.Contains(issuer, "Let's Encrypt") ||
225+ strings.Contains(issuer, "R3") ||
226+ strings.Contains(issuer, "E1") {
227+ return "production"
228+ }
229+
230+ // Unknown issuer
231+ return "unknown"
165 }232 }
166 233
167 // isCertificateValid checks if a certificate PEM data is valid and not expired234 // isCertificateValid checks if a certificate PEM data is valid and not expired
168-// Returns whether it's valid and how many days until expiration235+// Returns whether it's valid and how many days until expiration, and the certificate type
169-func isCertificateValid(certData []byte, domain string) (bool, float64) {236+func isCertificateValid(certData []byte, domain string) (bool, float64, string, *x509.Certificate) {
170 // Parse the certificate to check its validity237 // Parse the certificate to check its validity
171 block, _ := pem.Decode(certData)238 block, _ := pem.Decode(certData)
172 if block == nil || block.Type != "CERTIFICATE" {239 if block == nil || block.Type != "CERTIFICATE" {
173 logger.Warn("Invalid certificate data",240 logger.Warn("Invalid certificate data",
174 "domain", domain)241 "domain", domain)
175- return false, 0242+ return false, 0, "unknown", nil
176 }243 }
177 244
178 cert, err := x509.ParseCertificate(block.Bytes)245 cert, err := x509.ParseCertificate(block.Bytes)
@@ -180,17 +247,21 @@ func isCertificateValid(certData []byte, domain string) (bool, float64) {
180 logger.Warn("Failed to parse certificate",247 logger.Warn("Failed to parse certificate",
181 "domain", domain,248 "domain", domain,
182 "error", err)249 "error", err)
183- return false, 0250+ return false, 0, "unknown", nil
184 }251 }
185 252
253+ // Determine the certificate type
254+ certType := getCertificateType(cert)
255+
186 // Check if the certificate is still valid256 // Check if the certificate is still valid
187 now := time.Now()257 now := time.Now()
188 if now.After(cert.NotAfter) || now.Before(cert.NotBefore) {258 if now.After(cert.NotAfter) || now.Before(cert.NotBefore) {
189 logger.Info("Certificate has expired or is not yet valid",259 logger.Info("Certificate has expired or is not yet valid",
190 "domain", domain,260 "domain", domain,
191 "not_before", cert.NotBefore,261 "not_before", cert.NotBefore,
192- "not_after", cert.NotAfter)262+ "not_after", cert.NotAfter,
193- return false, 0263+ "type", certType)
264+ return false, 0, certType, cert
194 }265 }
195 266
196 // Calculate days until expiration267 // Calculate days until expiration
@@ -201,19 +272,20 @@ func isCertificateValid(certData []byte, domain string) (bool, float64) {
201 logger.Info("Certificate is valid but will expire soon",272 logger.Info("Certificate is valid but will expire soon",
202 "domain", domain,273 "domain", domain,
203 "expires_in", expiresInDays,274 "expires_in", expiresInDays,
204- "days")275+ "days",
276+ "type", certType)
205 // Return false to trigger renewal if it's about to expire277 // Return false to trigger renewal if it's about to expire
206- return false, expiresInDays278+ return false, expiresInDays, certType, cert
207 }279 }
208 280
209- return true, expiresInDays281+ return true, expiresInDays, certType, cert
210 }282 }
211 283
212 // checkCertificateInFilesystem checks if valid certificate files exist directly in the certs directory284 // checkCertificateInFilesystem checks if valid certificate files exist directly in the certs directory
213 // This is useful when the database record might be missing but valid certificate files exist285 // This is useful when the database record might be missing but valid certificate files exist
214-func (p *Proxy) checkCertificateInFilesystem(domain string) bool {286+func (p *Proxy) checkCertificateInFilesystem(domain string) (bool, string) {
215 if p.certManager == nil {287 if p.certManager == nil {
216- return false288+ return false, ""
217 }289 }
218 290
219 // Get the certificate directory path291 // Get the certificate directory path
@@ -222,60 +294,112 @@ func (p *Proxy) checkCertificateInFilesystem(domain string) bool {
222 certDir = p.app.GetConfig().General.StorageDir + "/certs"294 certDir = p.app.GetConfig().General.StorageDir + "/certs"
223 }295 }
224 296
225- // Check for the certificate file and its corresponding private key297+ // Check for certificates using different naming patterns
226- certFile := filepath.Join(certDir, domain)298+ certFiles := []string{
227- keyFile := filepath.Join(certDir, domain+"+rsa")299+ // Standard autocert naming
228-300+ domain,
229- // Check if both files exist301+ // Our naming patterns
230- if _, err := os.Stat(certFile); os.IsNotExist(err) {302+ getCertificateFileName(domain, "self-signed"),
231- logger.Debug("Certificate file not found in filesystem",303+ getCertificateFileName(domain, "staging"),
232- "domain", domain,304+ getCertificateFileName(domain, "production"),
233- "file", certFile)
234- return false
235 }305 }
236-306+
237- if _, err := os.Stat(keyFile); os.IsNotExist(err) {307+ // Check each possible certificate file
238- logger.Debug("Certificate key file not found in filesystem",308+ for _, baseName := range certFiles {
309+ certFile := filepath.Join(certDir, baseName)
310+ keyFile := filepath.Join(certDir, baseName+"+rsa")
311+
312+ // Skip if either file doesn't exist
313+ if _, err := os.Stat(certFile); os.IsNotExist(err) {
314+ continue
315+ }
316+
317+ if _, err := os.Stat(keyFile); os.IsNotExist(err) {
318+ continue
319+ }
320+
321+ // If we found both files, validate the certificate
322+ certData, err := os.ReadFile(certFile)
323+ if err != nil {
324+ logger.Warn("Failed to read certificate file",
325+ "domain", domain,
326+ "file", certFile,
327+ "error", err)
328+ continue
329+ }
330+
331+ // Validate the certificate data
332+ validCert, expiresInDays, certType, _ := isCertificateValid(certData, domain)
333+ if !validCert {
334+ continue
335+ }
336+
337+ logger.Info("Valid certificate found in filesystem",
239 "domain", domain,338 "domain", domain,
240- "file", keyFile)339+ "expires_in", expiresInDays,
241- return false340+ "days",
341+ "type", certType,
342+ "not_in_cache", true)
343+
344+ // Check if certificate matches current mode
345+ if p.config.LetsEncryptMode == "production" && certType == "staging" {
346+ logger.Info("Found staging certificate but production mode is enabled",
347+ "domain", domain,
348+ "action", "will request new production certificate")
349+ // Delete the staging certificate
350+ deleteCertificateFiles(certDir, baseName)
351+ return false, certType
352+ } else if p.config.LetsEncryptMode == "staging" && certType == "production" {
353+ // We're in staging mode, but we already have a production certificate
354+ // That's fine - keep using the production certificate
355+ logger.Info("Found production certificate while in staging mode",
356+ "domain", domain,
357+ "action", "using existing production certificate")
358+ }
359+
360+ // Log some debug information about the certificate files
361+ certFileInfo, _ := os.Stat(certFile)
362+ keyFileInfo, _ := os.Stat(keyFile)
363+ logger.Debug("Certificate file details",
364+ "domain", domain,
365+ "cert_file", certFile,
366+ "cert_size", certFileInfo.Size(),
367+ "cert_modified", certFileInfo.ModTime(),
368+ "key_file", keyFile,
369+ "key_size", keyFileInfo.Size(),
370+ "key_modified", keyFileInfo.ModTime())
371+
372+ return true, certType
242 }373 }
243 374
244- // If we found both files, validate the certificate375+ logger.Debug("No valid certificate files found in filesystem",
245- certData, err := os.ReadFile(certFile)376+ "domain", domain,
246- if err != nil {377+ "searched_patterns", certFiles)
247- logger.Warn("Failed to read certificate file",378+ return false, ""
248- "domain", domain,379+}
380+
381+// deleteCertificateFiles removes certificate files from the filesystem
382+func deleteCertificateFiles(certDir string, baseName string) {
383+ certFile := filepath.Join(certDir, baseName)
384+ keyFile := filepath.Join(certDir, baseName+"+rsa")
385+
386+ // Delete certificate file
387+ if err := os.Remove(certFile); err != nil && !os.IsNotExist(err) {
388+ logger.Warn("Failed to delete certificate file",
249 "file", certFile,389 "file", certFile,
250 "error", err)390 "error", err)
251- return false391+ } else if !os.IsNotExist(err) {
392+ logger.Info("Deleted certificate file", "file", certFile)
252 }393 }
253-394+
254- // Validate the certificate data395+ // Delete key file
255- validCert, expiresInDays := isCertificateValid(certData, domain)396+ if err := os.Remove(keyFile); err != nil && !os.IsNotExist(err) {
256- if !validCert {397+ logger.Warn("Failed to delete certificate key file",
257- return false398+ "file", keyFile,
399+ "error", err)
400+ } else if !os.IsNotExist(err) {
401+ logger.Info("Deleted certificate key file", "file", keyFile)
258 }402 }
259-
260- logger.Info("Valid certificate found in filesystem",
261- "domain", domain,
262- "expires_in", expiresInDays,
263- "days",
264- "not_in_cache", true)
265-
266- // Log some debug information about the certificate files
267- certFileInfo, _ := os.Stat(certFile)
268- keyFileInfo, _ := os.Stat(keyFile)
269- logger.Debug("Certificate file details",
270- "domain", domain,
271- "cert_file", certFile,
272- "cert_size", certFileInfo.Size(),
273- "cert_modified", certFileInfo.ModTime(),
274- "key_file", keyFile,
275- "key_size", keyFileInfo.Size(),
276- "key_modified", keyFileInfo.ModTime())
277-
278- return true
279 }403 }
280 404
281 // testCertificateWithTLSConnection attempts to establish a TLS connection to verify the certificate405 // testCertificateWithTLSConnection attempts to establish a TLS connection to verify the certificate
@@ -352,23 +476,30 @@ func testCertificateWithTLSConnection(domain string, port string) bool {
352 }476 }
353 477
354 // checkCertificateExists is a helper function that checks if a valid certificate exists478 // checkCertificateExists is a helper function that checks if a valid certificate exists
355-// either in the cache or the filesystem. Returns true if a valid certificate is found.479+// either in the cache or the filesystem. Returns true if a valid certificate is found,
356-func (p *Proxy) checkCertificateExists(domain string) bool {480+// along with the certificate type.
481+func (p *Proxy) checkCertificateExists(domain string) (bool, string) {
357 // First check in the cache482 // First check in the cache
358- if p.checkCertificateInCache(domain) {483+ validInCache, cacheType := p.checkCertificateInCache(domain)
359- logger.Debug("Certificate exists in cache", "domain", domain)484+ if validInCache {
360- return true485+ logger.Debug("Certificate exists in cache",
486+ "domain", domain,
487+ "type", cacheType)
488+ return true, cacheType
361 }489 }
362 490
363 // If not in cache, check the filesystem491 // If not in cache, check the filesystem
364- if p.checkCertificateInFilesystem(domain) {492+ validInFS, fsType := p.checkCertificateInFilesystem(domain)
365- logger.Debug("Certificate exists in filesystem but not in cache", "domain", domain)493+ if validInFS {
366- return true494+ logger.Debug("Certificate exists in filesystem but not in cache",
495+ "domain", domain,
496+ "type", fsType)
497+ return true, fsType
367 }498 }
368 499
369 // No valid certificate found500 // No valid certificate found
370 logger.Debug("No valid certificate found for domain", "domain", domain)501 logger.Debug("No valid certificate found for domain", "domain", domain)
371- return false502+ return false, ""
372 }503 }
373 504
374 // requestAdminCertificate preemptively requests a Let's Encrypt certificate505 // requestAdminCertificate preemptively requests a Let's Encrypt certificate
@@ -406,40 +537,63 @@ func (p *Proxy) requestAdminCertificate() {
406 "ips", ipStrings)537 "ips", ipStrings)
407 538
408 // Check if we already have a valid certificate (cache or filesystem)539 // Check if we already have a valid certificate (cache or filesystem)
409- if p.checkCertificateExists(adminDomain) {540+ hasValidCert, certType := p.checkCertificateExists(adminDomain)
410- logger.Info("Using existing certificate",541+
411- "domain", adminDomain,542+ // If we have a valid cert that matches our mode, use it
412- "action", "skipping Let's Encrypt request to avoid rate limits")543+ if hasValidCert {
413-544+ // Check for mode mismatch
414- // If a valid certificate was found in the filesystem but not in the cache,545+ if p.config.LetsEncryptMode == "production" && certType == "staging" {
415- // try to verify it with a TLS connection (non-blocking)546+ logger.Info("Found staging certificate but production mode is enabled",
416- go func() {547+ "domain", adminDomain,
417- // Only attempt TLS verification if certificate was found in filesystem548+ "action", "requesting new production certificate")
418- // and not in cache (to avoid unnecessary check for cached certificates)549+ // Continue to request a new certificate
419- if !p.checkCertificateInCache(adminDomain) && p.checkCertificateInFilesystem(adminDomain) {550+ } else {
420- if testCertificateWithTLSConnection(adminDomain, p.config.Port) {551+ logger.Info("Using existing certificate",
421- logger.Debug("Certificate verification succeeded via TLS connection",552+ "domain", adminDomain,
422- "domain", adminDomain,553+ "type", certType,
423- "note", "Certificate is trusted and working properly")554+ "action", "skipping Let's Encrypt request to avoid rate limits")
424- } else {555+
425- logger.Warn("Certificate verification via TLS connection failed",556+ // If a valid certificate was found in the filesystem but not in the cache,
426- "domain", adminDomain,557+ // try to verify it with a TLS connection (non-blocking)
427- "note", "Certificate exists but might not be trusted or properly configured")558+ go func() {
559+ // Only attempt TLS verification if certificate was found in filesystem
560+ // and not in cache (to avoid unnecessary check for cached certificates)
561+ validInCache, _ := p.checkCertificateInCache(adminDomain)
562+ validInFS, _ := p.checkCertificateInFilesystem(adminDomain)
563+ if !validInCache && validInFS {
564+ if testCertificateWithTLSConnection(adminDomain, p.config.Port) {
565+ logger.Debug("Certificate verification succeeded via TLS connection",
566+ "domain", adminDomain,
567+ "note", "Certificate is trusted and working properly")
568+ } else {
569+ logger.Warn("Certificate verification via TLS connection failed",
570+ "domain", adminDomain,
571+ "note", "Certificate exists but might not be trusted or properly configured")
572+ }
428 }573 }
429- }574+ }()
430- }()
431 575
432- return576+ return
577+ }
578+ }
579+
580+ // Get the certificate directory path for naming the output files
581+ certDir := p.config.CertDir
582+ if certDir == "" {
583+ certDir = p.app.GetConfig().General.StorageDir + "/certs"
584+ }
585+
586+ // Ensure certificate directory exists
587+ if err := os.MkdirAll(certDir, 0755); err != nil {
588+ logger.Warn("Failed to create certificate directory",
589+ "dir", certDir,
590+ "error", err)
433 }591 }
434 592
435 // Check if environment is production593 // Check if environment is production
436- mode := "staging"594+ mode := p.config.LetsEncryptMode
437 email := p.config.Email595 email := p.config.Email
438 596
439- if !p.app.IsDevEnvironment() && email != "" {
440- mode = "production"
441- }
442-
443 // Log the certificate request intent597 // Log the certificate request intent
444 logger.Info("Initiating Let's Encrypt certificate request for admin domain",598 logger.Info("Initiating Let's Encrypt certificate request for admin domain",
445 "domain", adminDomain,599 "domain", adminDomain,
@@ -521,8 +675,61 @@ func (p *Proxy) requestAdminCertificate() {
521 go p.retryCertificateRequest(adminDomain, 3, 10*time.Second)675 go p.retryCertificateRequest(adminDomain, 3, 10*time.Second)
522 }676 }
523 } else if certResult != nil {677 } else if certResult != nil {
678+ // Rename the certificate files based on the mode
679+ certType := "production"
680+ if p.config.LetsEncryptMode == "staging" {
681+ certType = "staging"
682+ }
683+
684+ // Try to rename the certificate files
685+ oldCertPath := filepath.Join(certDir, adminDomain)
686+ oldKeyPath := filepath.Join(certDir, adminDomain+"+rsa")
687+
688+ newName := getCertificateFileName(adminDomain, certType)
689+ newCertPath := filepath.Join(certDir, newName)
690+ newKeyPath := filepath.Join(certDir, newName+"+rsa")
691+
692+ // Only rename if the files exist and the new names are different
693+ if oldCertPath != newCertPath {
694+ // Check if old cert exists
695+ if _, err := os.Stat(oldCertPath); err == nil {
696+ // Rename cert
697+ if err := os.Rename(oldCertPath, newCertPath); err != nil {
698+ logger.Warn("Failed to rename certificate file",
699+ "domain", adminDomain,
700+ "from", oldCertPath,
701+ "to", newCertPath,
702+ "error", err)
703+ } else {
704+ logger.Info("Renamed certificate file with appropriate type",
705+ "domain", adminDomain,
706+ "type", certType,
707+ "from", oldCertPath,
708+ "to", newCertPath)
709+ }
710+
711+ // Rename key
712+ if _, err := os.Stat(oldKeyPath); err == nil {
713+ if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
714+ logger.Warn("Failed to rename certificate key file",
715+ "domain", adminDomain,
716+ "from", oldKeyPath,
717+ "to", newKeyPath,
718+ "error", err)
719+ } else {
720+ logger.Info("Renamed certificate key file with appropriate type",
721+ "domain", adminDomain,
722+ "type", certType,
723+ "from", oldKeyPath,
724+ "to", newKeyPath)
725+ }
726+ }
727+ }
728+ }
729+
524 logger.Info("Successfully obtained Let's Encrypt certificate",730 logger.Info("Successfully obtained Let's Encrypt certificate",
525- "domain", adminDomain)731+ "domain", adminDomain,
732+ "type", certType)
526 } else {733 } else {
527 logger.Error("Unexpected error: received nil certificate but no error",734 logger.Error("Unexpected error: received nil certificate but no error",
528 "domain", adminDomain,735 "domain", adminDomain,
@@ -560,40 +767,63 @@ func (p *Proxy) requestDomainCertificate(domain string) {
560 "ips", ipStrings)767 "ips", ipStrings)
561 768
562 // Check if we already have a valid certificate (cache or filesystem)769 // Check if we already have a valid certificate (cache or filesystem)
563- if p.checkCertificateExists(domain) {770+ hasValidCert, certType := p.checkCertificateExists(domain)
564- logger.Info("Using existing certificate",771+
565- "domain", domain,772+ // If we have a valid cert that matches our mode, use it
566- "action", "skipping Let's Encrypt request to avoid rate limits")773+ if hasValidCert {
567-774+ // Check for mode mismatch
568- // If a valid certificate was found in the filesystem but not in the cache,775+ if p.config.LetsEncryptMode == "production" && certType == "staging" {
569- // try to verify it with a TLS connection (non-blocking)776+ logger.Info("Found staging certificate but production mode is enabled",
570- go func() {777+ "domain", domain,
571- // Only attempt TLS verification if certificate was found in filesystem778+ "action", "requesting new production certificate")
572- // and not in cache (to avoid unnecessary check for cached certificates)779+ // Continue to request a new certificate
573- if !p.checkCertificateInCache(domain) && p.checkCertificateInFilesystem(domain) {780+ } else {
574- if testCertificateWithTLSConnection(domain, p.config.Port) {781+ logger.Info("Using existing certificate",
575- logger.Debug("Certificate verification succeeded via TLS connection",782+ "domain", domain,
576- "domain", domain,783+ "type", certType,
577- "note", "Certificate is trusted and working properly")784+ "action", "skipping Let's Encrypt request to avoid rate limits")
578- } else {785+
579- logger.Warn("Certificate verification via TLS connection failed",786+ // If a valid certificate was found in the filesystem but not in the cache,
580- "domain", domain,787+ // try to verify it with a TLS connection (non-blocking)
581- "note", "Certificate exists but might not be trusted or properly configured")788+ go func() {
789+ // Only attempt TLS verification if certificate was found in filesystem
790+ // and not in cache (to avoid unnecessary check for cached certificates)
791+ validInCache, _ := p.checkCertificateInCache(domain)
792+ validInFS, _ := p.checkCertificateInFilesystem(domain)
793+ if !validInCache && validInFS {
794+ if testCertificateWithTLSConnection(domain, p.config.Port) {
795+ logger.Debug("Certificate verification succeeded via TLS connection",
796+ "domain", domain,
797+ "note", "Certificate is trusted and working properly")
798+ } else {
799+ logger.Warn("Certificate verification via TLS connection failed",
800+ "domain", domain,
801+ "note", "Certificate exists but might not be trusted or properly configured")
802+ }
582 }803 }
583- }804+ }()
584- }()
585 805
586- return806+ return
807+ }
587 }808 }
588 809
589- // Check if environment is production810+ // Get the certificate directory path for naming the output files
590- mode := "staging"811+ certDir := p.config.CertDir
591- email := p.config.Email812+ if certDir == "" {
592-813+ certDir = p.app.GetConfig().General.StorageDir + "/certs"
593- if !p.app.IsDevEnvironment() && email != "" {814+ }
594- mode = "production"815+
816+ // Ensure certificate directory exists
817+ if err := os.MkdirAll(certDir, 0755); err != nil {
818+ logger.Warn("Failed to create certificate directory",
819+ "dir", certDir,
820+ "error", err)
595 }821 }
596 822
823+ // Use the configured mode
824+ mode := p.config.LetsEncryptMode
825+ email := p.config.Email
826+
597 // Log the certificate request intent827 // Log the certificate request intent
598 logger.Info("Initiating Let's Encrypt certificate request for container domain",828 logger.Info("Initiating Let's Encrypt certificate request for container domain",
599 "domain", domain,829 "domain", domain,
@@ -675,8 +905,61 @@ func (p *Proxy) requestDomainCertificate(domain string) {
675 go p.retryCertificateRequest(domain, 3, 10*time.Second)905 go p.retryCertificateRequest(domain, 3, 10*time.Second)
676 }906 }
677 } else if certResult != nil {907 } else if certResult != nil {
908+ // Rename the certificate files based on the mode
909+ certType := "production"
910+ if p.config.LetsEncryptMode == "staging" {
911+ certType = "staging"
912+ }
913+
914+ // Try to rename the certificate files
915+ oldCertPath := filepath.Join(certDir, domain)
916+ oldKeyPath := filepath.Join(certDir, domain+"+rsa")
917+
918+ newName := getCertificateFileName(domain, certType)
919+ newCertPath := filepath.Join(certDir, newName)
920+ newKeyPath := filepath.Join(certDir, newName+"+rsa")
921+
922+ // Only rename if the files exist and the new names are different
923+ if oldCertPath != newCertPath {
924+ // Check if old cert exists
925+ if _, err := os.Stat(oldCertPath); err == nil {
926+ // Rename cert
927+ if err := os.Rename(oldCertPath, newCertPath); err != nil {
928+ logger.Warn("Failed to rename certificate file",
929+ "domain", domain,
930+ "from", oldCertPath,
931+ "to", newCertPath,
932+ "error", err)
933+ } else {
934+ logger.Info("Renamed certificate file with appropriate type",
935+ "domain", domain,
936+ "type", certType,
937+ "from", oldCertPath,
938+ "to", newCertPath)
939+ }
940+
941+ // Rename key
942+ if _, err := os.Stat(oldKeyPath); err == nil {
943+ if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
944+ logger.Warn("Failed to rename certificate key file",
945+ "domain", domain,
946+ "from", oldKeyPath,
947+ "to", newKeyPath,
948+ "error", err)
949+ } else {
950+ logger.Info("Renamed certificate key file with appropriate type",
951+ "domain", domain,
952+ "type", certType,
953+ "from", oldKeyPath,
954+ "to", newKeyPath)
955+ }
956+ }
957+ }
958+ }
959+
678 logger.Info("Successfully obtained Let's Encrypt certificate",960 logger.Info("Successfully obtained Let's Encrypt certificate",
679- "domain", domain)961+ "domain", domain,
962+ "type", certType)
680 } else {963 } else {
681 logger.Error("Unexpected error: received nil certificate but no error",964 logger.Error("Unexpected error: received nil certificate but no error",
682 "domain", domain,965 "domain", domain,
@@ -690,6 +973,12 @@ func (p *Proxy) requestDomainCertificate(domain string) {
690 func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) {973 func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBackoff time.Duration) {
691 backoff := initialBackoff974 backoff := initialBackoff
692 975
976+ // Get the certificate directory for renaming
977+ certDir := p.config.CertDir
978+ if certDir == "" {
979+ certDir = p.app.GetConfig().General.StorageDir + "/certs"
980+ }
981+
693 for attempt := 1; attempt <= maxRetries; attempt++ {982 for attempt := 1; attempt <= maxRetries; attempt++ {
694 // Wait for backoff period983 // Wait for backoff period
695 logger.Info("Retrying Let's Encrypt certificate request",984 logger.Info("Retrying Let's Encrypt certificate request",
@@ -747,9 +1036,62 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
747 1036
748 // Check result1037 // Check result
749 if err == nil {1038 if err == nil {
1039+ // Determine certificate type based on mode
1040+ certType := "production"
1041+ if p.config.LetsEncryptMode == "staging" {
1042+ certType = "staging"
1043+ }
1044+
1045+ // Rename the certificate files to include the type
1046+ oldCertPath := filepath.Join(certDir, domain)
1047+ oldKeyPath := filepath.Join(certDir, domain+"+rsa")
1048+
1049+ newName := getCertificateFileName(domain, certType)
1050+ newCertPath := filepath.Join(certDir, newName)
1051+ newKeyPath := filepath.Join(certDir, newName+"+rsa")
1052+
1053+ // Only rename if the files exist and the new names are different
1054+ if oldCertPath != newCertPath {
1055+ // Check if old cert exists
1056+ if _, err := os.Stat(oldCertPath); err == nil {
1057+ // Rename cert
1058+ if err := os.Rename(oldCertPath, newCertPath); err != nil {
1059+ logger.Warn("Failed to rename certificate file",
1060+ "domain", domain,
1061+ "from", oldCertPath,
1062+ "to", newCertPath,
1063+ "error", err)
1064+ } else {
1065+ logger.Info("Renamed certificate file with appropriate type",
1066+ "domain", domain,
1067+ "type", certType,
1068+ "from", oldCertPath,
1069+ "to", newCertPath)
1070+ }
1071+
1072+ // Rename key
1073+ if _, err := os.Stat(oldKeyPath); err == nil {
1074+ if err := os.Rename(oldKeyPath, newKeyPath); err != nil {
1075+ logger.Warn("Failed to rename certificate key file",
1076+ "domain", domain,
1077+ "from", oldKeyPath,
1078+ "to", newKeyPath,
1079+ "error", err)
1080+ } else {
1081+ logger.Info("Renamed certificate key file with appropriate type",
1082+ "domain", domain,
1083+ "type", certType,
1084+ "from", oldKeyPath,
1085+ "to", newKeyPath)
1086+ }
1087+ }
1088+ }
1089+ }
1090+
750 logger.Info("Successfully obtained Let's Encrypt certificate on retry",1091 logger.Info("Successfully obtained Let's Encrypt certificate on retry",
751 "domain", domain,1092 "domain", domain,
752- "attempt", attempt)1093+ "attempt", attempt,
1094+ "type", certType)
753 return1095 return
754 }1096 }
755 1097
@@ -796,10 +1138,52 @@ func (p *Proxy) retryCertificateRequest(domain string, maxRetries int, initialBa
796 backoff *= 21138 backoff *= 2
797 }1139 }
798 1140
1141+ // Generate a self-signed certificate as a fallback
799 logger.Error("All Let's Encrypt certificate request retries failed",1142 logger.Error("All Let's Encrypt certificate request retries failed",
800 "domain", domain,1143 "domain", domain,
801 "max_retries", maxRetries,1144 "max_retries", maxRetries,
802- "fallback", "Using self-signed certificate")1145+ "fallback", "Generating self-signed certificate")
1146+
1147+ // Generate a self-signed certificate for this domain
1148+ fallbackCert, err := generateFallbackCertificates([]string{domain})
1149+ if err != nil {
1150+ logger.Error("Failed to generate self-signed fallback certificate",
1151+ "domain", domain,
1152+ "error", err)
1153+ } else {
1154+ // Save the self-signed certificate to disk with appropriate naming
1155+ selfSignedName := getCertificateFileName(domain, "self-signed")
1156+ certPath := filepath.Join(certDir, selfSignedName)
1157+ keyPath := filepath.Join(certDir, selfSignedName+"+rsa")
1158+
1159+ // Save the certificate and key
1160+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: fallbackCert.Certificate[0]})
1161+ keyBytes, err := x509.MarshalPKCS8PrivateKey(fallbackCert.PrivateKey)
1162+ if err == nil {
1163+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
1164+
1165+ // Write files
1166+ if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
1167+ logger.Error("Failed to write self-signed certificate to disk",
1168+ "domain", domain,
1169+ "path", certPath,
1170+ "error", err)
1171+ }
1172+
1173+ if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
1174+ logger.Error("Failed to write self-signed key to disk",
1175+ "domain", domain,
1176+ "path", keyPath,
1177+ "error", err)
1178+ }
1179+
1180+ logger.Info("Generated and saved self-signed fallback certificate",
1181+ "domain", domain,
1182+ "cert_path", certPath,
1183+ "key_path", keyPath,
1184+ "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05"))
1185+ }
1186+ }
803 1187
804 // At this point, all retries have failed, so we'll rely on the fallback self-signed certificate1188 // At this point, all retries have failed, so we'll rely on the fallback self-signed certificate
805 // But let's log this prominently for debugging1189 // But let's log this prominently for debugging
M internal/proxy/containers.go +13 -2
@@ -110,7 +110,13 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
110110 // Check certificates for each domain
111111 for _, domain := range domains {
112112 // Check certificate for the full domain
113- hasCert := p.checkCertificateInCache(domain)
113+ hasCert, certType := p.checkCertificateInCache(domain)
114+
115+ if certType == "staging" && p.config.LetsEncryptMode == "production" {
116+ logger.Info("Found staging certificate but production mode is enabled",
117+ "domain", domain,
118+ "action", "will request new production certificate")
119+ }
114120
115121 if hasCert {
116122 logger.Debug("Certificate exists for domain",
@@ -140,7 +146,12 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
140146
141147 // Only check parent domain if it's not an ordinary TLD
142148 if len(parts) > 2 && !isCommonTLD(parts[len(parts)-1]) {
143- hasCert = p.checkCertificateInCache(parentDomain)
149+ hasCert, certType := p.checkCertificateInCache(parentDomain)
150+ if certType == "staging" && p.config.LetsEncryptMode == "production" {
151+ logger.Info("Found staging certificate but production mode is enabled",
152+ "domain", parentDomain,
153+ "action", "will request new production certificate")
154+ }
144155 if !hasCert {
145156 logger.Info("Certificate not found for parent domain",
146157 "domain", parentDomain,
@@ -110,7 +110,13 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
110 // Check certificates for each domain110 // Check certificates for each domain
111 for _, domain := range domains {111 for _, domain := range domains {
112 // Check certificate for the full domain112 // Check certificate for the full domain
113- hasCert := p.checkCertificateInCache(domain)113+ hasCert, certType := p.checkCertificateInCache(domain)
114+
115+ if certType == "staging" && p.config.LetsEncryptMode == "production" {
116+ logger.Info("Found staging certificate but production mode is enabled",
117+ "domain", domain,
118+ "action", "will request new production certificate")
119+ }
114 120
115 if hasCert {121 if hasCert {
116 logger.Debug("Certificate exists for domain",122 logger.Debug("Certificate exists for domain",
@@ -140,7 +146,12 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
140 146
141 // Only check parent domain if it's not an ordinary TLD147 // Only check parent domain if it's not an ordinary TLD
142 if len(parts) > 2 && !isCommonTLD(parts[len(parts)-1]) {148 if len(parts) > 2 && !isCommonTLD(parts[len(parts)-1]) {
143- hasCert = p.checkCertificateInCache(parentDomain)149+ hasCert, certType := p.checkCertificateInCache(parentDomain)
150+ if certType == "staging" && p.config.LetsEncryptMode == "production" {
151+ logger.Info("Found staging certificate but production mode is enabled",
152+ "domain", parentDomain,
153+ "action", "will request new production certificate")
154+ }
144 if !hasCert {155 if !hasCert {
145 logger.Info("Certificate not found for parent domain",156 logger.Info("Certificate not found for parent domain",
146 "domain", parentDomain,157 "domain", parentDomain,
M internal/webui/public/assets/css/tailwind.css +124 -1251
@@ -1,1429 +1,302 @@
1-/**
2- * Path: internal/ui/public/assets/css/custom.css
3- *
4- * @format
5- */
6-
7-/* Embeded path : assets/css/cutsom.css */
8-
9-*, ::before, ::after {
10- --tw-border-spacing-x: 0;
11- --tw-border-spacing-y: 0;
12- --tw-translate-x: 0;
13- --tw-translate-y: 0;
14- --tw-rotate: 0;
15- --tw-skew-x: 0;
16- --tw-skew-y: 0;
17- --tw-scale-x: 1;
18- --tw-scale-y: 1;
19- --tw-pan-x: ;
20- --tw-pan-y: ;
21- --tw-pinch-zoom: ;
22- --tw-scroll-snap-strictness: proximity;
23- --tw-gradient-from-position: ;
24- --tw-gradient-via-position: ;
25- --tw-gradient-to-position: ;
26- --tw-ordinal: ;
27- --tw-slashed-zero: ;
28- --tw-numeric-figure: ;
29- --tw-numeric-spacing: ;
30- --tw-numeric-fraction: ;
31- --tw-ring-inset: ;
32- --tw-ring-offset-width: 0px;
33- --tw-ring-offset-color: #fff;
34- --tw-ring-color: rgb(59 130 246 / 0.5);
35- --tw-ring-offset-shadow: 0 0 #0000;
36- --tw-ring-shadow: 0 0 #0000;
37- --tw-shadow: 0 0 #0000;
38- --tw-shadow-colored: 0 0 #0000;
39- --tw-blur: ;
40- --tw-brightness: ;
41- --tw-contrast: ;
42- --tw-grayscale: ;
43- --tw-hue-rotate: ;
44- --tw-invert: ;
45- --tw-saturate: ;
46- --tw-sepia: ;
47- --tw-drop-shadow: ;
48- --tw-backdrop-blur: ;
49- --tw-backdrop-brightness: ;
50- --tw-backdrop-contrast: ;
51- --tw-backdrop-grayscale: ;
52- --tw-backdrop-hue-rotate: ;
53- --tw-backdrop-invert: ;
54- --tw-backdrop-opacity: ;
55- --tw-backdrop-saturate: ;
56- --tw-backdrop-sepia: ;
57- --tw-contain-size: ;
58- --tw-contain-layout: ;
59- --tw-contain-paint: ;
60- --tw-contain-style: ;
61-}
62-
63-::backdrop {
64- --tw-border-spacing-x: 0;
65- --tw-border-spacing-y: 0;
66- --tw-translate-x: 0;
67- --tw-translate-y: 0;
68- --tw-rotate: 0;
69- --tw-skew-x: 0;
70- --tw-skew-y: 0;
71- --tw-scale-x: 1;
72- --tw-scale-y: 1;
73- --tw-pan-x: ;
74- --tw-pan-y: ;
75- --tw-pinch-zoom: ;
76- --tw-scroll-snap-strictness: proximity;
77- --tw-gradient-from-position: ;
78- --tw-gradient-via-position: ;
79- --tw-gradient-to-position: ;
80- --tw-ordinal: ;
81- --tw-slashed-zero: ;
82- --tw-numeric-figure: ;
83- --tw-numeric-spacing: ;
84- --tw-numeric-fraction: ;
85- --tw-ring-inset: ;
86- --tw-ring-offset-width: 0px;
87- --tw-ring-offset-color: #fff;
88- --tw-ring-color: rgb(59 130 246 / 0.5);
89- --tw-ring-offset-shadow: 0 0 #0000;
90- --tw-ring-shadow: 0 0 #0000;
91- --tw-shadow: 0 0 #0000;
92- --tw-shadow-colored: 0 0 #0000;
93- --tw-blur: ;
94- --tw-brightness: ;
95- --tw-contrast: ;
96- --tw-grayscale: ;
97- --tw-hue-rotate: ;
98- --tw-invert: ;
99- --tw-saturate: ;
100- --tw-sepia: ;
101- --tw-drop-shadow: ;
102- --tw-backdrop-blur: ;
103- --tw-backdrop-brightness: ;
104- --tw-backdrop-contrast: ;
105- --tw-backdrop-grayscale: ;
106- --tw-backdrop-hue-rotate: ;
107- --tw-backdrop-invert: ;
108- --tw-backdrop-opacity: ;
109- --tw-backdrop-saturate: ;
110- --tw-backdrop-sepia: ;
111- --tw-contain-size: ;
112- --tw-contain-layout: ;
113- --tw-contain-paint: ;
114- --tw-contain-style: ;
115-}
116-
117-/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com
118- */
119-
120-/*
121-1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
122-2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
123-*/
124-
125-*,
126-::before,
127-::after {
128- box-sizing: border-box;
129- /* 1 */
130- border-width: 0;
131- /* 2 */
132- border-style: solid;
133- /* 2 */
134- border-color: #e5e7eb;
135- /* 2 */
136-}
137-
138-::before,
139-::after {
140- --tw-content: '';
141-}
142-
143-/*
144-1. Use a consistent sensible line-height in all browsers.
145-2. Prevent adjustments of font size after orientation changes in iOS.
146-3. Use a more readable tab size.
147-4. Use the user's configured `sans` font-family by default.
148-5. Use the user's configured `sans` font-feature-settings by default.
149-6. Use the user's configured `sans` font-variation-settings by default.
150-7. Disable tap highlights on iOS
151-*/
152-
153-html,
154-:host {
155- line-height: 1.5;
156- /* 1 */
157- -webkit-text-size-adjust: 100%;
158- /* 2 */
159- -moz-tab-size: 4;
160- /* 3 */
161- -o-tab-size: 4;
162- tab-size: 4;
163- /* 3 */
164- font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
165- /* 4 */
166- font-feature-settings: normal;
167- /* 5 */
168- font-variation-settings: normal;
169- /* 6 */
170- -webkit-tap-highlight-color: transparent;
171- /* 7 */
172-}
173-
174-/*
175-1. Remove the margin in all browsers.
176-2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
177-*/
178-
179-body {
180- margin: 0;
181- /* 1 */
182- line-height: inherit;
183- /* 2 */
184-}
185-
186-/*
187-1. Add the correct height in Firefox.
188-2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
189-3. Ensure horizontal rules are visible by default.
190-*/
191-
192-hr {
193- height: 0;
194- /* 1 */
195- color: inherit;
196- /* 2 */
197- border-top-width: 1px;
198- /* 3 */
199-}
200-
201-/*
202-Add the correct text decoration in Chrome, Edge, and Safari.
203-*/
204-
205-abbr:where([title]) {
206- -webkit-text-decoration: underline dotted;
207- text-decoration: underline dotted;
208-}
209-
210-/*
211-Remove the default font size and weight for headings.
212-*/
213-
214-h1,
215-h2,
216-h3,
217-h4,
218-h5,
219-h6 {
220- font-size: inherit;
221- font-weight: inherit;
222-}
223-
224-/*
225-Reset links to optimize for opt-in styling instead of opt-out.
226-*/
227-
228-a {
229- color: inherit;
230- text-decoration: inherit;
231-}
232-
233-/*
234-Add the correct font weight in Edge and Safari.
235-*/
236-
237-b,
238-strong {
239- font-weight: bolder;
240-}
241-
242-/*
243-1. Use the user's configured `mono` font-family by default.
244-2. Use the user's configured `mono` font-feature-settings by default.
245-3. Use the user's configured `mono` font-variation-settings by default.
246-4. Correct the odd `em` font sizing in all browsers.
247-*/
248-
249-code,
250-kbd,
251-samp,
252-pre {
253- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
254- /* 1 */
255- font-feature-settings: normal;
256- /* 2 */
257- font-variation-settings: normal;
258- /* 3 */
259- font-size: 1em;
260- /* 4 */
261-}
262-
263-/*
264-Add the correct font size in all browsers.
265-*/
266-
267-small {
268- font-size: 80%;
269-}
270-
271-/*
272-Prevent `sub` and `sup` elements from affecting the line height in all browsers.
273-*/
274-
275-sub,
276-sup {
277- font-size: 75%;
278- line-height: 0;
279- position: relative;
280- vertical-align: baseline;
281-}
282-
283-sub {
284- bottom: -0.25em;
285-}
286-
287-sup {
288- top: -0.5em;
289-}
290-
291-/*
292-1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
293-2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
294-3. Remove gaps between table borders by default.
295-*/
296-
297-table {
298- text-indent: 0;
299- /* 1 */
300- border-color: inherit;
301- /* 2 */
302- border-collapse: collapse;
303- /* 3 */
304-}
305-
306-/*
307-1. Change the font styles in all browsers.
308-2. Remove the margin in Firefox and Safari.
309-3. Remove default padding in all browsers.
310-*/
311-
312-button,
313-input,
314-optgroup,
315-select,
316-textarea {
317- font-family: inherit;
318- /* 1 */
319- font-feature-settings: inherit;
320- /* 1 */
321- font-variation-settings: inherit;
322- /* 1 */
323- font-size: 100%;
324- /* 1 */
325- font-weight: inherit;
326- /* 1 */
327- line-height: inherit;
328- /* 1 */
329- letter-spacing: inherit;
330- /* 1 */
331- color: inherit;
332- /* 1 */
333- margin: 0;
334- /* 2 */
335- padding: 0;
336- /* 3 */
337-}
338-
339-/*
340-Remove the inheritance of text transform in Edge and Firefox.
341-*/
342-
343-button,
344-select {
345- text-transform: none;
346-}
347-
348-/*
349-1. Correct the inability to style clickable types in iOS and Safari.
350-2. Remove default button styles.
351-*/
352-
353-button,
354-input:where([type='button']),
355-input:where([type='reset']),
356-input:where([type='submit']) {
357- -webkit-appearance: button;
358- /* 1 */
359- background-color: transparent;
360- /* 2 */
361- background-image: none;
362- /* 2 */
363-}
364-
365-/*
366-Use the modern Firefox focus style for all focusable elements.
367-*/
368-
369-:-moz-focusring {
370- outline: auto;
371-}
372-
373-/*
374-Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
375-*/
376-
377-:-moz-ui-invalid {
378- box-shadow: none;
379-}
380-
381-/*
382-Add the correct vertical alignment in Chrome and Firefox.
383-*/
384-
385-progress {
386- vertical-align: baseline;
387-}
388-
389-/*
390-Correct the cursor style of increment and decrement buttons in Safari.
391-*/
392-
393-::-webkit-inner-spin-button,
394-::-webkit-outer-spin-button {
395- height: auto;
396-}
397-
398-/*
399-1. Correct the odd appearance in Chrome and Safari.
400-2. Correct the outline style in Safari.
401-*/
402-
403-[type='search'] {
404- -webkit-appearance: textfield;
405- /* 1 */
406- outline-offset: -2px;
407- /* 2 */
408-}
409-
410-/*
411-Remove the inner padding in Chrome and Safari on macOS.
412-*/
413-
414-::-webkit-search-decoration {
415- -webkit-appearance: none;
416-}
417-
418-/*
419-1. Correct the inability to style clickable types in iOS and Safari.
420-2. Change font properties to `inherit` in Safari.
421-*/
422-
423-::-webkit-file-upload-button {
424- -webkit-appearance: button;
425- /* 1 */
426- font: inherit;
427- /* 2 */
428-}
429-
430-/*
431-Add the correct display in Chrome and Safari.
432-*/
433-
434-summary {
435- display: list-item;
436-}
437-
438-/*
439-Removes the default spacing and border for appropriate elements.
440-*/
441-
442-blockquote,
443-dl,
444-dd,
445-h1,
446-h2,
447-h3,
448-h4,
449-h5,
450-h6,
451-hr,
452-figure,
453-p,
454-pre {
455- margin: 0;
456-}
457-
458-fieldset {
459- margin: 0;
460- padding: 0;
461-}
462-
463-legend {
464- padding: 0;
465-}
466-
467-ol,
468-ul,
469-menu {
470- list-style: none;
471- margin: 0;
472- padding: 0;
473-}
474-
475-/*
476-Reset default styling for dialogs.
477-*/
478-
479-dialog {
480- padding: 0;
481-}
482-
483-/*
484-Prevent resizing textareas horizontally by default.
485-*/
486-
487-textarea {
488- resize: vertical;
489-}
490-
491-/*
492-1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
493-2. Set the default placeholder color to the user's configured gray 400 color.
494-*/
495-
496-input::-moz-placeholder, textarea::-moz-placeholder {
497- opacity: 1;
498- /* 1 */
499- color: #9ca3af;
500- /* 2 */
501-}
502-
503-input::placeholder,
504-textarea::placeholder {
505- opacity: 1;
506- /* 1 */
507- color: #9ca3af;
508- /* 2 */
509-}
510-
511-/*
512-Set the default cursor for buttons.
513-*/
514-
515-button,
516-[role="button"] {
517- cursor: pointer;
518-}
519-
520-/*
521-Make sure disabled buttons don't get the pointer cursor.
522-*/
523-
524-:disabled {
525- cursor: default;
526-}
527-
528-/*
529-1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
530-2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
531- This can trigger a poorly considered lint error in some tools but is included by design.
532-*/
533-
534-img,
535-svg,
536-video,
537-canvas,
538-audio,
539-iframe,
540-embed,
541-object {
542- display: block;
543- /* 1 */
544- vertical-align: middle;
545- /* 2 */
546-}
547-
548-/*
549-Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
550-*/
551-
552-img,
553-video {
554- max-width: 100%;
555- height: auto;
556-}
557-
558-/* Make elements with the HTML hidden attribute stay hidden by default */
559-
560-[hidden]:where(:not([hidden="until-found"])) {
561- display: none;
1+/*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */
2+.visible {
3+ visibility: visible;
5624 }
563-
564-.container {
565- width: 100%;
566-}
567-
568-@media (min-width: 640px) {
569- .container {
570- max-width: 640px;
571- }
5+.absolute {
6+ position: absolute;
5727 }
573-
574-@media (min-width: 768px) {
575- .container {
576- max-width: 768px;
577- }
578-}
579-
580-@media (min-width: 1024px) {
581- .container {
582- max-width: 1024px;
583- }
584-}
585-
586-@media (min-width: 1280px) {
587- .container {
588- max-width: 1280px;
589- }
590-}
591-
592-@media (min-width: 1536px) {
593- .container {
594- max-width: 1536px;
595- }
596-}
597-
5988 .fixed {
5999 position: fixed;
60010 }
601-
60211 .relative {
60312 position: relative;
60413 }
605-
606-.left-0 {
607- left: 0px;
608-}
609-
610-.top-0 {
611- top: 0px;
612-}
613-
614-.mb-1 {
615- margin-bottom: 0.25rem;
616-}
617-
618-.mb-2 {
619- margin-bottom: 0.5rem;
620-}
621-
622-.mb-4 {
623- margin-bottom: 1rem;
624-}
625-
626-.mb-6 {
627- margin-bottom: 1.5rem;
628-}
629-
630-.ml-2 {
631- margin-left: 0.5rem;
632-}
633-
634-.ml-6 {
635- margin-left: 1.5rem;
636-}
637-
638-.ml-7 {
639- margin-left: 1.75rem;
14+.static {
15+ position: static;
64016 }
641-
642-.mt-1 {
643- margin-top: 0.25rem;
17+.z-50 {
18+ z-index: 50;
64419 }
645-
646-.mt-2 {
647- margin-top: 0.5rem;
648-}
649-
650-.mt-4 {
651- margin-top: 1rem;
652-}
653-
654-.mt-8 {
655- margin-top: 2rem;
20+.container {
21+ width: 100%;
65622 }
657-
65823 .block {
65924 display: block;
66025 }
661-
26+.contents {
27+ display: contents;
28+}
66229 .flex {
66330 display: flex;
66431 }
665-
666-.table {
667- display: table;
668-}
669-
67032 .hidden {
67133 display: none;
67234 }
673-
674-.h-12 {
675- height: 3rem;
676-}
677-
678-.h-32 {
679- height: 8rem;
680-}
681-
682-.h-44 {
683- height: 11rem;
35+.inline {
36+ display: inline;
68437 }
685-
686-.h-5 {
687- height: 1.25rem;
38+.inline-block {
39+ display: inline-block;
68840 }
689-
690-.h-8 {
691- height: 2rem;
41+.table {
42+ display: table;
69243 }
693-
69444 .h-full {
69545 height: 100%;
69646 }
697-
69847 .w-1\/3 {
699- width: 33.333333%;
48+ width: calc(1/3 * 100%);
70049 }
701-
70250 .w-1\/4 {
703- width: 25%;
51+ width: calc(1/4 * 100%);
70452 }
705-
70653 .w-2\/3 {
707- width: 66.666667%;
708-}
709-
710-.w-5 {
711- width: 1.25rem;
54+ width: calc(2/3 * 100%);
71255 }
713-
71456 .w-5\/12 {
715- width: 41.666667%;
57+ width: calc(5/12 * 100%);
71658 }
717-
718-.w-8 {
719- width: 2rem;
720-}
721-
72259 .w-full {
72360 width: 100%;
72461 }
725-
726-.max-w-md {
727- max-width: 28rem;
728-}
729-
73062 .flex-1 {
731- flex: 1 1 0%;
63+ flex: 1;
73264 }
733-
73465 .flex-grow {
73566 flex-grow: 1;
73667 }
737-
73868 .table-auto {
73969 table-layout: auto;
74070 }
741-
74271 .cursor-pointer {
74372 cursor: pointer;
74473 }
745-
74+.resize {
75+ resize: both;
76+}
74677 .list-disc {
74778 list-style-type: disc;
74879 }
749-
750-.flex-row {
751- flex-direction: row;
752-}
753-
75480 .flex-col {
75581 flex-direction: column;
75682 }
757-
83+.flex-row {
84+ flex-direction: row;
85+}
75886 .content-center {
75987 align-content: center;
76088 }
761-
76289 .items-center {
76390 align-items: center;
76491 }
765-
766-.justify-start {
767- justify-content: flex-start;
768-}
769-
770-.justify-end {
771- justify-content: flex-end;
92+.justify-between {
93+ justify-content: space-between;
77294 }
773-
77495 .justify-center {
77596 justify-content: center;
77697 }
777-
778-.justify-between {
779- justify-content: space-between;
98+.justify-end {
99+ justify-content: flex-end;
780100 }
781-
782101 .justify-evenly {
783102 justify-content: space-evenly;
784103 }
785-
786-.space-x-2 > :not([hidden]) ~ :not([hidden]) {
787- --tw-space-x-reverse: 0;
788- margin-right: calc(0.5rem * var(--tw-space-x-reverse));
789- margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
790-}
791-
792-.space-x-4 > :not([hidden]) ~ :not([hidden]) {
793- --tw-space-x-reverse: 0;
794- margin-right: calc(1rem * var(--tw-space-x-reverse));
795- margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
796-}
797-
798-.space-y-4 > :not([hidden]) ~ :not([hidden]) {
799- --tw-space-y-reverse: 0;
800- margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
801- margin-bottom: calc(1rem * var(--tw-space-y-reverse));
802-}
803-
804-.space-y-6 > :not([hidden]) ~ :not([hidden]) {
805- --tw-space-y-reverse: 0;
806- margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
807- margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
808-}
809-
810-.rounded {
811- border-radius: 0.25rem;
812-}
813-
814-.rounded-full {
815- border-radius: 9999px;
816-}
817-
818-.rounded-md {
819- border-radius: 0.375rem;
104+.justify-start {
105+ justify-content: flex-start;
820106 }
821-
822-.rounded-sm {
823- border-radius: 0.125rem;
107+.rounded-full {
108+ border-radius: calc(infinity * 1px);
824109 }
825-
826110 .border {
111+ border-style: var(--tw-border-style);
827112 border-width: 1px;
828113 }
829-
830114 .border-2 {
115+ border-style: var(--tw-border-style);
831116 border-width: 2px;
832117 }
833-
834118 .border-t-2 {
119+ border-top-style: var(--tw-border-style);
835120 border-top-width: 2px;
836121 }
837-
838-.border-gray-300 {
839- --tw-border-opacity: 1;
840- border-color: rgb(209 213 219 / var(--tw-border-opacity));
841-}
842-
843-.border-red-400 {
844- --tw-border-opacity: 1;
845- border-color: rgb(248 113 113 / var(--tw-border-opacity));
846-}
847-
848-.bg-blue-500 {
849- --tw-bg-opacity: 1;
850- background-color: rgb(59 130 246 / var(--tw-bg-opacity));
851-}
852-
853-.bg-gordonmascot-hblue {
854- --tw-bg-opacity: 1;
855- background-color: rgb(1 30 92 / var(--tw-bg-opacity));
856-}
857-
858-.bg-gordonmascot-lblue {
859- --tw-bg-opacity: 1;
860- background-color: rgb(0 123 192 / var(--tw-bg-opacity));
861-}
862-
863-.bg-gordonmascot-mblue {
864- --tw-bg-opacity: 1;
865- background-color: rgb(0 82 147 / var(--tw-bg-opacity));
866-}
867-
868-.bg-gray-100 {
869- --tw-bg-opacity: 1;
870- background-color: rgb(243 244 246 / var(--tw-bg-opacity));
871-}
872-
873-.bg-gray-300 {
874- --tw-bg-opacity: 1;
875- background-color: rgb(209 213 219 / var(--tw-bg-opacity));
876-}
877-
878-.bg-green-500 {
879- --tw-bg-opacity: 1;
880- background-color: rgb(34 197 94 / var(--tw-bg-opacity));
881-}
882-
883-.bg-red-100 {
884- --tw-bg-opacity: 1;
885- background-color: rgb(254 226 226 / var(--tw-bg-opacity));
886-}
887-
888-.bg-slate-100 {
889- --tw-bg-opacity: 1;
890- background-color: rgb(241 245 249 / var(--tw-bg-opacity));
891-}
892-
893-.bg-slate-200 {
894- --tw-bg-opacity: 1;
895- background-color: rgb(226 232 240 / var(--tw-bg-opacity));
896-}
897-
898122 .bg-transparent {
899123 background-color: transparent;
900124 }
901-
902-.bg-white {
903- --tw-bg-opacity: 1;
904- background-color: rgb(255 255 255 / var(--tw-bg-opacity));
905-}
906-
907-.bg-zinc-200 {
908- --tw-bg-opacity: 1;
909- background-color: rgb(228 228 231 / var(--tw-bg-opacity));
910-}
911-
912-.p-0 {
913- padding: 0px;
914-}
915-
916-.p-2 {
917- padding: 0.5rem;
918-}
919-
920-.p-4 {
921- padding: 1rem;
922-}
923-
924-.px-2 {
925- padding-left: 0.5rem;
926- padding-right: 0.5rem;
927-}
928-
929-.px-3 {
930- padding-left: 0.75rem;
931- padding-right: 0.75rem;
932-}
933-
934-.px-4 {
935- padding-left: 1rem;
936- padding-right: 1rem;
937-}
938-
939-.px-8 {
940- padding-left: 2rem;
941- padding-right: 2rem;
942-}
943-
944-.py-1 {
945- padding-top: 0.25rem;
946- padding-bottom: 0.25rem;
947-}
948-
949-.py-2 {
950- padding-top: 0.5rem;
951- padding-bottom: 0.5rem;
952-}
953-
954-.py-3 {
955- padding-top: 0.75rem;
956- padding-bottom: 0.75rem;
957-}
958-
959-.pb-4 {
960- padding-bottom: 1rem;
961-}
962-
963-.pr-1 {
964- padding-right: 0.25rem;
965-}
966-
967-.pt-16 {
968- padding-top: 4rem;
969-}
970-
971125 .text-center {
972126 text-align: center;
973127 }
974-
975128 .align-middle {
976129 vertical-align: middle;
977130 }
978-
979-.text-2xl {
980- font-size: 1.5rem;
981- line-height: 2rem;
982-}
983-
984-.text-3xl {
985- font-size: 1.875rem;
986- line-height: 2.25rem;
987-}
988-
989-.text-4xl {
990- font-size: 2.25rem;
991- line-height: 2.5rem;
992-}
993-
994-.text-5xl {
995- font-size: 3rem;
996- line-height: 1;
997-}
998-
999-.text-lg {
1000- font-size: 1.125rem;
1001- line-height: 1.75rem;
1002-}
1003-
1004-.text-sm {
1005- font-size: 0.875rem;
1006- line-height: 1.25rem;
1007-}
1008-
1009-.text-xl {
1010- font-size: 1.25rem;
1011- line-height: 1.75rem;
1012-}
1013-
1014-.text-xs {
1015- font-size: 0.75rem;
1016- line-height: 1rem;
1017-}
1018-
1019-.font-bold {
1020- font-weight: 700;
1021-}
1022-
1023-.font-medium {
1024- font-weight: 500;
1025-}
1026-
1027-.font-semibold {
1028- font-weight: 600;
1029-}
1030-
1031-.tracking-tighter {
1032- letter-spacing: -0.05em;
1033-}
1034-
1035-.text-blue-600 {
1036- --tw-text-opacity: 1;
1037- color: rgb(37 99 235 / var(--tw-text-opacity));
1038-}
1039-
1040-.text-blue-800 {
1041- --tw-text-opacity: 1;
1042- color: rgb(30 64 175 / var(--tw-text-opacity));
1043-}
1044-
1045-.text-gordonmascot-hblue {
1046- --tw-text-opacity: 1;
1047- color: rgb(1 30 92 / var(--tw-text-opacity));
1048-}
1049-
1050-.text-gordonmascot-mblue {
1051- --tw-text-opacity: 1;
1052- color: rgb(0 82 147 / var(--tw-text-opacity));
1053-}
1054-
1055-.text-gray-100 {
1056- --tw-text-opacity: 1;
1057- color: rgb(243 244 246 / var(--tw-text-opacity));
1058-}
1059-
1060-.text-gray-400 {
1061- --tw-text-opacity: 1;
1062- color: rgb(156 163 175 / var(--tw-text-opacity));
1063-}
1064-
1065-.text-gray-500 {
1066- --tw-text-opacity: 1;
1067- color: rgb(107 114 128 / var(--tw-text-opacity));
1068-}
1069-
1070-.text-gray-700 {
1071- --tw-text-opacity: 1;
1072- color: rgb(55 65 81 / var(--tw-text-opacity));
1073-}
1074-
1075-.text-gray-800 {
1076- --tw-text-opacity: 1;
1077- color: rgb(31 41 55 / var(--tw-text-opacity));
1078-}
1079-
1080-.text-green-300 {
1081- --tw-text-opacity: 1;
1082- color: rgb(134 239 172 / var(--tw-text-opacity));
1083-}
1084-
1085-.text-red-500 {
1086- --tw-text-opacity: 1;
1087- color: rgb(239 68 68 / var(--tw-text-opacity));
1088-}
1089-
1090-.text-red-600 {
1091- --tw-text-opacity: 1;
1092- color: rgb(220 38 38 / var(--tw-text-opacity));
1093-}
1094-
1095-.text-red-700 {
1096- --tw-text-opacity: 1;
1097- color: rgb(185 28 28 / var(--tw-text-opacity));
1098-}
1099-
1100-.text-white {
1101- --tw-text-opacity: 1;
1102- color: rgb(255 255 255 / var(--tw-text-opacity));
1103-}
1104-
1105-.shadow {
1106- --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
1107- --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
1108- box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1109-}
1110-
1111-.shadow-sm {
1112- --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
1113- --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
1114- box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1115-}
1116-
1117-.outline-none {
1118- outline: 2px solid transparent;
1119- outline-offset: 2px;
131+.opacity-0 {
132+ opacity: 0%;
133+}
134+.filter {
135+ 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,);
1120136 }
1121-
1122137 .transition {
1123- transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
1124- transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
1125- transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
1126- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
1127- transition-duration: 150ms;
138+ 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;
139+ transition-timing-function: var(--tw-ease, ease);
140+ transition-duration: var(--tw-duration, 0s);
141+}
142+.transition-opacity {
143+ transition-property: opacity;
144+ transition-timing-function: var(--tw-ease, ease);
145+ transition-duration: var(--tw-duration, 0s);
1128146 }
1129-
1130147 .duration-150 {
148+ --tw-duration: 150ms;
1131149 transition-duration: 150ms;
1132150 }
1133-
1134-.ease-in-out {
1135- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
151+.duration-300 {
152+ --tw-duration: 300ms;
153+ transition-duration: 300ms;
154+}
155+.outline-none {
156+ --tw-outline-style: none;
157+ outline-style: none;
158+}
159+.\[image\:tag\] {
160+ image: tag;
161+}
162+.focus\:outline-none {
163+ &:focus {
164+ --tw-outline-style: none;
165+ outline-style: none;
166+ }
1136167 }
1137-
1138-/* Import IBM Plex Fonts */
1139-
1140168 @font-face {
1141169 font-family: "IBMPlexSans-Bold";
1142-
1143170 src: url("/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2") format("woff2");
1144-
1145171 font-weight: bold;
1146-
1147172 font-style: normal;
1148173 }
1149-
1150174 @font-face {
1151175 font-family: "IBMPlexSans-Regular";
1152-
1153176 src: url("/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2") format("woff2");
1154-
1155177 font-weight: normal;
1156-
1157178 font-style: normal;
1158179 }
1159-
1160-/* Import Jetbrains Mono Nerd Font */
1161-
1162180 @font-face {
1163181 font-family: "JetBrainsMono-Nerd-Font-Regular";
1164-
1165- src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2")
1166- format("woff2");
1167-
182+ src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2") format("woff2");
1168183 font-weight: normal;
1169-
1170184 font-style: normal;
1171185 }
1172-
1173-/* Import Jetbrains Mono Nerd Font Bold */
1174-
1175186 @font-face {
1176187 font-family: "JetBrainsMono-Nerd-Font-Bold";
1177-
1178- src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2")
1179- format("woff2");
1180-
188+ src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2") format("woff2");
1181189 font-weight: bold;
1182-
1183190 font-style: normal;
1184191 }
1185-
1186192 @font-face {
1187193 font-family: "LilitaOne-Regular";
1188-
1189194 src: url("/assets/fonts/LilitaOne/LilitaOne-Regular.ttf") format("truetype");
1190-
1191195 font-weight: normal;
1192-
1193196 font-style: normal;
1194197 }
1195-
1196198 .fade-me-out.htmx-swapping {
1197199 opacity: 0;
1198200 transition: opacity 1s ease-out;
1199201 }
1200-
1201-/* View transition */
1202-
1203202 @keyframes fade-in {
1204203 from {
1205204 opacity: 0;
1206205 }
1207206 }
1208-
1209207 @keyframes fade-out {
1210208 to {
1211209 opacity: 0;
1212210 }
1213211 }
1214-
1215212 @keyframes slide-from-right {
1216213 from {
1217214 transform: translateX(90px);
1218215 }
1219216 }
1220-
1221217 @keyframes slide-to-left {
1222218 to {
1223219 transform: translateX(-90px);
1224220 }
1225221 }
1226-
1227222 @keyframes wiggle {
1228- 0%,
1229- 100% {
223+ 0%, 100% {
1230224 transform: rotate(-3deg);
1231225 }
1232-
1233226 50% {
1234227 transform: rotate(3deg);
1235228 }
1236229 }
1237-
1238230 #update-available {
1239231 animation: wiggle 1s ease-in-out infinite;
1240232 }
1241-
1242233 .slide-it {
1243234 view-transition-name: slide-it;
1244235 }
1245-
1246236 ::view-transition-old(slide-it) {
1247- animation:
1248- 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
1249- 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
237+ animation: 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
1250238 }
1251-
1252239 ::view-transition-new(slide-it) {
1253- animation:
1254- 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
1255- 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
240+ animation: 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
1256241 }
1257-
1258242 .gordon-title {
1259243 font-family: "LilitaOne-Regular", sans-serif;
1260244 }
1261-
1262245 .IBMPlexSans {
1263246 font-family: "IBMPlexSans-Regular", sans-serif;
1264247 }
1265-
1266-.JetBrainsMonoNF, .iconf{
248+.JetBrainsMonoNF, .iconf {
1267249 font-family: "JetBrainsMono-Nerd-Font-Regular", sans-serif;
1268250 }
1269-
1270-.JetBrainsMonoBoldNF, .iconfb{
251+.JetBrainsMonoBoldNF, .iconfb {
1271252 font-family: "JetBrainsMono-Nerd-Font-Bold", sans-serif;
1272253 }
1273-
1274-.hover\:bg-blue-700:hover {
1275- --tw-bg-opacity: 1;
1276- background-color: rgb(29 78 216 / var(--tw-bg-opacity));
1277-}
1278-
1279-.hover\:bg-gordonmascot-hblue:hover {
1280- --tw-bg-opacity: 1;
1281- background-color: rgb(1 30 92 / var(--tw-bg-opacity));
1282-}
1283-
1284-.hover\:bg-gordonmascot-mblue:hover {
1285- --tw-bg-opacity: 1;
1286- background-color: rgb(0 82 147 / var(--tw-bg-opacity));
1287-}
1288-
1289-.hover\:bg-slate-100:hover {
1290- --tw-bg-opacity: 1;
1291- background-color: rgb(241 245 249 / var(--tw-bg-opacity));
1292-}
1293-
1294-.hover\:text-gordonmascot-hblue:hover {
1295- --tw-text-opacity: 1;
1296- color: rgb(1 30 92 / var(--tw-text-opacity));
1297-}
1298-
1299-.hover\:text-red-900:hover {
1300- --tw-text-opacity: 1;
1301- color: rgb(127 29 29 / var(--tw-text-opacity));
1302-}
1303-
1304-.focus\:border-gordonmascot-hblue:focus {
1305- --tw-border-opacity: 1;
1306- border-color: rgb(1 30 92 / var(--tw-border-opacity));
1307-}
1308-
1309-.focus\:outline-none:focus {
1310- outline: 2px solid transparent;
1311- outline-offset: 2px;
1312-}
1313-
1314-.focus\:ring-gordonmascot-hblue:focus {
1315- --tw-ring-opacity: 1;
1316- --tw-ring-color: rgb(1 30 92 / var(--tw-ring-opacity));
1317-}
1318-
1319-@media (min-width: 640px) {
1320- .sm\:inline {
1321- display: inline;
1322- }
1323-
1324- .sm\:h-24 {
1325- height: 6rem;
1326- }
254+@property --tw-border-style {
255+ syntax: "*";
256+ inherits: false;
257+ initial-value: solid;
1327258 }
1328-
1329-@media (min-width: 768px) {
1330- .md\:ml-4 {
1331- margin-left: 1rem;
1332- }
1333-
1334- .md\:h-16 {
1335- height: 4rem;
1336- }
1337-
1338- .md\:w-2\/6 {
1339- width: 33.333333%;
1340- }
1341-
1342- .md\:flex-row {
1343- flex-direction: row;
1344- }
1345-
1346- .md\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
1347- --tw-space-x-reverse: 0;
1348- margin-right: calc(1rem * var(--tw-space-x-reverse));
1349- margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
1350- }
1351-
1352- .md\:space-y-0 > :not([hidden]) ~ :not([hidden]) {
1353- --tw-space-y-reverse: 0;
1354- margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
1355- margin-bottom: calc(0px * var(--tw-space-y-reverse));
1356- }
1357-
1358- .md\:rounded-md {
1359- border-radius: 0.375rem;
1360- }
1361-
1362- .md\:p-0 {
1363- padding: 0px;
1364- }
1365-
1366- .md\:p-8 {
1367- padding: 2rem;
1368- }
1369-
1370- .md\:px-6 {
1371- padding-left: 1.5rem;
1372- padding-right: 1.5rem;
1373- }
1374-
1375- .md\:pt-10 {
1376- padding-top: 2.5rem;
1377- }
1378-
1379- .md\:pt-32 {
1380- padding-top: 8rem;
1381- }
1382-
1383- .md\:text-4xl {
1384- font-size: 2.25rem;
1385- line-height: 2.5rem;
1386- }
1387-
1388- .md\:text-sm {
1389- font-size: 0.875rem;
1390- line-height: 1.25rem;
1391- }
1392-
1393- .md\:text-xl {
1394- font-size: 1.25rem;
1395- line-height: 1.75rem;
1396- }
1397-
1398- .md\:shadow {
1399- --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
1400- --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
1401- box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1402- }
259+@property --tw-blur {
260+ syntax: "*";
261+ inherits: false;
1403262 }
1404-
1405-@media (min-width: 1024px) {
1406- .lg\:flex-row {
1407- flex-direction: row;
1408- }
1409-
1410- .lg\:text-4xl {
1411- font-size: 2.25rem;
1412- line-height: 2.5rem;
1413- }
263+@property --tw-brightness {
264+ syntax: "*";
265+ inherits: false;
1414266 }
1415-
1416-@media (min-width: 1280px) {
1417- .xl\:h-12 {
1418- height: 3rem;
1419- }
1420-
1421- .xl\:h-48 {
1422- height: 12rem;
1423- }
1424-
1425- .xl\:text-5xl {
1426- font-size: 3rem;
1427- line-height: 1;
1428- }
267+@property --tw-contrast {
268+ syntax: "*";
269+ inherits: false;
270+}
271+@property --tw-grayscale {
272+ syntax: "*";
273+ inherits: false;
274+}
275+@property --tw-hue-rotate {
276+ syntax: "*";
277+ inherits: false;
278+}
279+@property --tw-invert {
280+ syntax: "*";
281+ inherits: false;
282+}
283+@property --tw-opacity {
284+ syntax: "*";
285+ inherits: false;
286+}
287+@property --tw-saturate {
288+ syntax: "*";
289+ inherits: false;
290+}
291+@property --tw-sepia {
292+ syntax: "*";
293+ inherits: false;
294+}
295+@property --tw-drop-shadow {
296+ syntax: "*";
297+ inherits: false;
298+}
299+@property --tw-duration {
300+ syntax: "*";
301+ inherits: false;
1429302 }
@@ -1,1429 +1,302 @@
1-/**1+/*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */
2- * Path: internal/ui/public/assets/css/custom.css2+.visible {
3- *3+ visibility: visible;
4- * @format
5- */
6-
7-/* Embeded path : assets/css/cutsom.css */
8-
9-*, ::before, ::after {
10- --tw-border-spacing-x: 0;
11- --tw-border-spacing-y: 0;
12- --tw-translate-x: 0;
13- --tw-translate-y: 0;
14- --tw-rotate: 0;
15- --tw-skew-x: 0;
16- --tw-skew-y: 0;
17- --tw-scale-x: 1;
18- --tw-scale-y: 1;
19- --tw-pan-x: ;
20- --tw-pan-y: ;
21- --tw-pinch-zoom: ;
22- --tw-scroll-snap-strictness: proximity;
23- --tw-gradient-from-position: ;
24- --tw-gradient-via-position: ;
25- --tw-gradient-to-position: ;
26- --tw-ordinal: ;
27- --tw-slashed-zero: ;
28- --tw-numeric-figure: ;
29- --tw-numeric-spacing: ;
30- --tw-numeric-fraction: ;
31- --tw-ring-inset: ;
32- --tw-ring-offset-width: 0px;
33- --tw-ring-offset-color: #fff;
34- --tw-ring-color: rgb(59 130 246 / 0.5);
35- --tw-ring-offset-shadow: 0 0 #0000;
36- --tw-ring-shadow: 0 0 #0000;
37- --tw-shadow: 0 0 #0000;
38- --tw-shadow-colored: 0 0 #0000;
39- --tw-blur: ;
40- --tw-brightness: ;
41- --tw-contrast: ;
42- --tw-grayscale: ;
43- --tw-hue-rotate: ;
44- --tw-invert: ;
45- --tw-saturate: ;
46- --tw-sepia: ;
47- --tw-drop-shadow: ;
48- --tw-backdrop-blur: ;
49- --tw-backdrop-brightness: ;
50- --tw-backdrop-contrast: ;
51- --tw-backdrop-grayscale: ;
52- --tw-backdrop-hue-rotate: ;
53- --tw-backdrop-invert: ;
54- --tw-backdrop-opacity: ;
55- --tw-backdrop-saturate: ;
56- --tw-backdrop-sepia: ;
57- --tw-contain-size: ;
58- --tw-contain-layout: ;
59- --tw-contain-paint: ;
60- --tw-contain-style: ;
61-}
62-
63-::backdrop {
64- --tw-border-spacing-x: 0;
65- --tw-border-spacing-y: 0;
66- --tw-translate-x: 0;
67- --tw-translate-y: 0;
68- --tw-rotate: 0;
69- --tw-skew-x: 0;
70- --tw-skew-y: 0;
71- --tw-scale-x: 1;
72- --tw-scale-y: 1;
73- --tw-pan-x: ;
74- --tw-pan-y: ;
75- --tw-pinch-zoom: ;
76- --tw-scroll-snap-strictness: proximity;
77- --tw-gradient-from-position: ;
78- --tw-gradient-via-position: ;
79- --tw-gradient-to-position: ;
80- --tw-ordinal: ;
81- --tw-slashed-zero: ;
82- --tw-numeric-figure: ;
83- --tw-numeric-spacing: ;
84- --tw-numeric-fraction: ;
85- --tw-ring-inset: ;
86- --tw-ring-offset-width: 0px;
87- --tw-ring-offset-color: #fff;
88- --tw-ring-color: rgb(59 130 246 / 0.5);
89- --tw-ring-offset-shadow: 0 0 #0000;
90- --tw-ring-shadow: 0 0 #0000;
91- --tw-shadow: 0 0 #0000;
92- --tw-shadow-colored: 0 0 #0000;
93- --tw-blur: ;
94- --tw-brightness: ;
95- --tw-contrast: ;
96- --tw-grayscale: ;
97- --tw-hue-rotate: ;
98- --tw-invert: ;
99- --tw-saturate: ;
100- --tw-sepia: ;
101- --tw-drop-shadow: ;
102- --tw-backdrop-blur: ;
103- --tw-backdrop-brightness: ;
104- --tw-backdrop-contrast: ;
105- --tw-backdrop-grayscale: ;
106- --tw-backdrop-hue-rotate: ;
107- --tw-backdrop-invert: ;
108- --tw-backdrop-opacity: ;
109- --tw-backdrop-saturate: ;
110- --tw-backdrop-sepia: ;
111- --tw-contain-size: ;
112- --tw-contain-layout: ;
113- --tw-contain-paint: ;
114- --tw-contain-style: ;
115-}
116-
117-/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com
118- */
119-
120-/*
121-1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
122-2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
123-*/
124-
125-*,
126-::before,
127-::after {
128- box-sizing: border-box;
129- /* 1 */
130- border-width: 0;
131- /* 2 */
132- border-style: solid;
133- /* 2 */
134- border-color: #e5e7eb;
135- /* 2 */
136-}
137-
138-::before,
139-::after {
140- --tw-content: '';
141-}
142-
143-/*
144-1. Use a consistent sensible line-height in all browsers.
145-2. Prevent adjustments of font size after orientation changes in iOS.
146-3. Use a more readable tab size.
147-4. Use the user's configured `sans` font-family by default.
148-5. Use the user's configured `sans` font-feature-settings by default.
149-6. Use the user's configured `sans` font-variation-settings by default.
150-7. Disable tap highlights on iOS
151-*/
152-
153-html,
154-:host {
155- line-height: 1.5;
156- /* 1 */
157- -webkit-text-size-adjust: 100%;
158- /* 2 */
159- -moz-tab-size: 4;
160- /* 3 */
161- -o-tab-size: 4;
162- tab-size: 4;
163- /* 3 */
164- font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
165- /* 4 */
166- font-feature-settings: normal;
167- /* 5 */
168- font-variation-settings: normal;
169- /* 6 */
170- -webkit-tap-highlight-color: transparent;
171- /* 7 */
172-}
173-
174-/*
175-1. Remove the margin in all browsers.
176-2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
177-*/
178-
179-body {
180- margin: 0;
181- /* 1 */
182- line-height: inherit;
183- /* 2 */
184-}
185-
186-/*
187-1. Add the correct height in Firefox.
188-2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
189-3. Ensure horizontal rules are visible by default.
190-*/
191-
192-hr {
193- height: 0;
194- /* 1 */
195- color: inherit;
196- /* 2 */
197- border-top-width: 1px;
198- /* 3 */
199-}
200-
201-/*
202-Add the correct text decoration in Chrome, Edge, and Safari.
203-*/
204-
205-abbr:where([title]) {
206- -webkit-text-decoration: underline dotted;
207- text-decoration: underline dotted;
208-}
209-
210-/*
211-Remove the default font size and weight for headings.
212-*/
213-
214-h1,
215-h2,
216-h3,
217-h4,
218-h5,
219-h6 {
220- font-size: inherit;
221- font-weight: inherit;
222-}
223-
224-/*
225-Reset links to optimize for opt-in styling instead of opt-out.
226-*/
227-
228-a {
229- color: inherit;
230- text-decoration: inherit;
231-}
232-
233-/*
234-Add the correct font weight in Edge and Safari.
235-*/
236-
237-b,
238-strong {
239- font-weight: bolder;
240-}
241-
242-/*
243-1. Use the user's configured `mono` font-family by default.
244-2. Use the user's configured `mono` font-feature-settings by default.
245-3. Use the user's configured `mono` font-variation-settings by default.
246-4. Correct the odd `em` font sizing in all browsers.
247-*/
248-
249-code,
250-kbd,
251-samp,
252-pre {
253- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
254- /* 1 */
255- font-feature-settings: normal;
256- /* 2 */
257- font-variation-settings: normal;
258- /* 3 */
259- font-size: 1em;
260- /* 4 */
261-}
262-
263-/*
264-Add the correct font size in all browsers.
265-*/
266-
267-small {
268- font-size: 80%;
269-}
270-
271-/*
272-Prevent `sub` and `sup` elements from affecting the line height in all browsers.
273-*/
274-
275-sub,
276-sup {
277- font-size: 75%;
278- line-height: 0;
279- position: relative;
280- vertical-align: baseline;
281-}
282-
283-sub {
284- bottom: -0.25em;
285-}
286-
287-sup {
288- top: -0.5em;
289-}
290-
291-/*
292-1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
293-2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
294-3. Remove gaps between table borders by default.
295-*/
296-
297-table {
298- text-indent: 0;
299- /* 1 */
300- border-color: inherit;
301- /* 2 */
302- border-collapse: collapse;
303- /* 3 */
304-}
305-
306-/*
307-1. Change the font styles in all browsers.
308-2. Remove the margin in Firefox and Safari.
309-3. Remove default padding in all browsers.
310-*/
311-
312-button,
313-input,
314-optgroup,
315-select,
316-textarea {
317- font-family: inherit;
318- /* 1 */
319- font-feature-settings: inherit;
320- /* 1 */
321- font-variation-settings: inherit;
322- /* 1 */
323- font-size: 100%;
324- /* 1 */
325- font-weight: inherit;
326- /* 1 */
327- line-height: inherit;
328- /* 1 */
329- letter-spacing: inherit;
330- /* 1 */
331- color: inherit;
332- /* 1 */
333- margin: 0;
334- /* 2 */
335- padding: 0;
336- /* 3 */
337-}
338-
339-/*
340-Remove the inheritance of text transform in Edge and Firefox.
341-*/
342-
343-button,
344-select {
345- text-transform: none;
346-}
347-
348-/*
349-1. Correct the inability to style clickable types in iOS and Safari.
350-2. Remove default button styles.
351-*/
352-
353-button,
354-input:where([type='button']),
355-input:where([type='reset']),
356-input:where([type='submit']) {
357- -webkit-appearance: button;
358- /* 1 */
359- background-color: transparent;
360- /* 2 */
361- background-image: none;
362- /* 2 */
363-}
364-
365-/*
366-Use the modern Firefox focus style for all focusable elements.
367-*/
368-
369-:-moz-focusring {
370- outline: auto;
371-}
372-
373-/*
374-Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
375-*/
376-
377-:-moz-ui-invalid {
378- box-shadow: none;
379-}
380-
381-/*
382-Add the correct vertical alignment in Chrome and Firefox.
383-*/
384-
385-progress {
386- vertical-align: baseline;
387-}
388-
389-/*
390-Correct the cursor style of increment and decrement buttons in Safari.
391-*/
392-
393-::-webkit-inner-spin-button,
394-::-webkit-outer-spin-button {
395- height: auto;
396-}
397-
398-/*
399-1. Correct the odd appearance in Chrome and Safari.
400-2. Correct the outline style in Safari.
401-*/
402-
403-[type='search'] {
404- -webkit-appearance: textfield;
405- /* 1 */
406- outline-offset: -2px;
407- /* 2 */
408-}
409-
410-/*
411-Remove the inner padding in Chrome and Safari on macOS.
412-*/
413-
414-::-webkit-search-decoration {
415- -webkit-appearance: none;
416-}
417-
418-/*
419-1. Correct the inability to style clickable types in iOS and Safari.
420-2. Change font properties to `inherit` in Safari.
421-*/
422-
423-::-webkit-file-upload-button {
424- -webkit-appearance: button;
425- /* 1 */
426- font: inherit;
427- /* 2 */
428-}
429-
430-/*
431-Add the correct display in Chrome and Safari.
432-*/
433-
434-summary {
435- display: list-item;
436-}
437-
438-/*
439-Removes the default spacing and border for appropriate elements.
440-*/
441-
442-blockquote,
443-dl,
444-dd,
445-h1,
446-h2,
447-h3,
448-h4,
449-h5,
450-h6,
451-hr,
452-figure,
453-p,
454-pre {
455- margin: 0;
456-}
457-
458-fieldset {
459- margin: 0;
460- padding: 0;
461-}
462-
463-legend {
464- padding: 0;
465-}
466-
467-ol,
468-ul,
469-menu {
470- list-style: none;
471- margin: 0;
472- padding: 0;
473-}
474-
475-/*
476-Reset default styling for dialogs.
477-*/
478-
479-dialog {
480- padding: 0;
481-}
482-
483-/*
484-Prevent resizing textareas horizontally by default.
485-*/
486-
487-textarea {
488- resize: vertical;
489-}
490-
491-/*
492-1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
493-2. Set the default placeholder color to the user's configured gray 400 color.
494-*/
495-
496-input::-moz-placeholder, textarea::-moz-placeholder {
497- opacity: 1;
498- /* 1 */
499- color: #9ca3af;
500- /* 2 */
501-}
502-
503-input::placeholder,
504-textarea::placeholder {
505- opacity: 1;
506- /* 1 */
507- color: #9ca3af;
508- /* 2 */
509-}
510-
511-/*
512-Set the default cursor for buttons.
513-*/
514-
515-button,
516-[role="button"] {
517- cursor: pointer;
518-}
519-
520-/*
521-Make sure disabled buttons don't get the pointer cursor.
522-*/
523-
524-:disabled {
525- cursor: default;
526-}
527-
528-/*
529-1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
530-2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
531- This can trigger a poorly considered lint error in some tools but is included by design.
532-*/
533-
534-img,
535-svg,
536-video,
537-canvas,
538-audio,
539-iframe,
540-embed,
541-object {
542- display: block;
543- /* 1 */
544- vertical-align: middle;
545- /* 2 */
546-}
547-
548-/*
549-Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
550-*/
551-
552-img,
553-video {
554- max-width: 100%;
555- height: auto;
556-}
557-
558-/* Make elements with the HTML hidden attribute stay hidden by default */
559-
560-[hidden]:where(:not([hidden="until-found"])) {
561- display: none;
562 }4 }
563-5+.absolute {
564-.container {6+ position: absolute;
565- width: 100%;
566-}
567-
568-@media (min-width: 640px) {
569- .container {
570- max-width: 640px;
571- }
572 }7 }
573-
574-@media (min-width: 768px) {
575- .container {
576- max-width: 768px;
577- }
578-}
579-
580-@media (min-width: 1024px) {
581- .container {
582- max-width: 1024px;
583- }
584-}
585-
586-@media (min-width: 1280px) {
587- .container {
588- max-width: 1280px;
589- }
590-}
591-
592-@media (min-width: 1536px) {
593- .container {
594- max-width: 1536px;
595- }
596-}
597-
598 .fixed {8 .fixed {
599 position: fixed;9 position: fixed;
600 }10 }
601-
602 .relative {11 .relative {
603 position: relative;12 position: relative;
604 }13 }
605-14+.static {
606-.left-0 {15+ position: static;
607- left: 0px;
608-}
609-
610-.top-0 {
611- top: 0px;
612-}
613-
614-.mb-1 {
615- margin-bottom: 0.25rem;
616-}
617-
618-.mb-2 {
619- margin-bottom: 0.5rem;
620-}
621-
622-.mb-4 {
623- margin-bottom: 1rem;
624-}
625-
626-.mb-6 {
627- margin-bottom: 1.5rem;
628-}
629-
630-.ml-2 {
631- margin-left: 0.5rem;
632-}
633-
634-.ml-6 {
635- margin-left: 1.5rem;
636-}
637-
638-.ml-7 {
639- margin-left: 1.75rem;
640 }16 }
641-17+.z-50 {
642-.mt-1 {18+ z-index: 50;
643- margin-top: 0.25rem;
644 }19 }
645-20+.container {
646-.mt-2 {21+ width: 100%;
647- margin-top: 0.5rem;
648-}
649-
650-.mt-4 {
651- margin-top: 1rem;
652-}
653-
654-.mt-8 {
655- margin-top: 2rem;
656 }22 }
657-
658 .block {23 .block {
659 display: block;24 display: block;
660 }25 }
661-26+.contents {
27+ display: contents;
28+}
662 .flex {29 .flex {
663 display: flex;30 display: flex;
664 }31 }
665-
666-.table {
667- display: table;
668-}
669-
670 .hidden {32 .hidden {
671 display: none;33 display: none;
672 }34 }
673-35+.inline {
674-.h-12 {36+ display: inline;
675- height: 3rem;
676-}
677-
678-.h-32 {
679- height: 8rem;
680-}
681-
682-.h-44 {
683- height: 11rem;
684 }37 }
685-38+.inline-block {
686-.h-5 {39+ display: inline-block;
687- height: 1.25rem;
688 }40 }
689-41+.table {
690-.h-8 {42+ display: table;
691- height: 2rem;
692 }43 }
693-
694 .h-full {44 .h-full {
695 height: 100%;45 height: 100%;
696 }46 }
697-
698 .w-1\/3 {47 .w-1\/3 {
699- width: 33.333333%;48+ width: calc(1/3 * 100%);
700 }49 }
701-
702 .w-1\/4 {50 .w-1\/4 {
703- width: 25%;51+ width: calc(1/4 * 100%);
704 }52 }
705-
706 .w-2\/3 {53 .w-2\/3 {
707- width: 66.666667%;54+ width: calc(2/3 * 100%);
708-}
709-
710-.w-5 {
711- width: 1.25rem;
712 }55 }
713-
714 .w-5\/12 {56 .w-5\/12 {
715- width: 41.666667%;57+ width: calc(5/12 * 100%);
716 }58 }
717-
718-.w-8 {
719- width: 2rem;
720-}
721-
722 .w-full {59 .w-full {
723 width: 100%;60 width: 100%;
724 }61 }
725-
726-.max-w-md {
727- max-width: 28rem;
728-}
729-
730 .flex-1 {62 .flex-1 {
731- flex: 1 1 0%;63+ flex: 1;
732 }64 }
733-
734 .flex-grow {65 .flex-grow {
735 flex-grow: 1;66 flex-grow: 1;
736 }67 }
737-
738 .table-auto {68 .table-auto {
739 table-layout: auto;69 table-layout: auto;
740 }70 }
741-
742 .cursor-pointer {71 .cursor-pointer {
743 cursor: pointer;72 cursor: pointer;
744 }73 }
745-74+.resize {
75+ resize: both;
76+}
746 .list-disc {77 .list-disc {
747 list-style-type: disc;78 list-style-type: disc;
748 }79 }
749-
750-.flex-row {
751- flex-direction: row;
752-}
753-
754 .flex-col {80 .flex-col {
755 flex-direction: column;81 flex-direction: column;
756 }82 }
757-83+.flex-row {
84+ flex-direction: row;
85+}
758 .content-center {86 .content-center {
759 align-content: center;87 align-content: center;
760 }88 }
761-
762 .items-center {89 .items-center {
763 align-items: center;90 align-items: center;
764 }91 }
765-92+.justify-between {
766-.justify-start {93+ justify-content: space-between;
767- justify-content: flex-start;
768-}
769-
770-.justify-end {
771- justify-content: flex-end;
772 }94 }
773-
774 .justify-center {95 .justify-center {
775 justify-content: center;96 justify-content: center;
776 }97 }
777-98+.justify-end {
778-.justify-between {99+ justify-content: flex-end;
779- justify-content: space-between;
780 }100 }
781-
782 .justify-evenly {101 .justify-evenly {
783 justify-content: space-evenly;102 justify-content: space-evenly;
784 }103 }
785-104+.justify-start {
786-.space-x-2 > :not([hidden]) ~ :not([hidden]) {105+ justify-content: flex-start;
787- --tw-space-x-reverse: 0;
788- margin-right: calc(0.5rem * var(--tw-space-x-reverse));
789- margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
790-}
791-
792-.space-x-4 > :not([hidden]) ~ :not([hidden]) {
793- --tw-space-x-reverse: 0;
794- margin-right: calc(1rem * var(--tw-space-x-reverse));
795- margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
796-}
797-
798-.space-y-4 > :not([hidden]) ~ :not([hidden]) {
799- --tw-space-y-reverse: 0;
800- margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
801- margin-bottom: calc(1rem * var(--tw-space-y-reverse));
802-}
803-
804-.space-y-6 > :not([hidden]) ~ :not([hidden]) {
805- --tw-space-y-reverse: 0;
806- margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
807- margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
808-}
809-
810-.rounded {
811- border-radius: 0.25rem;
812-}
813-
814-.rounded-full {
815- border-radius: 9999px;
816-}
817-
818-.rounded-md {
819- border-radius: 0.375rem;
820 }106 }
821-107+.rounded-full {
822-.rounded-sm {108+ border-radius: calc(infinity * 1px);
823- border-radius: 0.125rem;
824 }109 }
825-
826 .border {110 .border {
111+ border-style: var(--tw-border-style);
827 border-width: 1px;112 border-width: 1px;
828 }113 }
829-
830 .border-2 {114 .border-2 {
115+ border-style: var(--tw-border-style);
831 border-width: 2px;116 border-width: 2px;
832 }117 }
833-
834 .border-t-2 {118 .border-t-2 {
119+ border-top-style: var(--tw-border-style);
835 border-top-width: 2px;120 border-top-width: 2px;
836 }121 }
837-
838-.border-gray-300 {
839- --tw-border-opacity: 1;
840- border-color: rgb(209 213 219 / var(--tw-border-opacity));
841-}
842-
843-.border-red-400 {
844- --tw-border-opacity: 1;
845- border-color: rgb(248 113 113 / var(--tw-border-opacity));
846-}
847-
848-.bg-blue-500 {
849- --tw-bg-opacity: 1;
850- background-color: rgb(59 130 246 / var(--tw-bg-opacity));
851-}
852-
853-.bg-gordonmascot-hblue {
854- --tw-bg-opacity: 1;
855- background-color: rgb(1 30 92 / var(--tw-bg-opacity));
856-}
857-
858-.bg-gordonmascot-lblue {
859- --tw-bg-opacity: 1;
860- background-color: rgb(0 123 192 / var(--tw-bg-opacity));
861-}
862-
863-.bg-gordonmascot-mblue {
864- --tw-bg-opacity: 1;
865- background-color: rgb(0 82 147 / var(--tw-bg-opacity));
866-}
867-
868-.bg-gray-100 {
869- --tw-bg-opacity: 1;
870- background-color: rgb(243 244 246 / var(--tw-bg-opacity));
871-}
872-
873-.bg-gray-300 {
874- --tw-bg-opacity: 1;
875- background-color: rgb(209 213 219 / var(--tw-bg-opacity));
876-}
877-
878-.bg-green-500 {
879- --tw-bg-opacity: 1;
880- background-color: rgb(34 197 94 / var(--tw-bg-opacity));
881-}
882-
883-.bg-red-100 {
884- --tw-bg-opacity: 1;
885- background-color: rgb(254 226 226 / var(--tw-bg-opacity));
886-}
887-
888-.bg-slate-100 {
889- --tw-bg-opacity: 1;
890- background-color: rgb(241 245 249 / var(--tw-bg-opacity));
891-}
892-
893-.bg-slate-200 {
894- --tw-bg-opacity: 1;
895- background-color: rgb(226 232 240 / var(--tw-bg-opacity));
896-}
897-
898 .bg-transparent {122 .bg-transparent {
899 background-color: transparent;123 background-color: transparent;
900 }124 }
901-
902-.bg-white {
903- --tw-bg-opacity: 1;
904- background-color: rgb(255 255 255 / var(--tw-bg-opacity));
905-}
906-
907-.bg-zinc-200 {
908- --tw-bg-opacity: 1;
909- background-color: rgb(228 228 231 / var(--tw-bg-opacity));
910-}
911-
912-.p-0 {
913- padding: 0px;
914-}
915-
916-.p-2 {
917- padding: 0.5rem;
918-}
919-
920-.p-4 {
921- padding: 1rem;
922-}
923-
924-.px-2 {
925- padding-left: 0.5rem;
926- padding-right: 0.5rem;
927-}
928-
929-.px-3 {
930- padding-left: 0.75rem;
931- padding-right: 0.75rem;
932-}
933-
934-.px-4 {
935- padding-left: 1rem;
936- padding-right: 1rem;
937-}
938-
939-.px-8 {
940- padding-left: 2rem;
941- padding-right: 2rem;
942-}
943-
944-.py-1 {
945- padding-top: 0.25rem;
946- padding-bottom: 0.25rem;
947-}
948-
949-.py-2 {
950- padding-top: 0.5rem;
951- padding-bottom: 0.5rem;
952-}
953-
954-.py-3 {
955- padding-top: 0.75rem;
956- padding-bottom: 0.75rem;
957-}
958-
959-.pb-4 {
960- padding-bottom: 1rem;
961-}
962-
963-.pr-1 {
964- padding-right: 0.25rem;
965-}
966-
967-.pt-16 {
968- padding-top: 4rem;
969-}
970-
971 .text-center {125 .text-center {
972 text-align: center;126 text-align: center;
973 }127 }
974-
975 .align-middle {128 .align-middle {
976 vertical-align: middle;129 vertical-align: middle;
977 }130 }
978-131+.opacity-0 {
979-.text-2xl {132+ opacity: 0%;
980- font-size: 1.5rem;133+}
981- line-height: 2rem;134+.filter {
982-}135+ 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,);
983-
984-.text-3xl {
985- font-size: 1.875rem;
986- line-height: 2.25rem;
987-}
988-
989-.text-4xl {
990- font-size: 2.25rem;
991- line-height: 2.5rem;
992-}
993-
994-.text-5xl {
995- font-size: 3rem;
996- line-height: 1;
997-}
998-
999-.text-lg {
1000- font-size: 1.125rem;
1001- line-height: 1.75rem;
1002-}
1003-
1004-.text-sm {
1005- font-size: 0.875rem;
1006- line-height: 1.25rem;
1007-}
1008-
1009-.text-xl {
1010- font-size: 1.25rem;
1011- line-height: 1.75rem;
1012-}
1013-
1014-.text-xs {
1015- font-size: 0.75rem;
1016- line-height: 1rem;
1017-}
1018-
1019-.font-bold {
1020- font-weight: 700;
1021-}
1022-
1023-.font-medium {
1024- font-weight: 500;
1025-}
1026-
1027-.font-semibold {
1028- font-weight: 600;
1029-}
1030-
1031-.tracking-tighter {
1032- letter-spacing: -0.05em;
1033-}
1034-
1035-.text-blue-600 {
1036- --tw-text-opacity: 1;
1037- color: rgb(37 99 235 / var(--tw-text-opacity));
1038-}
1039-
1040-.text-blue-800 {
1041- --tw-text-opacity: 1;
1042- color: rgb(30 64 175 / var(--tw-text-opacity));
1043-}
1044-
1045-.text-gordonmascot-hblue {
1046- --tw-text-opacity: 1;
1047- color: rgb(1 30 92 / var(--tw-text-opacity));
1048-}
1049-
1050-.text-gordonmascot-mblue {
1051- --tw-text-opacity: 1;
1052- color: rgb(0 82 147 / var(--tw-text-opacity));
1053-}
1054-
1055-.text-gray-100 {
1056- --tw-text-opacity: 1;
1057- color: rgb(243 244 246 / var(--tw-text-opacity));
1058-}
1059-
1060-.text-gray-400 {
1061- --tw-text-opacity: 1;
1062- color: rgb(156 163 175 / var(--tw-text-opacity));
1063-}
1064-
1065-.text-gray-500 {
1066- --tw-text-opacity: 1;
1067- color: rgb(107 114 128 / var(--tw-text-opacity));
1068-}
1069-
1070-.text-gray-700 {
1071- --tw-text-opacity: 1;
1072- color: rgb(55 65 81 / var(--tw-text-opacity));
1073-}
1074-
1075-.text-gray-800 {
1076- --tw-text-opacity: 1;
1077- color: rgb(31 41 55 / var(--tw-text-opacity));
1078-}
1079-
1080-.text-green-300 {
1081- --tw-text-opacity: 1;
1082- color: rgb(134 239 172 / var(--tw-text-opacity));
1083-}
1084-
1085-.text-red-500 {
1086- --tw-text-opacity: 1;
1087- color: rgb(239 68 68 / var(--tw-text-opacity));
1088-}
1089-
1090-.text-red-600 {
1091- --tw-text-opacity: 1;
1092- color: rgb(220 38 38 / var(--tw-text-opacity));
1093-}
1094-
1095-.text-red-700 {
1096- --tw-text-opacity: 1;
1097- color: rgb(185 28 28 / var(--tw-text-opacity));
1098-}
1099-
1100-.text-white {
1101- --tw-text-opacity: 1;
1102- color: rgb(255 255 255 / var(--tw-text-opacity));
1103-}
1104-
1105-.shadow {
1106- --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
1107- --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
1108- box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1109-}
1110-
1111-.shadow-sm {
1112- --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
1113- --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
1114- box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1115-}
1116-
1117-.outline-none {
1118- outline: 2px solid transparent;
1119- outline-offset: 2px;
1120 }136 }
1121-
1122 .transition {137 .transition {
1123- transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;138+ 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;
1124- transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;139+ transition-timing-function: var(--tw-ease, ease);
1125- transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;140+ transition-duration: var(--tw-duration, 0s);
1126- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);141+}
1127- transition-duration: 150ms;142+.transition-opacity {
143+ transition-property: opacity;
144+ transition-timing-function: var(--tw-ease, ease);
145+ transition-duration: var(--tw-duration, 0s);
1128 }146 }
1129-
1130 .duration-150 {147 .duration-150 {
148+ --tw-duration: 150ms;
1131 transition-duration: 150ms;149 transition-duration: 150ms;
1132 }150 }
1133-151+.duration-300 {
1134-.ease-in-out {152+ --tw-duration: 300ms;
1135- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);153+ transition-duration: 300ms;
154+}
155+.outline-none {
156+ --tw-outline-style: none;
157+ outline-style: none;
158+}
159+.\[image\:tag\] {
160+ image: tag;
161+}
162+.focus\:outline-none {
163+ &:focus {
164+ --tw-outline-style: none;
165+ outline-style: none;
166+ }
1136 }167 }
1137-
1138-/* Import IBM Plex Fonts */
1139-
1140 @font-face {168 @font-face {
1141 font-family: "IBMPlexSans-Bold";169 font-family: "IBMPlexSans-Bold";
1142-
1143 src: url("/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2") format("woff2");170 src: url("/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2") format("woff2");
1144-
1145 font-weight: bold;171 font-weight: bold;
1146-
1147 font-style: normal;172 font-style: normal;
1148 }173 }
1149-
1150 @font-face {174 @font-face {
1151 font-family: "IBMPlexSans-Regular";175 font-family: "IBMPlexSans-Regular";
1152-
1153 src: url("/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2") format("woff2");176 src: url("/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2") format("woff2");
1154-
1155 font-weight: normal;177 font-weight: normal;
1156-
1157 font-style: normal;178 font-style: normal;
1158 }179 }
1159-
1160-/* Import Jetbrains Mono Nerd Font */
1161-
1162 @font-face {180 @font-face {
1163 font-family: "JetBrainsMono-Nerd-Font-Regular";181 font-family: "JetBrainsMono-Nerd-Font-Regular";
1164-182+ src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2") format("woff2");
1165- src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2")
1166- format("woff2");
1167-
1168 font-weight: normal;183 font-weight: normal;
1169-
1170 font-style: normal;184 font-style: normal;
1171 }185 }
1172-
1173-/* Import Jetbrains Mono Nerd Font Bold */
1174-
1175 @font-face {186 @font-face {
1176 font-family: "JetBrainsMono-Nerd-Font-Bold";187 font-family: "JetBrainsMono-Nerd-Font-Bold";
1177-188+ src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2") format("woff2");
1178- src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2")
1179- format("woff2");
1180-
1181 font-weight: bold;189 font-weight: bold;
1182-
1183 font-style: normal;190 font-style: normal;
1184 }191 }
1185-
1186 @font-face {192 @font-face {
1187 font-family: "LilitaOne-Regular";193 font-family: "LilitaOne-Regular";
1188-
1189 src: url("/assets/fonts/LilitaOne/LilitaOne-Regular.ttf") format("truetype");194 src: url("/assets/fonts/LilitaOne/LilitaOne-Regular.ttf") format("truetype");
1190-
1191 font-weight: normal;195 font-weight: normal;
1192-
1193 font-style: normal;196 font-style: normal;
1194 }197 }
1195-
1196 .fade-me-out.htmx-swapping {198 .fade-me-out.htmx-swapping {
1197 opacity: 0;199 opacity: 0;
1198 transition: opacity 1s ease-out;200 transition: opacity 1s ease-out;
1199 }201 }
1200-
1201-/* View transition */
1202-
1203 @keyframes fade-in {202 @keyframes fade-in {
1204 from {203 from {
1205 opacity: 0;204 opacity: 0;
1206 }205 }
1207 }206 }
1208-
1209 @keyframes fade-out {207 @keyframes fade-out {
1210 to {208 to {
1211 opacity: 0;209 opacity: 0;
1212 }210 }
1213 }211 }
1214-
1215 @keyframes slide-from-right {212 @keyframes slide-from-right {
1216 from {213 from {
1217 transform: translateX(90px);214 transform: translateX(90px);
1218 }215 }
1219 }216 }
1220-
1221 @keyframes slide-to-left {217 @keyframes slide-to-left {
1222 to {218 to {
1223 transform: translateX(-90px);219 transform: translateX(-90px);
1224 }220 }
1225 }221 }
1226-
1227 @keyframes wiggle {222 @keyframes wiggle {
1228- 0%,223+ 0%, 100% {
1229- 100% {
1230 transform: rotate(-3deg);224 transform: rotate(-3deg);
1231 }225 }
1232-
1233 50% {226 50% {
1234 transform: rotate(3deg);227 transform: rotate(3deg);
1235 }228 }
1236 }229 }
1237-
1238 #update-available {230 #update-available {
1239 animation: wiggle 1s ease-in-out infinite;231 animation: wiggle 1s ease-in-out infinite;
1240 }232 }
1241-
1242 .slide-it {233 .slide-it {
1243 view-transition-name: slide-it;234 view-transition-name: slide-it;
1244 }235 }
1245-
1246 ::view-transition-old(slide-it) {236 ::view-transition-old(slide-it) {
1247- animation:237+ animation: 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
1248- 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
1249- 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
1250 }238 }
1251-
1252 ::view-transition-new(slide-it) {239 ::view-transition-new(slide-it) {
1253- animation:240+ animation: 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
1254- 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
1255- 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
1256 }241 }
1257-
1258 .gordon-title {242 .gordon-title {
1259 font-family: "LilitaOne-Regular", sans-serif;243 font-family: "LilitaOne-Regular", sans-serif;
1260 }244 }
1261-
1262 .IBMPlexSans {245 .IBMPlexSans {
1263 font-family: "IBMPlexSans-Regular", sans-serif;246 font-family: "IBMPlexSans-Regular", sans-serif;
1264 }247 }
1265-248+.JetBrainsMonoNF, .iconf {
1266-.JetBrainsMonoNF, .iconf{
1267 font-family: "JetBrainsMono-Nerd-Font-Regular", sans-serif;249 font-family: "JetBrainsMono-Nerd-Font-Regular", sans-serif;
1268 }250 }
1269-251+.JetBrainsMonoBoldNF, .iconfb {
1270-.JetBrainsMonoBoldNF, .iconfb{
1271 font-family: "JetBrainsMono-Nerd-Font-Bold", sans-serif;252 font-family: "JetBrainsMono-Nerd-Font-Bold", sans-serif;
1272 }253 }
1273-254+@property --tw-border-style {
1274-.hover\:bg-blue-700:hover {255+ syntax: "*";
1275- --tw-bg-opacity: 1;256+ inherits: false;
1276- background-color: rgb(29 78 216 / var(--tw-bg-opacity));257+ initial-value: solid;
1277-}
1278-
1279-.hover\:bg-gordonmascot-hblue:hover {
1280- --tw-bg-opacity: 1;
1281- background-color: rgb(1 30 92 / var(--tw-bg-opacity));
1282-}
1283-
1284-.hover\:bg-gordonmascot-mblue:hover {
1285- --tw-bg-opacity: 1;
1286- background-color: rgb(0 82 147 / var(--tw-bg-opacity));
1287-}
1288-
1289-.hover\:bg-slate-100:hover {
1290- --tw-bg-opacity: 1;
1291- background-color: rgb(241 245 249 / var(--tw-bg-opacity));
1292-}
1293-
1294-.hover\:text-gordonmascot-hblue:hover {
1295- --tw-text-opacity: 1;
1296- color: rgb(1 30 92 / var(--tw-text-opacity));
1297-}
1298-
1299-.hover\:text-red-900:hover {
1300- --tw-text-opacity: 1;
1301- color: rgb(127 29 29 / var(--tw-text-opacity));
1302-}
1303-
1304-.focus\:border-gordonmascot-hblue:focus {
1305- --tw-border-opacity: 1;
1306- border-color: rgb(1 30 92 / var(--tw-border-opacity));
1307-}
1308-
1309-.focus\:outline-none:focus {
1310- outline: 2px solid transparent;
1311- outline-offset: 2px;
1312-}
1313-
1314-.focus\:ring-gordonmascot-hblue:focus {
1315- --tw-ring-opacity: 1;
1316- --tw-ring-color: rgb(1 30 92 / var(--tw-ring-opacity));
1317-}
1318-
1319-@media (min-width: 640px) {
1320- .sm\:inline {
1321- display: inline;
1322- }
1323-
1324- .sm\:h-24 {
1325- height: 6rem;
1326- }
1327 }258 }
1328-259+@property --tw-blur {
1329-@media (min-width: 768px) {260+ syntax: "*";
1330- .md\:ml-4 {261+ inherits: false;
1331- margin-left: 1rem;
1332- }
1333-
1334- .md\:h-16 {
1335- height: 4rem;
1336- }
1337-
1338- .md\:w-2\/6 {
1339- width: 33.333333%;
1340- }
1341-
1342- .md\:flex-row {
1343- flex-direction: row;
1344- }
1345-
1346- .md\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
1347- --tw-space-x-reverse: 0;
1348- margin-right: calc(1rem * var(--tw-space-x-reverse));
1349- margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
1350- }
1351-
1352- .md\:space-y-0 > :not([hidden]) ~ :not([hidden]) {
1353- --tw-space-y-reverse: 0;
1354- margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
1355- margin-bottom: calc(0px * var(--tw-space-y-reverse));
1356- }
1357-
1358- .md\:rounded-md {
1359- border-radius: 0.375rem;
1360- }
1361-
1362- .md\:p-0 {
1363- padding: 0px;
1364- }
1365-
1366- .md\:p-8 {
1367- padding: 2rem;
1368- }
1369-
1370- .md\:px-6 {
1371- padding-left: 1.5rem;
1372- padding-right: 1.5rem;
1373- }
1374-
1375- .md\:pt-10 {
1376- padding-top: 2.5rem;
1377- }
1378-
1379- .md\:pt-32 {
1380- padding-top: 8rem;
1381- }
1382-
1383- .md\:text-4xl {
1384- font-size: 2.25rem;
1385- line-height: 2.5rem;
1386- }
1387-
1388- .md\:text-sm {
1389- font-size: 0.875rem;
1390- line-height: 1.25rem;
1391- }
1392-
1393- .md\:text-xl {
1394- font-size: 1.25rem;
1395- line-height: 1.75rem;
1396- }
1397-
1398- .md\:shadow {
1399- --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
1400- --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
1401- box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
1402- }
1403 }262 }
1404-263+@property --tw-brightness {
1405-@media (min-width: 1024px) {264+ syntax: "*";
1406- .lg\:flex-row {265+ inherits: false;
1407- flex-direction: row;
1408- }
1409-
1410- .lg\:text-4xl {
1411- font-size: 2.25rem;
1412- line-height: 2.5rem;
1413- }
1414 }266 }
1415-267+@property --tw-contrast {
1416-@media (min-width: 1280px) {268+ syntax: "*";
1417- .xl\:h-12 {269+ inherits: false;
1418- height: 3rem;270+}
1419- }271+@property --tw-grayscale {
1420-272+ syntax: "*";
1421- .xl\:h-48 {273+ inherits: false;
1422- height: 12rem;274+}
1423- }275+@property --tw-hue-rotate {
1424-276+ syntax: "*";
1425- .xl\:text-5xl {277+ inherits: false;
1426- font-size: 3rem;278+}
1427- line-height: 1;279+@property --tw-invert {
1428- }280+ syntax: "*";
281+ inherits: false;
282+}
283+@property --tw-opacity {
284+ syntax: "*";
285+ inherits: false;
286+}
287+@property --tw-saturate {
288+ syntax: "*";
289+ inherits: false;
290+}
291+@property --tw-sepia {
292+ syntax: "*";
293+ inherits: false;
294+}
295+@property --tw-drop-shadow {
296+ syntax: "*";
297+ inherits: false;
298+}
299+@property --tw-duration {
300+ syntax: "*";
301+ inherits: false;
1429 }302 }

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