feat: Add image pruning functionality and enhance image management UI
- Introduced a new endpoint for purging unused Docker images via the `/image-manager/prune` route. - Implemented the `ImageManagerPrune` handler to execute the pruning operation and return the number of images purged. - Updated the image management UI to include a "Purge Unused" button, allowing users to easily remove dangling images. - Refactored template components to utilize the new URL generation method for improved maintainability. - Enhanced logging for image management operations to provide better traceability.
- commit
- ad19124f7cd5c3f53163249c974741ef956e5b97
- author
- bamen · · 2025-03-30 12:46 UTC
- parent
- 10909c5
- signature
- unsigned
20 files · +347 -336
M go.mod +1 -1
| @@ -249,7 +249,7 @@ require ( | |||
| 249 | 249 | github.com/Microsoft/go-winio v0.6.2 // indirect | |
| 250 | 250 | github.com/andybalholm/cascadia v1.3.3 // indirect | |
| 251 | 251 | github.com/aymerick/douceur v0.2.0 // indirect | |
| 252 | - | github.com/docker/go-units v0.5.0 // indirect | |
| 252 | + | github.com/docker/go-units v0.5.0 | |
| 253 | 253 | github.com/gogo/protobuf v1.3.2 // indirect | |
| 254 | 254 | github.com/google/uuid v1.6.0 // direct | |
| 255 | 255 | github.com/gorilla/context v1.1.2 // indirect | |
| @@ -249,7 +249,7 @@ require ( | |||||
| 249 | github.com/Microsoft/go-winio v0.6.2 // indirect | 249 | github.com/Microsoft/go-winio v0.6.2 // indirect | ||
| 250 | github.com/andybalholm/cascadia v1.3.3 // indirect | 250 | github.com/andybalholm/cascadia v1.3.3 // indirect | ||
| 251 | github.com/aymerick/douceur v0.2.0 // indirect | 251 | github.com/aymerick/douceur v0.2.0 // indirect | ||
| 252 | - | github.com/docker/go-units v0.5.0 // indirect | 252 | + | github.com/docker/go-units v0.5.0 |
| 253 | github.com/gogo/protobuf v1.3.2 // indirect | 253 | github.com/gogo/protobuf v1.3.2 // indirect | ||
| 254 | github.com/google/uuid v1.6.0 // direct | 254 | github.com/google/uuid v1.6.0 // direct | ||
| 255 | github.com/gorilla/context v1.1.2 // indirect | 255 | github.com/gorilla/context v1.1.2 // indirect | ||
M internal/httpserve/handlers/container_management.go +9 -0
| @@ -177,6 +177,15 @@ func ImageManagerDelete(c echo.Context, a *server.App) error { | |||
| 177 | 177 | return c.String(http.StatusOK, ActionSuccess(a)) | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | + | // ImageManagerPrune handles the /image-manager/prune route | |
| 181 | + | func ImageManagerPrune(c echo.Context, a *server.App) error { | |
| 182 | + | numPurged, err := docker.PruneImages() | |
| 183 | + | if err != nil { | |
| 184 | + | return sendError(c, err) | |
| 185 | + | } | |
| 186 | + | return c.String(http.StatusOK, fmt.Sprintf("Successfully purged %d images", numPurged)) | |
| 187 | + | } | |
| 188 | + | ||
| 180 | 189 | // ContainerManagerComponent handles the /container-manager route | |
| 181 | 190 | func ContainerManagerComponent(c echo.Context, a *server.App) error { | |
| 182 | 191 | logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String()) | |
| @@ -177,6 +177,15 @@ func ImageManagerDelete(c echo.Context, a *server.App) error { | |||||
| 177 | return c.String(http.StatusOK, ActionSuccess(a)) | 177 | return c.String(http.StatusOK, ActionSuccess(a)) | ||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | + | // ImageManagerPrune handles the /image-manager/prune route | |||
| 181 | + | func ImageManagerPrune(c echo.Context, a *server.App) error { | |||
| 182 | + | numPurged, err := docker.PruneImages() | |||
| 183 | + | if err != nil { | |||
| 184 | + | return sendError(c, err) | |||
| 185 | + | } | |||
| 186 | + | return c.String(http.StatusOK, fmt.Sprintf("Successfully purged %d images", numPurged)) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 180 | // ContainerManagerComponent handles the /container-manager route | 189 | // ContainerManagerComponent handles the /container-manager route | ||
| 181 | func ContainerManagerComponent(c echo.Context, a *server.App) error { | 190 | func ContainerManagerComponent(c echo.Context, a *server.App) error { | ||
| 182 | logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String()) | 191 | logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String()) | ||
M internal/httpserve/router.go +4 -0
| @@ -221,6 +221,10 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) { | |||
| 221 | 221 | htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error { | |
| 222 | 222 | return handlers.ImageManagerDelete(c, a) | |
| 223 | 223 | }) | |
| 224 | + | // Purge unused images | |
| 225 | + | htmxGroup.POST("/image-manager/prune", func(c echo.Context) error { | |
| 226 | + | return handlers.ImageManagerPrune(c, a) | |
| 227 | + | }) | |
| 224 | 228 | ||
| 225 | 229 | // List all containers | |
| 226 | 230 | htmxGroup.GET("/container-manager", func(c echo.Context) error { | |
| @@ -221,6 +221,10 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) { | |||||
| 221 | htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error { | 221 | htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error { | ||
| 222 | return handlers.ImageManagerDelete(c, a) | 222 | return handlers.ImageManagerDelete(c, a) | ||
| 223 | }) | 223 | }) | ||
| 224 | + | // Purge unused images | |||
| 225 | + | htmxGroup.POST("/image-manager/prune", func(c echo.Context) error { | |||
| 226 | + | return handlers.ImageManagerPrune(c, a) | |||
| 227 | + | }) | |||
| 224 | 228 | ||||
| 225 | // List all containers | 229 | // List all containers | ||
| 226 | htmxGroup.GET("/container-manager", func(c echo.Context) error { | 230 | htmxGroup.GET("/container-manager", func(c echo.Context) error { | ||
M internal/proxy/acme.go +0 -1
| @@ -27,7 +27,6 @@ type CertManagerConfig struct { | |||
| 27 | 27 | Email string | |
| 28 | 28 | Mode string | |
| 29 | 29 | SkipCertificates bool | |
| 30 | - | BehindTLSProxy bool | |
| 31 | 30 | AdminDomain string | |
| 32 | 31 | RootDomain string | |
| 33 | 32 | HttpPort string | |
| @@ -27,7 +27,6 @@ type CertManagerConfig struct { | |||||
| 27 | Email string | 27 | Email string | ||
| 28 | Mode string | 28 | Mode string | ||
| 29 | SkipCertificates bool | 29 | SkipCertificates bool | ||
| 30 | - | BehindTLSProxy bool | |||
| 31 | AdminDomain string | 30 | AdminDomain string | ||
| 32 | RootDomain string | 31 | RootDomain string | ||
| 33 | HttpPort string | 32 | HttpPort string | ||
M internal/proxy/core.go +19 -55
| @@ -4,8 +4,6 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "net" | |
| 7 | - | "os" | |
| 8 | - | "strings" | |
| 9 | 7 | "sync" | |
| 10 | 8 | "time" | |
| 11 | 9 | ||
| @@ -162,37 +160,38 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||
| 162 | 160 | certDir = app.GetConfig().General.StorageDir + "/certs" | |
| 163 | 161 | } | |
| 164 | 162 | ||
| 165 | - | // Initial upstream proxy detection (before creating CertManager) | |
| 166 | - | initialBehindTLSProxy := detectInitialUpstreamProxy(&config) | |
| 167 | - | ||
| 168 | 163 | // Create Certificate Manager Config | |
| 169 | 164 | certManagerConfig := CertManagerConfig{ | |
| 170 | 165 | CertDir: certDir, | |
| 171 | 166 | Email: config.Email, | |
| 172 | 167 | Mode: config.LetsEncryptMode, // staging or production | |
| 173 | 168 | SkipCertificates: config.SkipCertificates, | |
| 174 | - | BehindTLSProxy: initialBehindTLSProxy, // Pass initial detection result | |
| 175 | 169 | AdminDomain: app.GetConfig().Http.FullDomain(), | |
| 176 | 170 | RootDomain: app.GetConfig().Http.Domain, | |
| 177 | 171 | HttpPort: config.DefaultHttpChallengePort, | |
| 178 | 172 | // RouteValidator: p.isHostInRoutes, // TODO: Pass a function if needed by hostPolicy | |
| 179 | 173 | } | |
| 180 | 174 | ||
| 181 | - | // Create the Certificate Manager | |
| 182 | - | certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries) | |
| 183 | - | if err != nil { | |
| 184 | - | logger.Error("Failed to initialize Certificate Manager", "error", err) | |
| 185 | - | // Return an error to prevent starting a broken proxy | |
| 186 | - | return nil, fmt.Errorf("failed to initialize certificate manager: %w", err) | |
| 187 | - | } else { | |
| 188 | - | logger.Debug("Certificate manager successfully initialized") | |
| 189 | - | p.certificateManager = certMgr // Store the new manager | |
| 190 | - | } | |
| 175 | + | // Only initialize Certificate Manager if SkipCertificates is false | |
| 176 | + | if !config.SkipCertificates { | |
| 177 | + | // Create the Certificate Manager | |
| 178 | + | certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries) | |
| 179 | + | if err != nil { | |
| 180 | + | logger.Error("Failed to initialize Certificate Manager", "error", err) | |
| 181 | + | // Return an error to prevent starting a broken proxy | |
| 182 | + | return nil, fmt.Errorf("failed to initialize certificate manager: %w", err) | |
| 183 | + | } else { | |
| 184 | + | logger.Debug("Certificate manager successfully initialized") | |
| 185 | + | p.certificateManager = certMgr // Store the new manager | |
| 186 | + | } | |
| 191 | 187 | ||
| 192 | - | // Ensure admin domain has proper ACME configuration | |
| 193 | - | if err := p.ensureAdminDomainConfig(); err != nil { | |
| 194 | - | logger.Error("Failed to ensure admin domain configuration", "error", err) | |
| 195 | - | return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err) | |
| 188 | + | // Ensure admin domain has proper ACME configuration only if manager was created | |
| 189 | + | if err := p.ensureAdminDomainConfig(); err != nil { | |
| 190 | + | logger.Error("Failed to ensure admin domain configuration", "error", err) | |
| 191 | + | return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err) | |
| 192 | + | } | |
| 193 | + | } else { | |
| 194 | + | logger.Info("Skipping certificate manager initialization because SkipCertificates is true") | |
| 196 | 195 | } | |
| 197 | 196 | ||
| 198 | 197 | // --- End Certificate Manager Setup --- | |
| @@ -217,41 +216,6 @@ func (p *Proxy) Close() { | |||
| 217 | 216 | logger.Info("Proxy resources cleaned up") | |
| 218 | 217 | } | |
| 219 | 218 | ||
| 220 | - | // detectInitialUpstreamProxy checks environment variables for signs of an upstream proxy | |
| 221 | - | // This is used during initialization before the CertificateManager is created. | |
| 222 | - | func detectInitialUpstreamProxy(config *common.ReverseProxyConfig) bool { | |
| 223 | - | // Check if detection is explicitly disabled | |
| 224 | - | if !config.DetectUpstreamProxy { | |
| 225 | - | logger.Debug("Upstream proxy detection disabled by config") | |
| 226 | - | return false | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | // Check for common environment variables set by upstream proxies | |
| 230 | - | if os.Getenv("HTTPS") == "on" || | |
| 231 | - | os.Getenv("HTTP_X_FORWARDED_PROTO") == "https" || | |
| 232 | - | os.Getenv("HTTP_X_FORWARDED_SSL") == "on" { | |
| 233 | - | logger.Info("Initial upstream TLS-terminating proxy detected via environment variables", | |
| 234 | - | "detection_method", "environment_variables", | |
| 235 | - | "https", os.Getenv("HTTPS"), | |
| 236 | - | "x_forwarded_proto", os.Getenv("HTTP_X_FORWARDED_PROTO"), | |
| 237 | - | "x_forwarded_ssl", os.Getenv("HTTP_X_FORWARDED_SSL")) | |
| 238 | - | return true | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | // Check Cloudflare headers | |
| 242 | - | if cfVisitor := os.Getenv("HTTP_CF_VISITOR"); cfVisitor != "" { | |
| 243 | - | if strings.Contains(cfVisitor, "\"scheme\":\"https\"") { | |
| 244 | - | logger.Info("Initial Cloudflare proxy detected via environment variables", | |
| 245 | - | "detection_method", "cloudflare_headers", | |
| 246 | - | "cf_visitor", cfVisitor) | |
| 247 | - | return true | |
| 248 | - | } | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | logger.Debug("No initial upstream proxy detected via environment variables") | |
| 252 | - | return false | |
| 253 | - | } | |
| 254 | - | ||
| 255 | 219 | // requestDomainCertificate requests a certificate for a domain | |
| 256 | 220 | func (p *Proxy) requestDomainCertificate(domainName string) error { | |
| 257 | 221 | logger.Debug("Requesting certificate for domain", "domain", domainName) | |
| @@ -4,8 +4,6 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "net" | 6 | "net" | ||
| 7 | - | "os" | |||
| 8 | - | "strings" | |||
| 9 | "sync" | 7 | "sync" | ||
| 10 | "time" | 8 | "time" | ||
| 11 | 9 | ||||
| @@ -162,37 +160,38 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||||
| 162 | certDir = app.GetConfig().General.StorageDir + "/certs" | 160 | certDir = app.GetConfig().General.StorageDir + "/certs" | ||
| 163 | } | 161 | } | ||
| 164 | 162 | ||||
| 165 | - | // Initial upstream proxy detection (before creating CertManager) | |||
| 166 | - | initialBehindTLSProxy := detectInitialUpstreamProxy(&config) | |||
| 167 | - | ||||
| 168 | // Create Certificate Manager Config | 163 | // Create Certificate Manager Config | ||
| 169 | certManagerConfig := CertManagerConfig{ | 164 | certManagerConfig := CertManagerConfig{ | ||
| 170 | CertDir: certDir, | 165 | CertDir: certDir, | ||
| 171 | Email: config.Email, | 166 | Email: config.Email, | ||
| 172 | Mode: config.LetsEncryptMode, // staging or production | 167 | Mode: config.LetsEncryptMode, // staging or production | ||
| 173 | SkipCertificates: config.SkipCertificates, | 168 | SkipCertificates: config.SkipCertificates, | ||
| 174 | - | BehindTLSProxy: initialBehindTLSProxy, // Pass initial detection result | |||
| 175 | AdminDomain: app.GetConfig().Http.FullDomain(), | 169 | AdminDomain: app.GetConfig().Http.FullDomain(), | ||
| 176 | RootDomain: app.GetConfig().Http.Domain, | 170 | RootDomain: app.GetConfig().Http.Domain, | ||
| 177 | HttpPort: config.DefaultHttpChallengePort, | 171 | HttpPort: config.DefaultHttpChallengePort, | ||
| 178 | // RouteValidator: p.isHostInRoutes, // TODO: Pass a function if needed by hostPolicy | 172 | // RouteValidator: p.isHostInRoutes, // TODO: Pass a function if needed by hostPolicy | ||
| 179 | } | 173 | } | ||
| 180 | 174 | ||||
| 181 | - | // Create the Certificate Manager | 175 | + | // Only initialize Certificate Manager if SkipCertificates is false |
| 182 | - | certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries) | 176 | + | if !config.SkipCertificates { |
| 183 | - | if err != nil { | 177 | + | // Create the Certificate Manager |
| 184 | - | logger.Error("Failed to initialize Certificate Manager", "error", err) | 178 | + | certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries) |
| 185 | - | // Return an error to prevent starting a broken proxy | 179 | + | if err != nil { |
| 186 | - | return nil, fmt.Errorf("failed to initialize certificate manager: %w", err) | 180 | + | logger.Error("Failed to initialize Certificate Manager", "error", err) |
| 187 | - | } else { | 181 | + | // Return an error to prevent starting a broken proxy |
| 188 | - | logger.Debug("Certificate manager successfully initialized") | 182 | + | return nil, fmt.Errorf("failed to initialize certificate manager: %w", err) |
| 189 | - | p.certificateManager = certMgr // Store the new manager | 183 | + | } else { |
| 190 | - | } | 184 | + | logger.Debug("Certificate manager successfully initialized") |
| 185 | + | p.certificateManager = certMgr // Store the new manager | |||
| 186 | + | } | |||
| 191 | 187 | ||||
| 192 | - | // Ensure admin domain has proper ACME configuration | 188 | + | // Ensure admin domain has proper ACME configuration only if manager was created |
| 193 | - | if err := p.ensureAdminDomainConfig(); err != nil { | 189 | + | if err := p.ensureAdminDomainConfig(); err != nil { |
| 194 | - | logger.Error("Failed to ensure admin domain configuration", "error", err) | 190 | + | logger.Error("Failed to ensure admin domain configuration", "error", err) |
| 195 | - | return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err) | 191 | + | return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err) |
| 192 | + | } | |||
| 193 | + | } else { | |||
| 194 | + | logger.Info("Skipping certificate manager initialization because SkipCertificates is true") | |||
| 196 | } | 195 | } | ||
| 197 | 196 | ||||
| 198 | // --- End Certificate Manager Setup --- | 197 | // --- End Certificate Manager Setup --- | ||
| @@ -217,41 +216,6 @@ func (p *Proxy) Close() { | |||||
| 217 | logger.Info("Proxy resources cleaned up") | 216 | logger.Info("Proxy resources cleaned up") | ||
| 218 | } | 217 | } | ||
| 219 | 218 | ||||
| 220 | - | // detectInitialUpstreamProxy checks environment variables for signs of an upstream proxy | |||
| 221 | - | // This is used during initialization before the CertificateManager is created. | |||
| 222 | - | func detectInitialUpstreamProxy(config *common.ReverseProxyConfig) bool { | |||
| 223 | - | // Check if detection is explicitly disabled | |||
| 224 | - | if !config.DetectUpstreamProxy { | |||
| 225 | - | logger.Debug("Upstream proxy detection disabled by config") | |||
| 226 | - | return false | |||
| 227 | - | } | |||
| 228 | - | ||||
| 229 | - | // Check for common environment variables set by upstream proxies | |||
| 230 | - | if os.Getenv("HTTPS") == "on" || | |||
| 231 | - | os.Getenv("HTTP_X_FORWARDED_PROTO") == "https" || | |||
| 232 | - | os.Getenv("HTTP_X_FORWARDED_SSL") == "on" { | |||
| 233 | - | logger.Info("Initial upstream TLS-terminating proxy detected via environment variables", | |||
| 234 | - | "detection_method", "environment_variables", | |||
| 235 | - | "https", os.Getenv("HTTPS"), | |||
| 236 | - | "x_forwarded_proto", os.Getenv("HTTP_X_FORWARDED_PROTO"), | |||
| 237 | - | "x_forwarded_ssl", os.Getenv("HTTP_X_FORWARDED_SSL")) | |||
| 238 | - | return true | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | // Check Cloudflare headers | |||
| 242 | - | if cfVisitor := os.Getenv("HTTP_CF_VISITOR"); cfVisitor != "" { | |||
| 243 | - | if strings.Contains(cfVisitor, "\"scheme\":\"https\"") { | |||
| 244 | - | logger.Info("Initial Cloudflare proxy detected via environment variables", | |||
| 245 | - | "detection_method", "cloudflare_headers", | |||
| 246 | - | "cf_visitor", cfVisitor) | |||
| 247 | - | return true | |||
| 248 | - | } | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | logger.Debug("No initial upstream proxy detected via environment variables") | |||
| 252 | - | return false | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | // requestDomainCertificate requests a certificate for a domain | 219 | // requestDomainCertificate requests a certificate for a domain | ||
| 256 | func (p *Proxy) requestDomainCertificate(domainName string) error { | 220 | func (p *Proxy) requestDomainCertificate(domainName string) error { | ||
| 257 | logger.Debug("Requesting certificate for domain", "domain", domainName) | 221 | logger.Debug("Requesting certificate for domain", "domain", domainName) | ||
M internal/proxy/middleware.go +21 -0
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package proxy | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "net/http" | |
| 6 | + | "strings" | |
| 5 | 7 | "time" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/gordon/pkg/logger" | |
| @@ -28,6 +30,9 @@ func (p *Proxy) setupMiddleware() { | |||
| 28 | 30 | // Add UUID generator middleware for both HTTP and HTTPS servers | |
| 29 | 31 | p.httpsServer.Use(p.createRequestIDMiddleware()) | |
| 30 | 32 | ||
| 33 | + | // Add middleware to log incoming headers (for debugging Cloudflare detection) | |
| 34 | + | p.httpsServer.Use(logHeadersMiddleware()) | |
| 35 | + | ||
| 31 | 36 | // Check if rate limiting is disabled via configuration | |
| 32 | 37 | if p.config.EnableRateLimit { | |
| 33 | 38 | // Create a new Starskey rate limiter store for HTTPS server | |
| @@ -215,3 +220,19 @@ func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc { | |||
| 215 | 220 | } | |
| 216 | 221 | } | |
| 217 | 222 | } | |
| 223 | + | ||
| 224 | + | // logHeadersMiddleware creates a middleware function to log request headers | |
| 225 | + | func logHeadersMiddleware() echo.MiddlewareFunc { | |
| 226 | + | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 227 | + | return func(c echo.Context) error { | |
| 228 | + | // Log all headers at DEBUG level | |
| 229 | + | headerStrings := []string{} | |
| 230 | + | for k, v := range c.Request().Header { | |
| 231 | + | headerStrings = append(headerStrings, fmt.Sprintf("%s: %s", k, strings.Join(v, ","))) | |
| 232 | + | } | |
| 233 | + | logger.Debug("Incoming Request Headers", "request_id", c.Get(RequestIDKey), "headers", strings.Join(headerStrings, " | ")) | |
| 234 | + | ||
| 235 | + | return next(c) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | } | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package proxy | 1 | package proxy | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "net/http" | 5 | "net/http" | ||
| 6 | + | "strings" | |||
| 5 | "time" | 7 | "time" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/gordon/pkg/logger" | 9 | "github.com/bnema/gordon/pkg/logger" | ||
| @@ -28,6 +30,9 @@ func (p *Proxy) setupMiddleware() { | |||||
| 28 | // Add UUID generator middleware for both HTTP and HTTPS servers | 30 | // Add UUID generator middleware for both HTTP and HTTPS servers | ||
| 29 | p.httpsServer.Use(p.createRequestIDMiddleware()) | 31 | p.httpsServer.Use(p.createRequestIDMiddleware()) | ||
| 30 | 32 | ||||
| 33 | + | // Add middleware to log incoming headers (for debugging Cloudflare detection) | |||
| 34 | + | p.httpsServer.Use(logHeadersMiddleware()) | |||
| 35 | + | ||||
| 31 | // Check if rate limiting is disabled via configuration | 36 | // Check if rate limiting is disabled via configuration | ||
| 32 | if p.config.EnableRateLimit { | 37 | if p.config.EnableRateLimit { | ||
| 33 | // Create a new Starskey rate limiter store for HTTPS server | 38 | // Create a new Starskey rate limiter store for HTTPS server | ||
| @@ -215,3 +220,19 @@ func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc { | |||||
| 215 | } | 220 | } | ||
| 216 | } | 221 | } | ||
| 217 | } | 222 | } | ||
| 223 | + | ||||
| 224 | + | // logHeadersMiddleware creates a middleware function to log request headers | |||
| 225 | + | func logHeadersMiddleware() echo.MiddlewareFunc { | |||
| 226 | + | return func(next echo.HandlerFunc) echo.HandlerFunc { | |||
| 227 | + | return func(c echo.Context) error { | |||
| 228 | + | // Log all headers at DEBUG level | |||
| 229 | + | headerStrings := []string{} | |||
| 230 | + | for k, v := range c.Request().Header { | |||
| 231 | + | headerStrings = append(headerStrings, fmt.Sprintf("%s: %s", k, strings.Join(v, ","))) | |||
| 232 | + | } | |||
| 233 | + | logger.Debug("Incoming Request Headers", "request_id", c.Get(RequestIDKey), "headers", strings.Join(headerStrings, " | ")) | |||
| 234 | + | ||||
| 235 | + | return next(c) | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | } | |||
M internal/proxy/routes.go +0 -38
| @@ -1971,44 +1971,6 @@ func (p *Proxy) doProxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1971 | 1971 | c.Response().Write([]byte("Bad Gateway: Container is unreachable")) | |
| 1972 | 1972 | } | |
| 1973 | 1973 | ||
| 1974 | - | // For admin domain, ensure the connection succeeds | |
| 1975 | - | if isAdminDomain { | |
| 1976 | - | // Do a pre-check to make sure the admin service is reachable | |
| 1977 | - | testURL := fmt.Sprintf("http://%s:%s/admin/ping", containerIP, containerPort) | |
| 1978 | - | client := &http.Client{ | |
| 1979 | - | Timeout: 2 * time.Second, | |
| 1980 | - | Transport: &http.Transport{ | |
| 1981 | - | DialContext: (&net.Dialer{ | |
| 1982 | - | Timeout: 1 * time.Second, | |
| 1983 | - | }).DialContext, | |
| 1984 | - | }, | |
| 1985 | - | } | |
| 1986 | - | ||
| 1987 | - | logger.Debug("Testing admin connection before proxying", | |
| 1988 | - | "test_url", testURL) | |
| 1989 | - | ||
| 1990 | - | _, err := client.Get(testURL) | |
| 1991 | - | if err != nil { | |
| 1992 | - | logger.Warn("Admin service pre-check failed, trying alternative connection methods", | |
| 1993 | - | "error", err) | |
| 1994 | - | ||
| 1995 | - | // Try alternative IPs for the admin domain if the container IP isn't working | |
| 1996 | - | altIPs := []string{"localhost", "127.0.0.1", p.app.GetConfig().Http.SubDomain} | |
| 1997 | - | ||
| 1998 | - | for _, altIP := range altIPs { | |
| 1999 | - | testURL = fmt.Sprintf("http://%s:%s/admin/ping", altIP, containerPort) | |
| 2000 | - | _, err := client.Get(testURL) | |
| 2001 | - | if err == nil { | |
| 2002 | - | logger.Info("Found working admin connection with alternative IP", | |
| 2003 | - | "ip", altIP) | |
| 2004 | - | containerIP = altIP | |
| 2005 | - | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | |
| 2006 | - | break | |
| 2007 | - | } | |
| 2008 | - | } | |
| 2009 | - | } | |
| 2010 | - | } | |
| 2011 | - | ||
| 2012 | 1974 | // Serve the request using the reverse proxy | |
| 2013 | 1975 | logger.Debug("Serving proxy request", | |
| 2014 | 1976 | "target", targetURL.String()) | |
| @@ -1971,44 +1971,6 @@ func (p *Proxy) doProxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||||
| 1971 | c.Response().Write([]byte("Bad Gateway: Container is unreachable")) | 1971 | c.Response().Write([]byte("Bad Gateway: Container is unreachable")) | ||
| 1972 | } | 1972 | } | ||
| 1973 | 1973 | ||||
| 1974 | - | // For admin domain, ensure the connection succeeds | |||
| 1975 | - | if isAdminDomain { | |||
| 1976 | - | // Do a pre-check to make sure the admin service is reachable | |||
| 1977 | - | testURL := fmt.Sprintf("http://%s:%s/admin/ping", containerIP, containerPort) | |||
| 1978 | - | client := &http.Client{ | |||
| 1979 | - | Timeout: 2 * time.Second, | |||
| 1980 | - | Transport: &http.Transport{ | |||
| 1981 | - | DialContext: (&net.Dialer{ | |||
| 1982 | - | Timeout: 1 * time.Second, | |||
| 1983 | - | }).DialContext, | |||
| 1984 | - | }, | |||
| 1985 | - | } | |||
| 1986 | - | ||||
| 1987 | - | logger.Debug("Testing admin connection before proxying", | |||
| 1988 | - | "test_url", testURL) | |||
| 1989 | - | ||||
| 1990 | - | _, err := client.Get(testURL) | |||
| 1991 | - | if err != nil { | |||
| 1992 | - | logger.Warn("Admin service pre-check failed, trying alternative connection methods", | |||
| 1993 | - | "error", err) | |||
| 1994 | - | ||||
| 1995 | - | // Try alternative IPs for the admin domain if the container IP isn't working | |||
| 1996 | - | altIPs := []string{"localhost", "127.0.0.1", p.app.GetConfig().Http.SubDomain} | |||
| 1997 | - | ||||
| 1998 | - | for _, altIP := range altIPs { | |||
| 1999 | - | testURL = fmt.Sprintf("http://%s:%s/admin/ping", altIP, containerPort) | |||
| 2000 | - | _, err := client.Get(testURL) | |||
| 2001 | - | if err == nil { | |||
| 2002 | - | logger.Info("Found working admin connection with alternative IP", | |||
| 2003 | - | "ip", altIP) | |||
| 2004 | - | containerIP = altIP | |||
| 2005 | - | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | |||
| 2006 | - | break | |||
| 2007 | - | } | |||
| 2008 | - | } | |||
| 2009 | - | } | |||
| 2010 | - | } | |||
| 2011 | - | ||||
| 2012 | // Serve the request using the reverse proxy | 1974 | // Serve the request using the reverse proxy | ||
| 2013 | logger.Debug("Serving proxy request", | 1975 | logger.Debug("Serving proxy request", | ||
| 2014 | "target", targetURL.String()) | 1976 | "target", targetURL.String()) | ||
M internal/proxy/server.go +121 -102
| @@ -3,6 +3,7 @@ package proxy | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/tls" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "strings" | |
| @@ -209,12 +210,13 @@ func (p *Proxy) Stop() error { | |||
| 209 | 210 | return nil | |
| 210 | 211 | } | |
| 211 | 212 | ||
| 212 | - | // testAdminConnection attempts to find a working connection to the Gordon admin | |
| 213 | + | // testAdminConnection attempts to find a working connection to the Gordon admin ping endpoint | |
| 213 | 214 | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |
| 214 | - | adminPath := p.app.GetConfig().Admin.Path | |
| 215 | - | url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, adminPath) | |
| 215 | + | adminPath := p.app.GetConfig().Admin.Path // Get the base admin path | |
| 216 | + | pingPath := adminPath + "/ping" // Construct the full ping path | |
| 217 | + | url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, pingPath) | |
| 216 | 218 | ||
| 217 | - | logger.Debug("Testing admin connection", "url", url) | |
| 219 | + | logger.Debug("Testing admin ping connection", "url", url) // Update log message | |
| 218 | 220 | ||
| 219 | 221 | // Try connecting using the provided default host | |
| 220 | 222 | client := &http.Client{ | |
| @@ -230,11 +232,18 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |||
| 230 | 232 | resp, err := client.Get(url) | |
| 231 | 233 | if err == nil { | |
| 232 | 234 | resp.Body.Close() | |
| 233 | - | logger.Info("Admin connection successful", "host", defaultHost) | |
| 234 | - | return defaultHost | |
| 235 | + | // Check if the response status is OK | |
| 236 | + | if resp.StatusCode == http.StatusOK { | |
| 237 | + | logger.Info("Admin ping connection successful", "host", defaultHost) // Update log message | |
| 238 | + | return defaultHost | |
| 239 | + | } else { | |
| 240 | + | logger.Warn("Admin ping connection returned non-OK status", "host", defaultHost, "status_code", resp.StatusCode) | |
| 241 | + | } | |
| 242 | + | } else { | |
| 243 | + | logger.Warn("Admin ping connection failed", "host", defaultHost, "error", err) | |
| 235 | 244 | } | |
| 236 | 245 | ||
| 237 | - | // If connection failed, try other common hostnames | |
| 246 | + | // If connection failed or status not OK, try other common hostnames | |
| 238 | 247 | hosts := []string{ | |
| 239 | 248 | "localhost", | |
| 240 | 249 | "127.0.0.1", | |
| @@ -247,18 +256,24 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |||
| 247 | 256 | continue // Already tried this one | |
| 248 | 257 | } | |
| 249 | 258 | ||
| 250 | - | url = fmt.Sprintf("http://%s:%s%s", host, port, adminPath) | |
| 251 | - | logger.Debug("Trying alternative host", "url", url) | |
| 259 | + | url = fmt.Sprintf("http://%s:%s%s", host, port, pingPath) // Use full pingPath here too | |
| 260 | + | logger.Debug("Trying alternative host for admin ping", "url", url) // Update log message | |
| 252 | 261 | ||
| 253 | 262 | resp, err := client.Get(url) | |
| 254 | 263 | if err == nil { | |
| 255 | 264 | resp.Body.Close() | |
| 256 | - | logger.Info("Found working admin connection", "host", host) | |
| 257 | - | return host | |
| 265 | + | if resp.StatusCode == http.StatusOK { | |
| 266 | + | logger.Info("Found working admin ping connection", "host", host) // Update log message | |
| 267 | + | return host | |
| 268 | + | } else { | |
| 269 | + | logger.Warn("Admin ping connection on alternative host returned non-OK status", "host", host, "status_code", resp.StatusCode) | |
| 270 | + | } | |
| 271 | + | } else { | |
| 272 | + | logger.Warn("Admin ping connection failed on alternative host", "host", host, "error", err) | |
| 258 | 273 | } | |
| 259 | 274 | } | |
| 260 | 275 | ||
| 261 | - | logger.Warn("Could not establish admin connection on any tested host", "fallback", defaultHost) | |
| 276 | + | logger.Warn("Could not establish admin ping connection on any tested host", "fallback", defaultHost) // Update log message | |
| 262 | 277 | return defaultHost | |
| 263 | 278 | } | |
| 264 | 279 | ||
| @@ -473,105 +488,109 @@ func (p *Proxy) checkExternalPortAccess() { | |||
| 473 | 488 | ||
| 474 | 489 | // StartHTTPSServer starts the HTTPS server | |
| 475 | 490 | func (p *Proxy) StartHTTPSServer() error { | |
| 476 | - | logger.Info("Starting HTTPS server", | |
| 477 | - | "port", p.config.Port, | |
| 478 | - | "mode", p.config.LetsEncryptMode) | |
| 479 | - | ||
| 480 | - | // Verify the certificate manager is initialized (it's done in NewProxy now) | |
| 481 | - | if p.certificateManager == nil { | |
| 482 | - | // This should ideally not happen if NewProxy succeeded | |
| 483 | - | logger.Error("Certificate manager is nil in StartHTTPSServer, cannot start HTTPS") | |
| 484 | - | return fmt.Errorf("certificate manager not initialized") | |
| 485 | - | } | |
| 486 | - | logger.Info("Using new certificate management system") | |
| 487 | - | ||
| 488 | - | // Setup TLS configuration | |
| 489 | - | tlsConfig := &tls.Config{ | |
| 490 | - | // Use the helper function that gets the GetCertificate func from the manager | |
| 491 | - | GetCertificate: p.getTLSCertificateFunc(), | |
| 492 | - | // Ensure we use TLS 1.2 or higher | |
| 493 | - | MinVersion: tls.VersionTLS12, | |
| 494 | - | // Optimize cipher suites for security and performance | |
| 495 | - | CipherSuites: []uint16{ | |
| 496 | - | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, | |
| 497 | - | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, | |
| 498 | - | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, | |
| 499 | - | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, | |
| 500 | - | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, | |
| 501 | - | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, | |
| 502 | - | }, | |
| 503 | - | // Use server's preferred ciphersuite | |
| 504 | - | PreferServerCipherSuites: true, | |
| 505 | - | // Enable HTTP/2 | |
| 506 | - | NextProtos: []string{"h2", "http/1.1"}, | |
| 507 | - | // Add server name to use when client doesn't send SNI | |
| 508 | - | ServerName: p.app.GetConfig().Http.FullDomain(), | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | // Removed setup for ACME challenge handler on the persistent HTTP server, | |
| 512 | - | // as it's no longer running. Lego's temporary server will handle challenges. | |
| 513 | - | // p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.getACMEHTTPHandler())) | |
| 514 | - | ||
| 515 | - | // Create a custom HTTPS server with proper TLS config | |
| 516 | - | httpsServer := &http.Server{ | |
| 517 | - | Addr: fmt.Sprintf(":%s", p.config.Port), | |
| 518 | - | Handler: p.httpsServer, | |
| 519 | - | TLSConfig: tlsConfig, | |
| 520 | - | // Add increased timeouts to prevent hanging connections | |
| 521 | - | ReadTimeout: 60 * time.Second, | |
| 522 | - | WriteTimeout: 60 * time.Second, | |
| 523 | - | IdleTimeout: 120 * time.Second, | |
| 524 | - | ReadHeaderTimeout: 30 * time.Second, // Add header timeout | |
| 525 | - | } | |
| 491 | + | // Setup main Echo instance for routing (used by both HTTP/HTTPS handlers) | |
| 492 | + | e := p.httpsServer // Use the existing Echo instance | |
| 493 | + | ||
| 494 | + | // If SkipCertificates is true, start a plain HTTP listener instead of HTTPS | |
| 495 | + | if p.config.SkipCertificates { | |
| 496 | + | logger.Info("Starting plain HTTP listener because SkipCertificates is true", | |
| 497 | + | "port", p.config.DefaultHttpChallengePort) | |
| 498 | + | ||
| 499 | + | // Create a standard http.Server for the plain HTTP listener | |
| 500 | + | httpServer := &http.Server{ | |
| 501 | + | Addr: fmt.Sprintf(":%s", p.config.DefaultHttpChallengePort), // Add colon prefix | |
| 502 | + | Handler: e, // Use the main Echo instance | |
| 503 | + | ReadTimeout: 5 * time.Second, | |
| 504 | + | WriteTimeout: 10 * time.Second, | |
| 505 | + | IdleTimeout: 120 * time.Second, | |
| 506 | + | } | |
| 526 | 507 | ||
| 527 | - | // Store the HTTPS server for later use in Stop() | |
| 528 | - | p.httpsServer.Server = httpsServer | |
| 508 | + | // Start plain HTTP listener using http.Server's ListenAndServe | |
| 509 | + | go func() { | |
| 510 | + | logger.Info("Plain HTTP listener starting", "address", httpServer.Addr) | |
| 511 | + | if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| 512 | + | // Log specific errors if possible | |
| 513 | + | if strings.Contains(err.Error(), "bind: address already in use") { | |
| 514 | + | logger.Error("Plain HTTP port is already in use", "port", p.config.DefaultHttpChallengePort, "error", err) | |
| 515 | + | } else if strings.Contains(err.Error(), "permission denied") { | |
| 516 | + | logger.Error("Permission denied binding to plain HTTP port", "port", p.config.DefaultHttpChallengePort, "error", err) | |
| 517 | + | } else { | |
| 518 | + | logger.Error("Plain HTTP listener failed", "error", err, "address", httpServer.Addr) | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | }() | |
| 522 | + | p.serverStarted = true // Mark server as started | |
| 529 | 523 | ||
| 530 | - | // // Create a custom HTTP server | |
| 531 | - | // httpServer := &http.Server{ | |
| 532 | - | // Addr: fmt.Sprintf(":%s", p.config.HttpPort), | |
| 533 | - | // Handler: p.httpServer, | |
| 534 | - | // ReadTimeout: 30 * time.Second, | |
| 535 | - | // WriteTimeout: 30 * time.Second, | |
| 536 | - | // IdleTimeout: 120 * time.Second, | |
| 537 | - | // } | |
| 524 | + | } else { | |
| 525 | + | // --- Start HTTPS Server (only if SkipCertificates is false) --- | |
| 526 | + | logger.Info("Starting HTTPS server", | |
| 527 | + | "port", p.config.Port, | |
| 528 | + | "mode", p.config.LetsEncryptMode) | |
| 538 | 529 | ||
| 539 | - | p.serverStarted = true | |
| 530 | + | // Verify certificate manager is initialized *only* if we need it for HTTPS | |
| 531 | + | if p.certificateManager == nil { | |
| 532 | + | logger.Error("Certificate manager is nil, cannot start HTTPS server") | |
| 533 | + | return fmt.Errorf("certificate manager not initialized for HTTPS") | |
| 534 | + | } | |
| 535 | + | logger.Info("Using certificate management system for HTTPS") | |
| 536 | + | ||
| 537 | + | // Setup TLS configuration | |
| 538 | + | tlsConfig := &tls.Config{ | |
| 539 | + | GetCertificate: p.getTLSCertificateFunc(), | |
| 540 | + | MinVersion: tls.VersionTLS12, | |
| 541 | + | CipherSuites: []uint16{ | |
| 542 | + | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, | |
| 543 | + | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, | |
| 544 | + | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, | |
| 545 | + | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, | |
| 546 | + | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, | |
| 547 | + | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, | |
| 548 | + | }, | |
| 549 | + | PreferServerCipherSuites: true, | |
| 550 | + | NextProtos: []string{"h2", "http/1.1"}, | |
| 551 | + | ServerName: p.app.GetConfig().Http.FullDomain(), | |
| 552 | + | } | |
| 540 | 553 | ||
| 541 | - | // httpServer is no longer needed as ACME HTTP-01 uses its own temporary server | |
| 542 | - | // httpServer := &http.Server{ | |
| 543 | - | // Addr: fmt.Sprintf(":%s", p.config.HttpPort), | |
| 544 | - | // Handler: p.httpServer, | |
| 545 | - | // ReadTimeout: 30 * time.Second, | |
| 546 | - | // WriteTimeout: 30 * time.Second, | |
| 547 | - | // IdleTimeout: 120 * time.Second, | |
| 548 | - | // } | |
| 554 | + | // Create a custom HTTPS server with proper TLS config | |
| 555 | + | httpsServer := &http.Server{ | |
| 556 | + | Addr: fmt.Sprintf(":%s", p.config.Port), // Use the configured HTTPS port (e.g., :443) | |
| 557 | + | Handler: e, // Use the main Echo instance | |
| 558 | + | TLSConfig: tlsConfig, | |
| 559 | + | ReadTimeout: 60 * time.Second, | |
| 560 | + | WriteTimeout: 60 * time.Second, | |
| 561 | + | IdleTimeout: 120 * time.Second, | |
| 562 | + | ReadHeaderTimeout: 30 * time.Second, | |
| 563 | + | } | |
| 549 | 564 | ||
| 550 | - | // Start HTTPS server with more error details | |
| 551 | - | go func() { | |
| 552 | - | logger.Info("Starting HTTPS server", "address", httpsServer.Addr) | |
| 553 | - | // Using empty strings for cert and key files since we're using GetCertificate | |
| 554 | - | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | |
| 555 | - | logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr) | |
| 556 | - | ||
| 557 | - | // Print more diagnostic information | |
| 558 | - | if strings.Contains(err.Error(), "bind: address already in use") { | |
| 559 | - | logger.Error("Port 443 is already in use by another process. Check if another web server is running.", | |
| 560 | - | "solution", "Stop other services using port 443 or configure Gordon to use a different HTTPS port") | |
| 561 | - | } else if strings.Contains(err.Error(), "permission denied") { | |
| 562 | - | logger.Error("Permission denied when binding to port 443. This often happens when running without root privileges.", | |
| 563 | - | "solution", "Use setcap to allow binding to privileged ports or run in a container with proper port mapping") | |
| 564 | - | } else if strings.Contains(err.Error(), "certificate") { | |
| 565 | - | logger.Error("Certificate-related error when starting HTTPS server", | |
| 566 | - | "solution", "Check certificate files and permissions in the certificate directory") | |
| 565 | + | // Store the HTTPS server for later use in Stop() | |
| 566 | + | p.httpsServer.Server = httpsServer // Store the http.Server within Echo | |
| 567 | + | ||
| 568 | + | // Start HTTPS server in a goroutine | |
| 569 | + | go func() { | |
| 570 | + | logger.Info("HTTPS server starting", "address", httpsServer.Addr) | |
| 571 | + | // Using empty strings for cert and key files since GetCertificate is used | |
| 572 | + | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| 573 | + | // Log specific errors if possible | |
| 574 | + | if strings.Contains(err.Error(), "bind: address already in use") { | |
| 575 | + | logger.Error("HTTPS port is already in use", "port", p.config.Port, "error", err) | |
| 576 | + | } else if strings.Contains(err.Error(), "permission denied") { | |
| 577 | + | logger.Error("Permission denied binding to HTTPS port", "port", p.config.Port, "error", err) | |
| 578 | + | } else if strings.Contains(err.Error(), "certificate") { | |
| 579 | + | logger.Error("Certificate-related error starting HTTPS server", "error", err) | |
| 580 | + | } else { | |
| 581 | + | logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr) | |
| 582 | + | } | |
| 567 | 583 | } | |
| 568 | - | } | |
| 569 | - | }() | |
| 584 | + | }() | |
| 585 | + | p.serverStarted = true // Mark server as started | |
| 586 | + | } | |
| 570 | 587 | ||
| 571 | - | // Test admin connection after server starts | |
| 588 | + | // Test admin connection after attempting to start the appropriate server | |
| 572 | 589 | go p.TestAdminConnectionLater() | |
| 573 | 590 | ||
| 574 | - | return nil | |
| 591 | + | // Remove the old/duplicate HTTP listener logic that was here | |
| 592 | + | ||
| 593 | + | return nil // Return nil indicating server start was initiated | |
| 575 | 594 | } | |
| 576 | 595 | ||
| 577 | 596 | // getTLSCertificateFunc returns a function that gets certificates for TLS handshakes | |
| @@ -3,6 +3,7 @@ package proxy | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/tls" | 5 | "crypto/tls" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "net/http" | 8 | "net/http" | ||
| 8 | "strings" | 9 | "strings" | ||
| @@ -209,12 +210,13 @@ func (p *Proxy) Stop() error { | |||||
| 209 | return nil | 210 | return nil | ||
| 210 | } | 211 | } | ||
| 211 | 212 | ||||
| 212 | - | // testAdminConnection attempts to find a working connection to the Gordon admin | 213 | + | // testAdminConnection attempts to find a working connection to the Gordon admin ping endpoint |
| 213 | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | 214 | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | ||
| 214 | - | adminPath := p.app.GetConfig().Admin.Path | 215 | + | adminPath := p.app.GetConfig().Admin.Path // Get the base admin path |
| 215 | - | url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, adminPath) | 216 | + | pingPath := adminPath + "/ping" // Construct the full ping path |
| 217 | + | url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, pingPath) | |||
| 216 | 218 | ||||
| 217 | - | logger.Debug("Testing admin connection", "url", url) | 219 | + | logger.Debug("Testing admin ping connection", "url", url) // Update log message |
| 218 | 220 | ||||
| 219 | // Try connecting using the provided default host | 221 | // Try connecting using the provided default host | ||
| 220 | client := &http.Client{ | 222 | client := &http.Client{ | ||
| @@ -230,11 +232,18 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |||||
| 230 | resp, err := client.Get(url) | 232 | resp, err := client.Get(url) | ||
| 231 | if err == nil { | 233 | if err == nil { | ||
| 232 | resp.Body.Close() | 234 | resp.Body.Close() | ||
| 233 | - | logger.Info("Admin connection successful", "host", defaultHost) | 235 | + | // Check if the response status is OK |
| 234 | - | return defaultHost | 236 | + | if resp.StatusCode == http.StatusOK { |
| 237 | + | logger.Info("Admin ping connection successful", "host", defaultHost) // Update log message | |||
| 238 | + | return defaultHost | |||
| 239 | + | } else { | |||
| 240 | + | logger.Warn("Admin ping connection returned non-OK status", "host", defaultHost, "status_code", resp.StatusCode) | |||
| 241 | + | } | |||
| 242 | + | } else { | |||
| 243 | + | logger.Warn("Admin ping connection failed", "host", defaultHost, "error", err) | |||
| 235 | } | 244 | } | ||
| 236 | 245 | ||||
| 237 | - | // If connection failed, try other common hostnames | 246 | + | // If connection failed or status not OK, try other common hostnames |
| 238 | hosts := []string{ | 247 | hosts := []string{ | ||
| 239 | "localhost", | 248 | "localhost", | ||
| 240 | "127.0.0.1", | 249 | "127.0.0.1", | ||
| @@ -247,18 +256,24 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |||||
| 247 | continue // Already tried this one | 256 | continue // Already tried this one | ||
| 248 | } | 257 | } | ||
| 249 | 258 | ||||
| 250 | - | url = fmt.Sprintf("http://%s:%s%s", host, port, adminPath) | 259 | + | url = fmt.Sprintf("http://%s:%s%s", host, port, pingPath) // Use full pingPath here too |
| 251 | - | logger.Debug("Trying alternative host", "url", url) | 260 | + | logger.Debug("Trying alternative host for admin ping", "url", url) // Update log message |
| 252 | 261 | ||||
| 253 | resp, err := client.Get(url) | 262 | resp, err := client.Get(url) | ||
| 254 | if err == nil { | 263 | if err == nil { | ||
| 255 | resp.Body.Close() | 264 | resp.Body.Close() | ||
| 256 | - | logger.Info("Found working admin connection", "host", host) | 265 | + | if resp.StatusCode == http.StatusOK { |
| 257 | - | return host | 266 | + | logger.Info("Found working admin ping connection", "host", host) // Update log message |
| 267 | + | return host | |||
| 268 | + | } else { | |||
| 269 | + | logger.Warn("Admin ping connection on alternative host returned non-OK status", "host", host, "status_code", resp.StatusCode) | |||
| 270 | + | } | |||
| 271 | + | } else { | |||
| 272 | + | logger.Warn("Admin ping connection failed on alternative host", "host", host, "error", err) | |||
| 258 | } | 273 | } | ||
| 259 | } | 274 | } | ||
| 260 | 275 | ||||
| 261 | - | logger.Warn("Could not establish admin connection on any tested host", "fallback", defaultHost) | 276 | + | logger.Warn("Could not establish admin ping connection on any tested host", "fallback", defaultHost) // Update log message |
| 262 | return defaultHost | 277 | return defaultHost | ||
| 263 | } | 278 | } | ||
| 264 | 279 | ||||
| @@ -473,105 +488,109 @@ func (p *Proxy) checkExternalPortAccess() { | |||||
| 473 | 488 | ||||
| 474 | // StartHTTPSServer starts the HTTPS server | 489 | // StartHTTPSServer starts the HTTPS server | ||
| 475 | func (p *Proxy) StartHTTPSServer() error { | 490 | func (p *Proxy) StartHTTPSServer() error { | ||
| 476 | - | logger.Info("Starting HTTPS server", | 491 | + | // Setup main Echo instance for routing (used by both HTTP/HTTPS handlers) |
| 477 | - | "port", p.config.Port, | 492 | + | e := p.httpsServer // Use the existing Echo instance |
| 478 | - | "mode", p.config.LetsEncryptMode) | 493 | + | |
| 479 | - | 494 | + | // If SkipCertificates is true, start a plain HTTP listener instead of HTTPS | |
| 480 | - | // Verify the certificate manager is initialized (it's done in NewProxy now) | 495 | + | if p.config.SkipCertificates { |
| 481 | - | if p.certificateManager == nil { | 496 | + | logger.Info("Starting plain HTTP listener because SkipCertificates is true", |
| 482 | - | // This should ideally not happen if NewProxy succeeded | 497 | + | "port", p.config.DefaultHttpChallengePort) |
| 483 | - | logger.Error("Certificate manager is nil in StartHTTPSServer, cannot start HTTPS") | 498 | + | |
| 484 | - | return fmt.Errorf("certificate manager not initialized") | 499 | + | // Create a standard http.Server for the plain HTTP listener |
| 485 | - | } | 500 | + | httpServer := &http.Server{ |
| 486 | - | logger.Info("Using new certificate management system") | 501 | + | Addr: fmt.Sprintf(":%s", p.config.DefaultHttpChallengePort), // Add colon prefix |
| 487 | - | 502 | + | Handler: e, // Use the main Echo instance | |
| 488 | - | // Setup TLS configuration | 503 | + | ReadTimeout: 5 * time.Second, |
| 489 | - | tlsConfig := &tls.Config{ | 504 | + | WriteTimeout: 10 * time.Second, |
| 490 | - | // Use the helper function that gets the GetCertificate func from the manager | 505 | + | IdleTimeout: 120 * time.Second, |
| 491 | - | GetCertificate: p.getTLSCertificateFunc(), | 506 | + | } |
| 492 | - | // Ensure we use TLS 1.2 or higher | |||
| 493 | - | MinVersion: tls.VersionTLS12, | |||
| 494 | - | // Optimize cipher suites for security and performance | |||
| 495 | - | CipherSuites: []uint16{ | |||
| 496 | - | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, | |||
| 497 | - | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, | |||
| 498 | - | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, | |||
| 499 | - | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, | |||
| 500 | - | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, | |||
| 501 | - | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, | |||
| 502 | - | }, | |||
| 503 | - | // Use server's preferred ciphersuite | |||
| 504 | - | PreferServerCipherSuites: true, | |||
| 505 | - | // Enable HTTP/2 | |||
| 506 | - | NextProtos: []string{"h2", "http/1.1"}, | |||
| 507 | - | // Add server name to use when client doesn't send SNI | |||
| 508 | - | ServerName: p.app.GetConfig().Http.FullDomain(), | |||
| 509 | - | } | |||
| 510 | - | ||||
| 511 | - | // Removed setup for ACME challenge handler on the persistent HTTP server, | |||
| 512 | - | // as it's no longer running. Lego's temporary server will handle challenges. | |||
| 513 | - | // p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.getACMEHTTPHandler())) | |||
| 514 | - | ||||
| 515 | - | // Create a custom HTTPS server with proper TLS config | |||
| 516 | - | httpsServer := &http.Server{ | |||
| 517 | - | Addr: fmt.Sprintf(":%s", p.config.Port), | |||
| 518 | - | Handler: p.httpsServer, | |||
| 519 | - | TLSConfig: tlsConfig, | |||
| 520 | - | // Add increased timeouts to prevent hanging connections | |||
| 521 | - | ReadTimeout: 60 * time.Second, | |||
| 522 | - | WriteTimeout: 60 * time.Second, | |||
| 523 | - | IdleTimeout: 120 * time.Second, | |||
| 524 | - | ReadHeaderTimeout: 30 * time.Second, // Add header timeout | |||
| 525 | - | } | |||
| 526 | 507 | ||||
| 527 | - | // Store the HTTPS server for later use in Stop() | 508 | + | // Start plain HTTP listener using http.Server's ListenAndServe |
| 528 | - | p.httpsServer.Server = httpsServer | 509 | + | go func() { |
| 510 | + | logger.Info("Plain HTTP listener starting", "address", httpServer.Addr) | |||
| 511 | + | if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | |||
| 512 | + | // Log specific errors if possible | |||
| 513 | + | if strings.Contains(err.Error(), "bind: address already in use") { | |||
| 514 | + | logger.Error("Plain HTTP port is already in use", "port", p.config.DefaultHttpChallengePort, "error", err) | |||
| 515 | + | } else if strings.Contains(err.Error(), "permission denied") { | |||
| 516 | + | logger.Error("Permission denied binding to plain HTTP port", "port", p.config.DefaultHttpChallengePort, "error", err) | |||
| 517 | + | } else { | |||
| 518 | + | logger.Error("Plain HTTP listener failed", "error", err, "address", httpServer.Addr) | |||
| 519 | + | } | |||
| 520 | + | } | |||
| 521 | + | }() | |||
| 522 | + | p.serverStarted = true // Mark server as started | |||
| 529 | 523 | ||||
| 530 | - | // // Create a custom HTTP server | 524 | + | } else { |
| 531 | - | // httpServer := &http.Server{ | 525 | + | // --- Start HTTPS Server (only if SkipCertificates is false) --- |
| 532 | - | // Addr: fmt.Sprintf(":%s", p.config.HttpPort), | 526 | + | logger.Info("Starting HTTPS server", |
| 533 | - | // Handler: p.httpServer, | 527 | + | "port", p.config.Port, |
| 534 | - | // ReadTimeout: 30 * time.Second, | 528 | + | "mode", p.config.LetsEncryptMode) |
| 535 | - | // WriteTimeout: 30 * time.Second, | |||
| 536 | - | // IdleTimeout: 120 * time.Second, | |||
| 537 | - | // } | |||
| 538 | 529 | ||||
| 539 | - | p.serverStarted = true | 530 | + | // Verify certificate manager is initialized *only* if we need it for HTTPS |
| 531 | + | if p.certificateManager == nil { | |||
| 532 | + | logger.Error("Certificate manager is nil, cannot start HTTPS server") | |||
| 533 | + | return fmt.Errorf("certificate manager not initialized for HTTPS") | |||
| 534 | + | } | |||
| 535 | + | logger.Info("Using certificate management system for HTTPS") | |||
| 536 | + | ||||
| 537 | + | // Setup TLS configuration | |||
| 538 | + | tlsConfig := &tls.Config{ | |||
| 539 | + | GetCertificate: p.getTLSCertificateFunc(), | |||
| 540 | + | MinVersion: tls.VersionTLS12, | |||
| 541 | + | CipherSuites: []uint16{ | |||
| 542 | + | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, | |||
| 543 | + | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, | |||
| 544 | + | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, | |||
| 545 | + | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, | |||
| 546 | + | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, | |||
| 547 | + | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, | |||
| 548 | + | }, | |||
| 549 | + | PreferServerCipherSuites: true, | |||
| 550 | + | NextProtos: []string{"h2", "http/1.1"}, | |||
| 551 | + | ServerName: p.app.GetConfig().Http.FullDomain(), | |||
| 552 | + | } | |||
| 540 | 553 | ||||
| 541 | - | // httpServer is no longer needed as ACME HTTP-01 uses its own temporary server | 554 | + | // Create a custom HTTPS server with proper TLS config |
| 542 | - | // httpServer := &http.Server{ | 555 | + | httpsServer := &http.Server{ |
| 543 | - | // Addr: fmt.Sprintf(":%s", p.config.HttpPort), | 556 | + | Addr: fmt.Sprintf(":%s", p.config.Port), // Use the configured HTTPS port (e.g., :443) |
| 544 | - | // Handler: p.httpServer, | 557 | + | Handler: e, // Use the main Echo instance |
| 545 | - | // ReadTimeout: 30 * time.Second, | 558 | + | TLSConfig: tlsConfig, |
| 546 | - | // WriteTimeout: 30 * time.Second, | 559 | + | ReadTimeout: 60 * time.Second, |
| 547 | - | // IdleTimeout: 120 * time.Second, | 560 | + | WriteTimeout: 60 * time.Second, |
| 548 | - | // } | 561 | + | IdleTimeout: 120 * time.Second, |
| 562 | + | ReadHeaderTimeout: 30 * time.Second, | |||
| 563 | + | } | |||
| 549 | 564 | ||||
| 550 | - | // Start HTTPS server with more error details | 565 | + | // Store the HTTPS server for later use in Stop() |
| 551 | - | go func() { | 566 | + | p.httpsServer.Server = httpsServer // Store the http.Server within Echo |
| 552 | - | logger.Info("Starting HTTPS server", "address", httpsServer.Addr) | 567 | + | |
| 553 | - | // Using empty strings for cert and key files since we're using GetCertificate | 568 | + | // Start HTTPS server in a goroutine |
| 554 | - | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | 569 | + | go func() { |
| 555 | - | logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr) | 570 | + | logger.Info("HTTPS server starting", "address", httpsServer.Addr) |
| 556 | - | 571 | + | // Using empty strings for cert and key files since GetCertificate is used | |
| 557 | - | // Print more diagnostic information | 572 | + | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { |
| 558 | - | if strings.Contains(err.Error(), "bind: address already in use") { | 573 | + | // Log specific errors if possible |
| 559 | - | logger.Error("Port 443 is already in use by another process. Check if another web server is running.", | 574 | + | if strings.Contains(err.Error(), "bind: address already in use") { |
| 560 | - | "solution", "Stop other services using port 443 or configure Gordon to use a different HTTPS port") | 575 | + | logger.Error("HTTPS port is already in use", "port", p.config.Port, "error", err) |
| 561 | - | } else if strings.Contains(err.Error(), "permission denied") { | 576 | + | } else if strings.Contains(err.Error(), "permission denied") { |
| 562 | - | logger.Error("Permission denied when binding to port 443. This often happens when running without root privileges.", | 577 | + | logger.Error("Permission denied binding to HTTPS port", "port", p.config.Port, "error", err) |
| 563 | - | "solution", "Use setcap to allow binding to privileged ports or run in a container with proper port mapping") | 578 | + | } else if strings.Contains(err.Error(), "certificate") { |
| 564 | - | } else if strings.Contains(err.Error(), "certificate") { | 579 | + | logger.Error("Certificate-related error starting HTTPS server", "error", err) |
| 565 | - | logger.Error("Certificate-related error when starting HTTPS server", | 580 | + | } else { |
| 566 | - | "solution", "Check certificate files and permissions in the certificate directory") | 581 | + | logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr) |
| 582 | + | } | |||
| 567 | } | 583 | } | ||
| 568 | - | } | 584 | + | }() |
| 569 | - | }() | 585 | + | p.serverStarted = true // Mark server as started |
| 586 | + | } | |||
| 570 | 587 | ||||
| 571 | - | // Test admin connection after server starts | 588 | + | // Test admin connection after attempting to start the appropriate server |
| 572 | go p.TestAdminConnectionLater() | 589 | go p.TestAdminConnectionLater() | ||
| 573 | 590 | ||||
| 574 | - | return nil | 591 | + | // Remove the old/duplicate HTTP listener logic that was here |
| 592 | + | ||||
| 593 | + | return nil // Return nil indicating server start was initiated | |||
| 575 | } | 594 | } | ||
| 576 | 595 | ||||
| 577 | // getTLSCertificateFunc returns a function that gets certificates for TLS handshakes | 596 | // getTLSCertificateFunc returns a function that gets certificates for TLS handshakes | ||
M internal/templating/models/templ/components/createcontainer.templ +8 -6
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package components | |
| 2 | 2 | ||
| 3 | + | import "fmt" | |
| 4 | + | ||
| 3 | 5 | // ContainerFormData holds the data needed for the container creation form | |
| 4 | 6 | type ContainerFormData struct { | |
| 5 | 7 | ImageName string | |
| @@ -17,7 +19,7 @@ type ContainerFormData struct { | |||
| 17 | 19 | ||
| 18 | 20 | // CreateContainerForm displays a form for creating a new container | |
| 19 | 21 | templ CreateContainerForm(data ContainerFormData) { | |
| 20 | - | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets"> | |
| 22 | + | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"> | |
| 21 | 23 | <!-- Left Side: Docker Run Options --> | |
| 22 | 24 | <div class="flex-grow card p-6"> | |
| 23 | 25 | <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2> | |
| @@ -27,11 +29,12 @@ templ CreateContainerForm(data ContainerFormData) { | |||
| 27 | 29 | <span>{ data.ErrorMessage }</span> | |
| 28 | 30 | </div> | |
| 29 | 31 | } | |
| 30 | - | <form id={ "create-container-form-" + data.ShortID } action={ templ.SafeURL("/create-container/" + data.ShortID) } method="post" | |
| 31 | - | hx-post={ "/htmx/create-container/" + data.ShortID } | |
| 32 | + | <form id={ "create-container-form-" + data.ShortID } | |
| 33 | + | action={ templ.URL(fmt.Sprintf("/create-container/%s", data.ShortID)) } | |
| 34 | + | method="post" | |
| 35 | + | hx-post={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", data.ShortID))) } | |
| 32 | 36 | hx-swap="innerHTML" | |
| 33 | - | hx-target={ "#create-container-" + data.ShortID } | |
| 34 | - | hx-target-*={ "#error-message-create-container-" + data.ShortID }> | |
| 37 | + | hx-target={ "#create-form-target-" + data.ShortID }> | |
| 35 | 38 | <!-- Image Name --> | |
| 36 | 39 | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4"> | |
| 37 | 40 | <div class="md:col-span-1"> | |
| @@ -154,7 +157,6 @@ templ CreateContainerForm(data ContainerFormData) { | |||
| 154 | 157 | Create Container | |
| 155 | 158 | </button> | |
| 156 | 159 | </form> | |
| 157 | - | <div id={ "error-message-create-container-" + data.ShortID } class="text-error mt-4"></div> | |
| 158 | 160 | </div> | |
| 159 | 161 | </div> | |
| 160 | 162 | } ⏎̸ | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package components | 1 | package components | ||
| 2 | 2 | ||||
| 3 | + | import "fmt" | |||
| 4 | + | ||||
| 3 | // ContainerFormData holds the data needed for the container creation form | 5 | // ContainerFormData holds the data needed for the container creation form | ||
| 4 | type ContainerFormData struct { | 6 | type ContainerFormData struct { | ||
| 5 | ImageName string | 7 | ImageName string | ||
| @@ -17,7 +19,7 @@ type ContainerFormData struct { | |||||
| 17 | 19 | ||||
| 18 | // CreateContainerForm displays a form for creating a new container | 20 | // CreateContainerForm displays a form for creating a new container | ||
| 19 | templ CreateContainerForm(data ContainerFormData) { | 21 | templ CreateContainerForm(data ContainerFormData) { | ||
| 20 | - | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets"> | 22 | + | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"> |
| 21 | <!-- Left Side: Docker Run Options --> | 23 | <!-- Left Side: Docker Run Options --> | ||
| 22 | <div class="flex-grow card p-6"> | 24 | <div class="flex-grow card p-6"> | ||
| 23 | <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2> | 25 | <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2> | ||
| @@ -27,11 +29,12 @@ templ CreateContainerForm(data ContainerFormData) { | |||||
| 27 | <span>{ data.ErrorMessage }</span> | 29 | <span>{ data.ErrorMessage }</span> | ||
| 28 | </div> | 30 | </div> | ||
| 29 | } | 31 | } | ||
| 30 | - | <form id={ "create-container-form-" + data.ShortID } action={ templ.SafeURL("/create-container/" + data.ShortID) } method="post" | 32 | + | <form id={ "create-container-form-" + data.ShortID } |
| 31 | - | hx-post={ "/htmx/create-container/" + data.ShortID } | 33 | + | action={ templ.URL(fmt.Sprintf("/create-container/%s", data.ShortID)) } |
| 34 | + | method="post" | |||
| 35 | + | hx-post={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", data.ShortID))) } | |||
| 32 | hx-swap="innerHTML" | 36 | hx-swap="innerHTML" | ||
| 33 | - | hx-target={ "#create-container-" + data.ShortID } | 37 | + | hx-target={ "#create-form-target-" + data.ShortID }> |
| 34 | - | hx-target-*={ "#error-message-create-container-" + data.ShortID }> | |||
| 35 | <!-- Image Name --> | 38 | <!-- Image Name --> | ||
| 36 | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4"> | 39 | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4"> | ||
| 37 | <div class="md:col-span-1"> | 40 | <div class="md:col-span-1"> | ||
| @@ -154,7 +157,6 @@ templ CreateContainerForm(data ContainerFormData) { | |||||
| 154 | Create Container | 157 | Create Container | ||
| 155 | </button> | 158 | </button> | ||
| 156 | </form> | 159 | </form> | ||
| 157 | - | <div id={ "error-message-create-container-" + data.ShortID } class="text-error mt-4"></div> | |||
| 158 | </div> | 160 | </div> | ||
| 159 | </div> | 161 | </div> | ||
| 160 | } ⏎̸ | 162 | } ⏎̸ | ||
M internal/templating/models/templ/components/createcontainer_templ.go +39 -63 generated file: collapsed
M internal/templating/models/templ/components/footer.templ +7 -29
| @@ -3,37 +3,15 @@ package components | |||
| 3 | 3 | // Footer is a templ component for the page footer | |
| 4 | 4 | // It replaces footer.gohtml | |
| 5 | 5 | templ Footer() { | |
| 6 | - | <footer class="footer p-10 bg-base-200 text-base-content"> | |
| 7 | - | <div> | |
| 8 | - | <span class="footer-title">Gordon</span> | |
| 9 | - | <a class="link link-hover">About us</a> | |
| 10 | - | <a class="link link-hover">Documentation</a> | |
| 11 | - | <a class="link link-hover">Github</a> | |
| 12 | - | </div> | |
| 13 | - | <div> | |
| 14 | - | <span class="footer-title">Container Management</span> | |
| 15 | - | <a class="link link-hover">Dashboard</a> | |
| 16 | - | <a class="link link-hover">Settings</a> | |
| 17 | - | <a class="link link-hover">Stats</a> | |
| 18 | - | </div> | |
| 19 | - | <div> | |
| 20 | - | <span class="footer-title">Legal</span> | |
| 21 | - | <a class="link link-hover">Terms of use</a> | |
| 22 | - | <a class="link link-hover">Privacy policy</a> | |
| 23 | - | <a class="link link-hover">License</a> | |
| 24 | - | </div> | |
| 25 | - | </footer> | |
| 26 | - | <footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300"> | |
| 6 | + | <footer class="footer items-center p-4 bg-base-300 text-base-content"> | |
| 27 | 7 | <div class="items-center grid-flow-col"> | |
| 28 | - | <span class="iconfb text-2xl"></span> | |
| 29 | - | <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p> | |
| 8 | + | <span class="iconfb text-xl"></span> | |
| 9 | + | <p class="IBMPlexSans text-sm">Gordon Container Manager © 2024</p> | |
| 30 | 10 | </div> | |
| 31 | - | <div class="md:place-self-center md:justify-self-end"> | |
| 32 | - | <div class="grid grid-flow-col gap-4"> | |
| 33 | - | <a class="iconfb text-2xl"></a> | |
| 34 | - | <a class="iconfb text-2xl"></a> | |
| 35 | - | <a class="iconfb text-2xl"></a> | |
| 36 | - | </div> | |
| 11 | + | <div class="grid-flow-col gap-4 md:place-self-center md:justify-self-end"> | |
| 12 | + | <a class="link link-hover text-sm" href="https://github.com/bnema/gordon" target="_blank">GitHub</a> | |
| 13 | + | // Add other essential links here if needed, e.g., Documentation | |
| 14 | + | // <a class="link link-hover text-sm">Documentation</a> | |
| 37 | 15 | </div> | |
| 38 | 16 | </footer> | |
| 39 | 17 | }⏎̸ | |
| @@ -3,37 +3,15 @@ package components | |||||
| 3 | // Footer is a templ component for the page footer | 3 | // Footer is a templ component for the page footer | ||
| 4 | // It replaces footer.gohtml | 4 | // It replaces footer.gohtml | ||
| 5 | templ Footer() { | 5 | templ Footer() { | ||
| 6 | - | <footer class="footer p-10 bg-base-200 text-base-content"> | 6 | + | <footer class="footer items-center p-4 bg-base-300 text-base-content"> |
| 7 | - | <div> | |||
| 8 | - | <span class="footer-title">Gordon</span> | |||
| 9 | - | <a class="link link-hover">About us</a> | |||
| 10 | - | <a class="link link-hover">Documentation</a> | |||
| 11 | - | <a class="link link-hover">Github</a> | |||
| 12 | - | </div> | |||
| 13 | - | <div> | |||
| 14 | - | <span class="footer-title">Container Management</span> | |||
| 15 | - | <a class="link link-hover">Dashboard</a> | |||
| 16 | - | <a class="link link-hover">Settings</a> | |||
| 17 | - | <a class="link link-hover">Stats</a> | |||
| 18 | - | </div> | |||
| 19 | - | <div> | |||
| 20 | - | <span class="footer-title">Legal</span> | |||
| 21 | - | <a class="link link-hover">Terms of use</a> | |||
| 22 | - | <a class="link link-hover">Privacy policy</a> | |||
| 23 | - | <a class="link link-hover">License</a> | |||
| 24 | - | </div> | |||
| 25 | - | </footer> | |||
| 26 | - | <footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300"> | |||
| 27 | <div class="items-center grid-flow-col"> | 7 | <div class="items-center grid-flow-col"> | ||
| 28 | - | <span class="iconfb text-2xl"></span> | 8 | + | <span class="iconfb text-xl"></span> |
| 29 | - | <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p> | 9 | + | <p class="IBMPlexSans text-sm">Gordon Container Manager © 2024</p> |
| 30 | </div> | 10 | </div> | ||
| 31 | - | <div class="md:place-self-center md:justify-self-end"> | 11 | + | <div class="grid-flow-col gap-4 md:place-self-center md:justify-self-end"> |
| 32 | - | <div class="grid grid-flow-col gap-4"> | 12 | + | <a class="link link-hover text-sm" href="https://github.com/bnema/gordon" target="_blank">GitHub</a> |
| 33 | - | <a class="iconfb text-2xl"></a> | 13 | + | // Add other essential links here if needed, e.g., Documentation |
| 34 | - | <a class="iconfb text-2xl"></a> | 14 | + | // <a class="link link-hover text-sm">Documentation</a> |
| 35 | - | <a class="iconfb text-2xl"></a> | |||
| 36 | - | </div> | |||
| 37 | </div> | 15 | </div> | ||
| 38 | </footer> | 16 | </footer> | ||
| 39 | }⏎̸ | 17 | }⏎̸ | ||
M internal/templating/models/templ/components/footer_templ.go +1 -1 generated file: collapsed
M internal/templating/models/templ/components/imagelist.templ +20 -3
| @@ -2,6 +2,7 @@ package components | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "strconv" | |
| 5 | + | "fmt" | |
| 5 | 6 | ) | |
| 6 | 7 | ||
| 7 | 8 | // HumanReadableImage represents an image with human-readable attributes | |
| @@ -24,7 +25,7 @@ templ ImageList(images []HumanReadableImage) { | |||
| 24 | 25 | <div class="card-body p-4"> | |
| 25 | 26 | ||
| 26 | 27 | <!-- Action buttons --> | |
| 27 | - | <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> | |
| 28 | + | <div class="flex flex-col sm:flex-row sm:justify-start items-start sm:items-center gap-4 flex-wrap"> | |
| 28 | 29 | <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3> | |
| 29 | 30 | <div class="flex gap-2"> | |
| 30 | 31 | <button | |
| @@ -41,6 +42,14 @@ templ ImageList(images []HumanReadableImage) { | |||
| 41 | 42 | hx-swap="innerHTML"> | |
| 42 | 43 | <span class="iconf"></span> Refresh | |
| 43 | 44 | </button> | |
| 45 | + | <button | |
| 46 | + | class="btn btn-sm btn-warning" | |
| 47 | + | hx-post="/htmx/image-manager/prune" | |
| 48 | + | hx-target="#image-manager" | |
| 49 | + | hx-swap="innerHTML" | |
| 50 | + | hx-confirm="Are you sure you want to delete all unused (dangling and unreferenced) images? This cannot be undone."> | |
| 51 | + | <span class="iconf"></span> Purge Unused | |
| 52 | + | </button> | |
| 44 | 53 | </div> | |
| 45 | 54 | </div> | |
| 46 | 55 | ||
| @@ -90,7 +99,10 @@ templ ImageList(images []HumanReadableImage) { | |||
| 90 | 99 | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"> | |
| 91 | 100 | <li> | |
| 92 | 101 | <a | |
| 93 | - | href={ templ.SafeURL("/htmx/create-container/" + img.ShortID) } | |
| 102 | + | href="#" | |
| 103 | + | hx-get={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", img.ShortID))) } | |
| 104 | + | hx-target={ "#create-form-target-" + img.ShortID } | |
| 105 | + | hx-swap="innerHTML" | |
| 94 | 106 | class="text-success flex items-center"> | |
| 95 | 107 | <span class="iconf mr-2"></span> Create Container | |
| 96 | 108 | </a> | |
| @@ -98,7 +110,7 @@ templ ImageList(images []HumanReadableImage) { | |||
| 98 | 110 | <li> | |
| 99 | 111 | <button | |
| 100 | 112 | class="text-error flex items-center" | |
| 101 | - | hx-delete={ "/htmx/image-manager/delete/" + img.ShortID } | |
| 113 | + | hx-delete={ string(templ.URL(fmt.Sprintf("/htmx/image-manager/delete/%s", img.ShortID))) } | |
| 102 | 114 | hx-target="#image-manager" | |
| 103 | 115 | hx-swap="innerHTML" | |
| 104 | 116 | hx-confirm="Are you sure you want to delete this image?"> | |
| @@ -109,6 +121,11 @@ templ ImageList(images []HumanReadableImage) { | |||
| 109 | 121 | </div> | |
| 110 | 122 | </th> | |
| 111 | 123 | </tr> | |
| 124 | + | <tr> | |
| 125 | + | <td colspan="5" id={ "create-form-target-" + img.ShortID }> | |
| 126 | + | <!-- Form will be loaded here by HTMX --> | |
| 127 | + | </td> | |
| 128 | + | </tr> | |
| 112 | 129 | } | |
| 113 | 130 | } | |
| 114 | 131 | </tbody> | |
| @@ -2,6 +2,7 @@ package components | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strconv" | 4 | "strconv" | ||
| 5 | + | "fmt" | |||
| 5 | ) | 6 | ) | ||
| 6 | 7 | ||||
| 7 | // HumanReadableImage represents an image with human-readable attributes | 8 | // HumanReadableImage represents an image with human-readable attributes | ||
| @@ -24,7 +25,7 @@ templ ImageList(images []HumanReadableImage) { | |||||
| 24 | <div class="card-body p-4"> | 25 | <div class="card-body p-4"> | ||
| 25 | 26 | ||||
| 26 | <!-- Action buttons --> | 27 | <!-- Action buttons --> | ||
| 27 | - | <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> | 28 | + | <div class="flex flex-col sm:flex-row sm:justify-start items-start sm:items-center gap-4 flex-wrap"> |
| 28 | <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3> | 29 | <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3> | ||
| 29 | <div class="flex gap-2"> | 30 | <div class="flex gap-2"> | ||
| 30 | <button | 31 | <button | ||
| @@ -41,6 +42,14 @@ templ ImageList(images []HumanReadableImage) { | |||||
| 41 | hx-swap="innerHTML"> | 42 | hx-swap="innerHTML"> | ||
| 42 | <span class="iconf"></span> Refresh | 43 | <span class="iconf"></span> Refresh | ||
| 43 | </button> | 44 | </button> | ||
| 45 | + | <button | |||
| 46 | + | class="btn btn-sm btn-warning" | |||
| 47 | + | hx-post="/htmx/image-manager/prune" | |||
| 48 | + | hx-target="#image-manager" | |||
| 49 | + | hx-swap="innerHTML" | |||
| 50 | + | hx-confirm="Are you sure you want to delete all unused (dangling and unreferenced) images? This cannot be undone."> | |||
| 51 | + | <span class="iconf"></span> Purge Unused | |||
| 52 | + | </button> | |||
| 44 | </div> | 53 | </div> | ||
| 45 | </div> | 54 | </div> | ||
| 46 | 55 | ||||
| @@ -90,7 +99,10 @@ templ ImageList(images []HumanReadableImage) { | |||||
| 90 | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"> | 99 | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"> | ||
| 91 | <li> | 100 | <li> | ||
| 92 | <a | 101 | <a | ||
| 93 | - | href={ templ.SafeURL("/htmx/create-container/" + img.ShortID) } | 102 | + | href="#" |
| 103 | + | hx-get={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", img.ShortID))) } | |||
| 104 | + | hx-target={ "#create-form-target-" + img.ShortID } | |||
| 105 | + | hx-swap="innerHTML" | |||
| 94 | class="text-success flex items-center"> | 106 | class="text-success flex items-center"> | ||
| 95 | <span class="iconf mr-2"></span> Create Container | 107 | <span class="iconf mr-2"></span> Create Container | ||
| 96 | </a> | 108 | </a> | ||
| @@ -98,7 +110,7 @@ templ ImageList(images []HumanReadableImage) { | |||||
| 98 | <li> | 110 | <li> | ||
| 99 | <button | 111 | <button | ||
| 100 | class="text-error flex items-center" | 112 | class="text-error flex items-center" | ||
| 101 | - | hx-delete={ "/htmx/image-manager/delete/" + img.ShortID } | 113 | + | hx-delete={ string(templ.URL(fmt.Sprintf("/htmx/image-manager/delete/%s", img.ShortID))) } |
| 102 | hx-target="#image-manager" | 114 | hx-target="#image-manager" | ||
| 103 | hx-swap="innerHTML" | 115 | hx-swap="innerHTML" | ||
| 104 | hx-confirm="Are you sure you want to delete this image?"> | 116 | hx-confirm="Are you sure you want to delete this image?"> | ||
| @@ -109,6 +121,11 @@ templ ImageList(images []HumanReadableImage) { | |||||
| 109 | </div> | 121 | </div> | ||
| 110 | </th> | 122 | </th> | ||
| 111 | </tr> | 123 | </tr> | ||
| 124 | + | <tr> | |||
| 125 | + | <td colspan="5" id={ "create-form-target-" + img.ShortID }> | |||
| 126 | + | <!-- Form will be loaded here by HTMX --> | |||
| 127 | + | </td> | |||
| 128 | + | </tr> | |||
| 112 | } | 129 | } | ||
| 113 | } | 130 | } | ||
| 114 | </tbody> | 131 | </tbody> | ||
M internal/templating/models/templ/components/imagelist_templ.go +47 -16 generated file: collapsed
M internal/templating/models/templ/components/navbar.templ +1 -1
| @@ -3,7 +3,7 @@ package components | |||
| 3 | 3 | // Navbar is a menu component for navigation | |
| 4 | 4 | templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) { | |
| 5 | 5 | <!-- DaisyUI Navbar --> | |
| 6 | - | <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50"> | |
| 6 | + | <div class="navbar bg-base-200 shadow-lg fixed top-0 z-50"> | |
| 7 | 7 | <div class="navbar-start"> | |
| 8 | 8 | <div class="flex items-center"> | |
| 9 | 9 | <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4"> | |
| @@ -3,7 +3,7 @@ package components | |||||
| 3 | // Navbar is a menu component for navigation | 3 | // Navbar is a menu component for navigation | ||
| 4 | templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) { | 4 | templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) { | ||
| 5 | <!-- DaisyUI Navbar --> | 5 | <!-- DaisyUI Navbar --> | ||
| 6 | - | <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50"> | 6 | + | <div class="navbar bg-base-200 shadow-lg fixed top-0 z-50"> |
| 7 | <div class="navbar-start"> | 7 | <div class="navbar-start"> | ||
| 8 | <div class="flex items-center"> | 8 | <div class="flex items-center"> | ||
| 9 | <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4"> | 9 | <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4"> | ||
M internal/templating/models/templ/components/navbar_templ.go +1 -1 generated file: collapsed
M internal/templating/models/templ/pages/admin/manager.templ +3 -3
| @@ -17,7 +17,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti | |||
| 17 | 17 | </div> | |
| 18 | 18 | ||
| 19 | 19 | <!-- Stats overview --> | |
| 20 | - | <div class="stats shadow mb-6 w-full"> | |
| 20 | + | <div class="stats shadow mb-6 w-full bg-base-300"> | |
| 21 | 21 | <div class="stat"> | |
| 22 | 22 | <div class="stat-figure text-primary"> | |
| 23 | 23 | <span class="iconf text-3xl"></span> | |
| @@ -40,7 +40,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti | |||
| 40 | 40 | <!-- Tabs for containers and images --> | |
| 41 | 41 | <div role="tablist" class="tabs tabs-boxed mb-4"> | |
| 42 | 42 | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked /> | |
| 43 | - | <div role="tabpanel" class="tab-content p-0"> | |
| 43 | + | <div role="tabpanel" class="tab-content p-0 bg-base-300"> | |
| 44 | 44 | <!-- Container manager section --> | |
| 45 | 45 | <div id="container-manager" | |
| 46 | 46 | hx-get="/htmx/container-manager" | |
| @@ -55,7 +55,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti | |||
| 55 | 55 | </div> | |
| 56 | 56 | ||
| 57 | 57 | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" /> | |
| 58 | - | <div role="tabpanel" class="tab-content p-0"> | |
| 58 | + | <div role="tabpanel" class="tab-content p-0 bg-base-300"> | |
| 59 | 59 | <!-- Image manager section --> | |
| 60 | 60 | <div id="image-manager" | |
| 61 | 61 | hx-get="/htmx/image-manager" | |
| @@ -17,7 +17,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti | |||||
| 17 | </div> | 17 | </div> | ||
| 18 | 18 | ||||
| 19 | <!-- Stats overview --> | 19 | <!-- Stats overview --> | ||
| 20 | - | <div class="stats shadow mb-6 w-full"> | 20 | + | <div class="stats shadow mb-6 w-full bg-base-300"> |
| 21 | <div class="stat"> | 21 | <div class="stat"> | ||
| 22 | <div class="stat-figure text-primary"> | 22 | <div class="stat-figure text-primary"> | ||
| 23 | <span class="iconf text-3xl"></span> | 23 | <span class="iconf text-3xl"></span> | ||
| @@ -40,7 +40,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti | |||||
| 40 | <!-- Tabs for containers and images --> | 40 | <!-- Tabs for containers and images --> | ||
| 41 | <div role="tablist" class="tabs tabs-boxed mb-4"> | 41 | <div role="tablist" class="tabs tabs-boxed mb-4"> | ||
| 42 | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked /> | 42 | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked /> | ||
| 43 | - | <div role="tabpanel" class="tab-content p-0"> | 43 | + | <div role="tabpanel" class="tab-content p-0 bg-base-300"> |
| 44 | <!-- Container manager section --> | 44 | <!-- Container manager section --> | ||
| 45 | <div id="container-manager" | 45 | <div id="container-manager" | ||
| 46 | hx-get="/htmx/container-manager" | 46 | hx-get="/htmx/container-manager" | ||
| @@ -55,7 +55,7 @@ templ ManagerPage(title string, buildVersion string, adminPath string, userSetti | |||||
| 55 | </div> | 55 | </div> | ||
| 56 | 56 | ||||
| 57 | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" /> | 57 | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" /> | ||
| 58 | - | <div role="tabpanel" class="tab-content p-0"> | 58 | + | <div role="tabpanel" class="tab-content p-0 bg-base-300"> |
| 59 | <!-- Image manager section --> | 59 | <!-- Image manager section --> | ||
| 60 | <div id="image-manager" | 60 | <div id="image-manager" | ||
| 61 | hx-get="/htmx/image-manager" | 61 | hx-get="/htmx/image-manager" | ||
M internal/templating/models/templ/pages/admin/manager_templ.go +2 -2
| @@ -56,7 +56,7 @@ func ManagerPage(title string, buildVersion string, adminPath string, userSettin | |||
| 56 | 56 | if templ_7745c5c3_Err != nil { | |
| 57 | 57 | return templ_7745c5c3_Err | |
| 58 | 58 | } | |
| 59 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">Gordon</a></li><li>Manager</li></ul></div><!-- Stats overview --> <div class=\"stats shadow mb-6 w-full\"><div class=\"stat\"><div class=\"stat-figure text-primary\"><span class=\"iconf text-3xl\">\U000f0868</span></div><div class=\"stat-title\">Containers</div><div class=\"stat-value\" id=\"container-count\">") | |
| 59 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">Gordon</a></li><li>Manager</li></ul></div><!-- Stats overview --> <div class=\"stats shadow mb-6 w-full bg-base-300\"><div class=\"stat\"><div class=\"stat-figure text-primary\"><span class=\"iconf text-3xl\">\U000f0868</span></div><div class=\"stat-title\">Containers</div><div class=\"stat-value\" id=\"container-count\">") | |
| 60 | 60 | if templ_7745c5c3_Err != nil { | |
| 61 | 61 | return templ_7745c5c3_Err | |
| 62 | 62 | } | |
| @@ -82,7 +82,7 @@ func ManagerPage(title string, buildVersion string, adminPath string, userSettin | |||
| 82 | 82 | if templ_7745c5c3_Err != nil { | |
| 83 | 83 | return templ_7745c5c3_Err | |
| 84 | 84 | } | |
| 85 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"stat-desc\">Docker images available</div></div></div><!-- Tabs for containers and images --> <div role=\"tablist\" class=\"tabs tabs-boxed mb-4\"><input type=\"radio\" name=\"my_tabs\" role=\"tab\" class=\"tab\" aria-label=\"Containers\" id=\"containers-tab\" checked><div role=\"tabpanel\" class=\"tab-content p-0\"><!-- Container manager section --><div id=\"container-manager\" hx-get=\"/htmx/container-manager\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><!-- Container content loaded via HTMX --><div class=\"text-center p-4\"><span class=\"iconf text-3xl animate-spin inline-block\">\U000f0450</span> Loading containers...</div></div></div><input type=\"radio\" name=\"my_tabs\" role=\"tab\" class=\"tab\" aria-label=\"Images\" id=\"images-tab\"><div role=\"tabpanel\" class=\"tab-content p-0\"><!-- Image manager section --><div id=\"image-manager\" hx-get=\"/htmx/image-manager\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><!-- Image content loaded via HTMX --><div class=\"text-center p-4\"><span class=\"iconf text-3xl animate-spin inline-block\">\U000f0450</span> Loading images...</div></div></div></div>") | |
| 85 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"stat-desc\">Docker images available</div></div></div><!-- Tabs for containers and images --> <div role=\"tablist\" class=\"tabs tabs-boxed mb-4\"><input type=\"radio\" name=\"my_tabs\" role=\"tab\" class=\"tab\" aria-label=\"Containers\" id=\"containers-tab\" checked><div role=\"tabpanel\" class=\"tab-content p-0 bg-base-300\"><!-- Container manager section --><div id=\"container-manager\" hx-get=\"/htmx/container-manager\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><!-- Container content loaded via HTMX --><div class=\"text-center p-4\"><span class=\"iconf text-3xl animate-spin inline-block\">\U000f0450</span> Loading containers...</div></div></div><input type=\"radio\" name=\"my_tabs\" role=\"tab\" class=\"tab\" aria-label=\"Images\" id=\"images-tab\"><div role=\"tabpanel\" class=\"tab-content p-0 bg-base-300\"><!-- Image manager section --><div id=\"image-manager\" hx-get=\"/htmx/image-manager\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><!-- Image content loaded via HTMX --><div class=\"text-center p-4\"><span class=\"iconf text-3xl animate-spin inline-block\">\U000f0450</span> Loading images...</div></div></div></div>") | |
| 86 | 86 | if templ_7745c5c3_Err != nil { | |
| 87 | 87 | return templ_7745c5c3_Err | |
| 88 | 88 | } | |
| @@ -56,7 +56,7 @@ func ManagerPage(title string, buildVersion string, adminPath string, userSettin | |||||
| 56 | if templ_7745c5c3_Err != nil { | 56 | if templ_7745c5c3_Err != nil { | ||
| 57 | return templ_7745c5c3_Err | 57 | return templ_7745c5c3_Err | ||
| 58 | } | 58 | } | ||
| 59 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">Gordon</a></li><li>Manager</li></ul></div><!-- Stats overview --> <div class=\"stats shadow mb-6 w-full\"><div class=\"stat\"><div class=\"stat-figure text-primary\"><span class=\"iconf text-3xl\">\U000f0868</span></div><div class=\"stat-title\">Containers</div><div class=\"stat-value\" id=\"container-count\">") | 59 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">Gordon</a></li><li>Manager</li></ul></div><!-- Stats overview --> <div class=\"stats shadow mb-6 w-full bg-base-300\"><div class=\"stat\"><div class=\"stat-figure text-primary\"><span class=\"iconf text-3xl\">\U000f0868</span></div><div class=\"stat-title\">Containers</div><div class=\"stat-value\" id=\"container-count\">") |
| 60 | if templ_7745c5c3_Err != nil { | 60 | if templ_7745c5c3_Err != nil { | ||
| 61 | return templ_7745c5c3_Err | 61 | return templ_7745c5c3_Err | ||
| 62 | } | 62 | } | ||
| @@ -82,7 +82,7 @@ func ManagerPage(title string, buildVersion string, adminPath string, userSettin | |||||
| 82 | if templ_7745c5c3_Err != nil { | 82 | if templ_7745c5c3_Err != nil { | ||
| 83 | return templ_7745c5c3_Err | 83 | return templ_7745c5c3_Err | ||
| 84 | } | 84 | } | ||
| 85 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"stat-desc\">Docker images available</div></div></div><!-- Tabs for containers and images --> <div role=\"tablist\" class=\"tabs tabs-boxed mb-4\"><input type=\"radio\" name=\"my_tabs\" role=\"tab\" class=\"tab\" aria-label=\"Containers\" id=\"containers-tab\" checked><div role=\"tabpanel\" class=\"tab-content p-0\"><!-- Container manager section --><div id=\"container-manager\" hx-get=\"/htmx/container-manager\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><!-- Container content loaded via HTMX --><div class=\"text-center p-4\"><span class=\"iconf text-3xl animate-spin inline-block\">\U000f0450</span> Loading containers...</div></div></div><input type=\"radio\" name=\"my_tabs\" role=\"tab\" class=\"tab\" aria-label=\"Images\" id=\"images-tab\"><div role=\"tabpanel\" class=\"tab-content p-0\"><!-- Image manager section --><div id=\"image-manager\" hx-get=\"/htmx/image-manager\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><!-- Image content loaded via HTMX --><div class=\"text-center p-4\"><span class=\"iconf text-3xl animate-spin inline-block\">\U000f0450</span> Loading images...</div></div></div></div>") | 85 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"stat-desc\">Docker images available</div></div></div><!-- Tabs for containers and images --> <div role=\"tablist\" class=\"tabs tabs-boxed mb-4\"><input type=\"radio\" name=\"my_tabs\" role=\"tab\" class=\"tab\" aria-label=\"Containers\" id=\"containers-tab\" checked><div role=\"tabpanel\" class=\"tab-content p-0 bg-base-300\"><!-- Container manager section --><div id=\"container-manager\" hx-get=\"/htmx/container-manager\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><!-- Container content loaded via HTMX --><div class=\"text-center p-4\"><span class=\"iconf text-3xl animate-spin inline-block\">\U000f0450</span> Loading containers...</div></div></div><input type=\"radio\" name=\"my_tabs\" role=\"tab\" class=\"tab\" aria-label=\"Images\" id=\"images-tab\"><div role=\"tabpanel\" class=\"tab-content p-0 bg-base-300\"><!-- Image manager section --><div id=\"image-manager\" hx-get=\"/htmx/image-manager\" hx-trigger=\"load\" hx-swap=\"innerHTML\"><!-- Image content loaded via HTMX --><div class=\"text-center p-4\"><span class=\"iconf text-3xl animate-spin inline-block\">\U000f0450</span> Loading images...</div></div></div></div>") |
| 86 | if templ_7745c5c3_Err != nil { | 86 | if templ_7745c5c3_Err != nil { | ||
| 87 | return templ_7745c5c3_Err | 87 | return templ_7745c5c3_Err | ||
| 88 | } | 88 | } | ||
M internal/webui/public/assets/css/tailwind.css +9 -13
| @@ -5886,10 +5886,6 @@ | |||
| 5886 | 5886 | border-style: var(--tw-border-style); | |
| 5887 | 5887 | border-width: 1px; | |
| 5888 | 5888 | } | |
| 5889 | - | .border-t { | |
| 5890 | - | border-top-style: var(--tw-border-style); | |
| 5891 | - | border-top-width: 1px; | |
| 5892 | - | } | |
| 5893 | 5889 | .btn-dash { | |
| 5894 | 5890 | &:not( .btn-active, :hover, :active:focus, :focus-visible, :disabled, [disabled], .btn-disabled, :checked ) { | |
| 5895 | 5891 | --btn-shadow: ""; | |
| @@ -5960,9 +5956,6 @@ | |||
| 5960 | 5956 | color: var(--color-warning-content); | |
| 5961 | 5957 | --alert-color: var(--color-warning); | |
| 5962 | 5958 | } | |
| 5963 | - | .border-base-300 { | |
| 5964 | - | border-color: var(--color-base-300); | |
| 5965 | - | } | |
| 5966 | 5959 | .glass { | |
| 5967 | 5960 | border: none; | |
| 5968 | 5961 | backdrop-filter: blur(var(--glass-blur, 40px)); | |
| @@ -6201,9 +6194,6 @@ | |||
| 6201 | 6194 | .p-6 { | |
| 6202 | 6195 | padding: calc(var(--spacing) * 6); | |
| 6203 | 6196 | } | |
| 6204 | - | .p-10 { | |
| 6205 | - | padding: calc(var(--spacing) * 10); | |
| 6206 | - | } | |
| 6207 | 6197 | .menu-title { | |
| 6208 | 6198 | padding-inline: calc(0.25rem * 3); | |
| 6209 | 6199 | padding-block: calc(0.25rem * 2); | |
| @@ -6287,9 +6277,6 @@ | |||
| 6287 | 6277 | .px-4 { | |
| 6288 | 6278 | padding-inline: calc(var(--spacing) * 4); | |
| 6289 | 6279 | } | |
| 6290 | - | .px-10 { | |
| 6291 | - | padding-inline: calc(var(--spacing) * 10); | |
| 6292 | - | } | |
| 6293 | 6280 | .py-4 { | |
| 6294 | 6281 | padding-block: calc(var(--spacing) * 4); | |
| 6295 | 6282 | } | |
| @@ -6376,6 +6363,10 @@ | |||
| 6376 | 6363 | font-size: var(--text-sm); | |
| 6377 | 6364 | line-height: var(--tw-leading, var(--text-sm--line-height)); | |
| 6378 | 6365 | } | |
| 6366 | + | .text-xl { | |
| 6367 | + | font-size: var(--text-xl); | |
| 6368 | + | line-height: var(--tw-leading, var(--text-xl--line-height)); | |
| 6369 | + | } | |
| 6379 | 6370 | .text-xs { | |
| 6380 | 6371 | font-size: var(--text-xs); | |
| 6381 | 6372 | line-height: var(--tw-leading, var(--text-xs--line-height)); | |
| @@ -7511,6 +7502,11 @@ | |||
| 7511 | 7502 | align-items: center; | |
| 7512 | 7503 | } | |
| 7513 | 7504 | } | |
| 7505 | + | .sm\:justify-start { | |
| 7506 | + | @media (width >= 40rem) { | |
| 7507 | + | justify-content: flex-start; | |
| 7508 | + | } | |
| 7509 | + | } | |
| 7514 | 7510 | .md\:col-span-1 { | |
| 7515 | 7511 | @media (width >= 48rem) { | |
| 7516 | 7512 | grid-column: span 1 / span 1; | |
| @@ -5886,10 +5886,6 @@ | |||||
| 5886 | border-style: var(--tw-border-style); | 5886 | border-style: var(--tw-border-style); | ||
| 5887 | border-width: 1px; | 5887 | border-width: 1px; | ||
| 5888 | } | 5888 | } | ||
| 5889 | - | .border-t { | |||
| 5890 | - | border-top-style: var(--tw-border-style); | |||
| 5891 | - | border-top-width: 1px; | |||
| 5892 | - | } | |||
| 5893 | .btn-dash { | 5889 | .btn-dash { | ||
| 5894 | &:not( .btn-active, :hover, :active:focus, :focus-visible, :disabled, [disabled], .btn-disabled, :checked ) { | 5890 | &:not( .btn-active, :hover, :active:focus, :focus-visible, :disabled, [disabled], .btn-disabled, :checked ) { | ||
| 5895 | --btn-shadow: ""; | 5891 | --btn-shadow: ""; | ||
| @@ -5960,9 +5956,6 @@ | |||||
| 5960 | color: var(--color-warning-content); | 5956 | color: var(--color-warning-content); | ||
| 5961 | --alert-color: var(--color-warning); | 5957 | --alert-color: var(--color-warning); | ||
| 5962 | } | 5958 | } | ||
| 5963 | - | .border-base-300 { | |||
| 5964 | - | border-color: var(--color-base-300); | |||
| 5965 | - | } | |||
| 5966 | .glass { | 5959 | .glass { | ||
| 5967 | border: none; | 5960 | border: none; | ||
| 5968 | backdrop-filter: blur(var(--glass-blur, 40px)); | 5961 | backdrop-filter: blur(var(--glass-blur, 40px)); | ||
| @@ -6201,9 +6194,6 @@ | |||||
| 6201 | .p-6 { | 6194 | .p-6 { | ||
| 6202 | padding: calc(var(--spacing) * 6); | 6195 | padding: calc(var(--spacing) * 6); | ||
| 6203 | } | 6196 | } | ||
| 6204 | - | .p-10 { | |||
| 6205 | - | padding: calc(var(--spacing) * 10); | |||
| 6206 | - | } | |||
| 6207 | .menu-title { | 6197 | .menu-title { | ||
| 6208 | padding-inline: calc(0.25rem * 3); | 6198 | padding-inline: calc(0.25rem * 3); | ||
| 6209 | padding-block: calc(0.25rem * 2); | 6199 | padding-block: calc(0.25rem * 2); | ||
| @@ -6287,9 +6277,6 @@ | |||||
| 6287 | .px-4 { | 6277 | .px-4 { | ||
| 6288 | padding-inline: calc(var(--spacing) * 4); | 6278 | padding-inline: calc(var(--spacing) * 4); | ||
| 6289 | } | 6279 | } | ||
| 6290 | - | .px-10 { | |||
| 6291 | - | padding-inline: calc(var(--spacing) * 10); | |||
| 6292 | - | } | |||
| 6293 | .py-4 { | 6280 | .py-4 { | ||
| 6294 | padding-block: calc(var(--spacing) * 4); | 6281 | padding-block: calc(var(--spacing) * 4); | ||
| 6295 | } | 6282 | } | ||
| @@ -6376,6 +6363,10 @@ | |||||
| 6376 | font-size: var(--text-sm); | 6363 | font-size: var(--text-sm); | ||
| 6377 | line-height: var(--tw-leading, var(--text-sm--line-height)); | 6364 | line-height: var(--tw-leading, var(--text-sm--line-height)); | ||
| 6378 | } | 6365 | } | ||
| 6366 | + | .text-xl { | |||
| 6367 | + | font-size: var(--text-xl); | |||
| 6368 | + | line-height: var(--tw-leading, var(--text-xl--line-height)); | |||
| 6369 | + | } | |||
| 6379 | .text-xs { | 6370 | .text-xs { | ||
| 6380 | font-size: var(--text-xs); | 6371 | font-size: var(--text-xs); | ||
| 6381 | line-height: var(--tw-leading, var(--text-xs--line-height)); | 6372 | line-height: var(--tw-leading, var(--text-xs--line-height)); | ||
| @@ -7511,6 +7502,11 @@ | |||||
| 7511 | align-items: center; | 7502 | align-items: center; | ||
| 7512 | } | 7503 | } | ||
| 7513 | } | 7504 | } | ||
| 7505 | + | .sm\:justify-start { | |||
| 7506 | + | @media (width >= 40rem) { | |||
| 7507 | + | justify-content: flex-start; | |||
| 7508 | + | } | |||
| 7509 | + | } | |||
| 7514 | .md\:col-span-1 { | 7510 | .md\:col-span-1 { | ||
| 7515 | @media (width >= 48rem) { | 7511 | @media (width >= 48rem) { | ||
| 7516 | grid-column: span 1 / span 1; | 7512 | grid-column: span 1 / span 1; | ||
M pkg/docker/images.go +34 -1
| @@ -13,8 +13,10 @@ import ( | |||
| 13 | 13 | "github.com/bnema/gordon/pkg/verify" | |
| 14 | 14 | "github.com/charmbracelet/log" | |
| 15 | 15 | "github.com/docker/docker/api/types" | |
| 16 | - | "github.com/docker/docker/api/types/image" | |
| 16 | + | "github.com/docker/docker/api/types/filters" | |
| 17 | + | image "github.com/docker/docker/api/types/image" | |
| 17 | 18 | "github.com/docker/docker/client" | |
| 19 | + | "github.com/docker/go-units" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 20 | 22 | func ListContainerImages() ([]image.Summary, error) { | |
| @@ -325,3 +327,34 @@ func GetAllImages() ([]image.Summary, error) { | |||
| 325 | 327 | ||
| 326 | 328 | return images, nil | |
| 327 | 329 | } | |
| 330 | + | ||
| 331 | + | // PruneImages purges all unused (dangling) images from the Docker engine | |
| 332 | + | func PruneImages() (uint64, error) { | |
| 333 | + | // Ensure the Docker client is initialized | |
| 334 | + | if err := CheckIfInitialized(); err != nil { | |
| 335 | + | return 0, fmt.Errorf("docker client not initialized: %w", err) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | ctx := context.Background() | |
| 339 | + | cli := dockerCli // Use the global client instance | |
| 340 | + | ||
| 341 | + | // Define the filter to prune only dangling images (those without tags) | |
| 342 | + | pruneFilters := filters.NewArgs() | |
| 343 | + | pruneFilters.Add("dangling", "true") | |
| 344 | + | ||
| 345 | + | // Prune the images | |
| 346 | + | report, err := cli.ImagesPrune(ctx, pruneFilters) | |
| 347 | + | if err != nil { | |
| 348 | + | log.Error("Failed to prune images", "error", err) | |
| 349 | + | return 0, fmt.Errorf("failed to prune images: %w", err) | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // Log the results | |
| 353 | + | log.Infof("Purged %d images, reclaimed %s space", len(report.ImagesDeleted), units.HumanSize(float64(report.SpaceReclaimed))) | |
| 354 | + | for _, img := range report.ImagesDeleted { | |
| 355 | + | log.Debugf("- Deleted: %s", img.Deleted) | |
| 356 | + | log.Debugf("- Untagged: %s", img.Untagged) | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | return report.SpaceReclaimed, nil | |
| 360 | + | } | |
| @@ -13,8 +13,10 @@ import ( | |||||
| 13 | "github.com/bnema/gordon/pkg/verify" | 13 | "github.com/bnema/gordon/pkg/verify" | ||
| 14 | "github.com/charmbracelet/log" | 14 | "github.com/charmbracelet/log" | ||
| 15 | "github.com/docker/docker/api/types" | 15 | "github.com/docker/docker/api/types" | ||
| 16 | - | "github.com/docker/docker/api/types/image" | 16 | + | "github.com/docker/docker/api/types/filters" |
| 17 | + | image "github.com/docker/docker/api/types/image" | |||
| 17 | "github.com/docker/docker/client" | 18 | "github.com/docker/docker/client" | ||
| 19 | + | "github.com/docker/go-units" | |||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| 20 | func ListContainerImages() ([]image.Summary, error) { | 22 | func ListContainerImages() ([]image.Summary, error) { | ||
| @@ -325,3 +327,34 @@ func GetAllImages() ([]image.Summary, error) { | |||||
| 325 | 327 | ||||
| 326 | return images, nil | 328 | return images, nil | ||
| 327 | } | 329 | } | ||
| 330 | + | ||||
| 331 | + | // PruneImages purges all unused (dangling) images from the Docker engine | |||
| 332 | + | func PruneImages() (uint64, error) { | |||
| 333 | + | // Ensure the Docker client is initialized | |||
| 334 | + | if err := CheckIfInitialized(); err != nil { | |||
| 335 | + | return 0, fmt.Errorf("docker client not initialized: %w", err) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | ctx := context.Background() | |||
| 339 | + | cli := dockerCli // Use the global client instance | |||
| 340 | + | ||||
| 341 | + | // Define the filter to prune only dangling images (those without tags) | |||
| 342 | + | pruneFilters := filters.NewArgs() | |||
| 343 | + | pruneFilters.Add("dangling", "true") | |||
| 344 | + | ||||
| 345 | + | // Prune the images | |||
| 346 | + | report, err := cli.ImagesPrune(ctx, pruneFilters) | |||
| 347 | + | if err != nil { | |||
| 348 | + | log.Error("Failed to prune images", "error", err) | |||
| 349 | + | return 0, fmt.Errorf("failed to prune images: %w", err) | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | // Log the results | |||
| 353 | + | log.Infof("Purged %d images, reclaimed %s space", len(report.ImagesDeleted), units.HumanSize(float64(report.SpaceReclaimed))) | |||
| 354 | + | for _, img := range report.ImagesDeleted { | |||
| 355 | + | log.Debugf("- Deleted: %s", img.Deleted) | |||
| 356 | + | log.Debugf("- Untagged: %s", img.Untagged) | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | return report.SpaceReclaimed, nil | |||
| 360 | + | } | |||