detached a1d9a53

wip

commit
a1d9a53ec2dc701265d5d76b2d637d9bcc27f0c4
author
bamen · · 2025-03-23 18:00 UTC
parent
dbdbc7c
signature
unsigned
101 files · +15924 -2963

this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.

M .goreleaser.yaml +2 -0
@@ -3,6 +3,8 @@ version: 2
3 before:3 before:
4 hooks:4 hooks:
5 - go mod tidy5 - go mod tidy
6+ - templ generate
7+ - bun run build:css
6 8
7 release:9 release:
8 draft: true10 draft: true
A .templ.config.json +4 -0
@@ -0,0 +1,4 @@
1+{
2+ "root": "internal/templating/models/templ",
3+ "paths": ["internal/templating/models/templ"]
4+}⏎̸
A CLAUDE.md +28 -0
@@ -0,0 +1,28 @@
1+# Gordon Project Guidelines
2+
3+## Build Commands
4+- Build project: `make build`
5+- Build CSS: `bun run build:css` or `make build-css`
6+- Watch CSS for changes: `bun run dev:css`
7+- Clean build artifacts: `make clean`
8+- Run rate limit test: `go run pkg/scripts/rate_limit_test/main.go --url URL`
9+
10+## Code Style
11+
12+### Go Guidelines
13+- Use tabs for indentation (4-space equivalent)
14+- Organize imports: standard lib first, then third-party packages
15+- Error handling: `if err != nil { return nil, fmt.Errorf("context: %w", err) }`
16+- Use structured logging with levels: `logger.Debug/Info/Warn/Error`
17+- Variable naming: camelCase for variables, PascalCase for exported items
18+- Functions should be focused and documented with comments
19+
20+### JavaScript/CSS
21+- Use Prettier for formatting
22+- CSS is built with Tailwind
23+- Run Tailwind build with `bun run build:css`
24+
25+## File Structure
26+- Package declaration first, followed by imports
27+- Type definitions before function implementations
28+- Methods for types should be grouped together⏎̸
M Dockerfile +17 -1
@@ -1,8 +1,24 @@
1 FROM --platform=$TARGETPLATFORM alpine:latest1 FROM --platform=$TARGETPLATFORM alpine:latest
2 2
3+# Install diagnostic utilities
4+RUN apk add --no-cache \
5+ curl \
6+ wget \
7+ net-tools \
8+ bind-tools \
9+ busybox-extras
10+
3 COPY gordon /gordon11 COPY gordon /gordon
12+COPY pkg/scripts/container_diagnostic/diagnostic.sh /diagnostic.sh
13+RUN chmod +x /diagnostic.sh
4 14
5 RUN touch /.iscontainer15 RUN touch /.iscontainer
6 16
17+# Ensure we're running as root to bind to privileged ports
18+USER root
19+
20+# Expose the ports the server needs to listen on
21+EXPOSE 80 443 8080
22+
7 ENTRYPOINT ["/gordon"]23 ENTRYPOINT ["/gordon"]
8-CMD ["serve"]24+CMD ["serve", "--use-fallback-binding", "--proxy-debug"]
M Makefile +8 -2
@@ -20,13 +20,13 @@ LDFLAGS := -s -w \
20 ARCHS := amd64 arm6420 ARCHS := amd64 arm64
21 21
22 # Phony targets22 # Phony targets
23-.PHONY: all build build-push clean build-css23+.PHONY: all build build-push clean build-css build-templ
24 24
25 # Default target25 # Default target
26 all: build26 all: build
27 27
28 # Build binaries28 # Build binaries
29-build: build-css29+build: build-css build-templ
30 @echo "Building Go binaries..."30 @echo "Building Go binaries..."
31 @mkdir -p $(DIST_DIR)31 @mkdir -p $(DIST_DIR)
32 @rm -f $(DIST_DIR)/*32 @rm -f $(DIST_DIR)/*
@@ -41,6 +41,12 @@ build-css:
41 @bun run build:css41 @bun run build:css
42 @echo "CSS built successfully"42 @echo "CSS built successfully"
43 43
44+# Generate Go code from templ templates
45+build-templ:
46+ @echo "Generating Go code from templ templates..."
47+ @templ generate
48+ @echo "templ generation completed successfully"
49+
44 # Build and push Docker images50 # Build and push Docker images
45 build-push: build-css build51 build-push: build-css build
46 @echo "Cleaning up dangling images..."52 @echo "Cleaning up dangling images..."
A TEMPL_MIGRATION.md +58 -0
@@ -0,0 +1,58 @@
1+# Gordon Templ Migration
2+
3+This project is being migrated from using Go's html/template to [templ](https://github.com/a-h/templ) for improved type safety, maintainability, and IDE support.
4+
5+## Migration Status
6+
7+The following templates have been migrated:
8+
9+- [x] Login page (index.gohtml → login/index.templ)
10+- [x] Header component (header.gohtml → components/header.templ)
11+- [x] Footer component (footer.gohtml → components/footer.templ)
12+- [x] Menu component (menu.gohtml → components/menu.templ)
13+- [x] Container list component (containerlist.gohtml → components/containerlist.templ)
14+- [x] Success component (success.gohtml → components/success.templ)
15+- [x] Admin dashboard (dashboard.gohtml → pages/admin/dashboard.templ)
16+- [x] Admin manager (manager.gohtml → pages/admin/manager.templ)
17+- [x] Image list component (imagelist.gohtml → components/imagelist.templ)
18+- [x] Upload image form (uploadimage.gohtml → components/uploadimage.templ)
19+- [x] Create container form (createcontainer.gohtml → components/createcontainer.templ)
20+- [x] Install page (install.gohtml → pages/admin/install.templ)
21+- [x] Edit container form (editcontainer.gohtml → components/editcontainer.templ)
22+- [x] Full container creation (createcontainerfull.gohtml → pages/admin/createcontainerfull.templ)
23+- [x] Index page (index.gohtml → pages/admin/index.templ)
24+- [x] User page (user.gohtml → pages/admin/user.templ)
25+
26+All templates have been successfully migrated! 🎉
27+
28+## Build Process
29+
30+The build process has been updated to include templ generation:
31+
32+1. Makefile includes a `build-templ` target that runs `templ generate`
33+2. GoReleaser configuration includes `templ generate` in the before hooks
34+
35+## Directory Structure
36+
37+- `internal/templating/models/templ/` - Root directory for templ templates
38+ - `components/` - Reusable UI components
39+ - `layouts/` - Page layouts like AdminLayout
40+ - `pages/` - Full page templates
41+ - `admin/` - Admin pages
42+ - `login/` - Login pages
43+
44+## Theme Configuration
45+
46+The default theme has been set to `gordon-dark`, which is defined in `internal/webui/public/assets/css/custom.css`.
47+
48+## Handlers
49+
50+New handlers have been created in `internal/httpserve/handlers/templ_handlers.go` that use the templ renderer to render the migrated templates.
51+
52+## Further Work
53+
54+1. ✅ Complete the migration of all templates
55+2. Update all handlers to use the new templ templates
56+3. Add error handling and loading states to components
57+4. Create more reusable components for forms, buttons, cards, etc.
58+5. Consider integrating with Go's generics for more type safety⏎̸
M docker-compose.yml.example +6 -1
@@ -44,9 +44,11 @@ services:
44 # Reverse Proxy Configuration44 # Reverse Proxy Configuration
45 - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy45 - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
46 - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy46 - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy
47+ - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container)
47 - GORDON_PROXY_CERT_DIR=/certs48 - GORDON_PROXY_CERT_DIR=/certs
48 - GORDON_PROXY_EMAIL=admin@example.com49 - GORDON_PROXY_EMAIL=admin@example.com
49- - GORDON_PROXY_ENABLE_LOGS=true # Enable HTTP request logging (set to false to disable)50+ - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable)
51+ - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable)
50 52
51 # Session Secret for security53 # Session Secret for security
52 - SESSION_SECRET=replace_with_secure_random_string54 - SESSION_SECRET=replace_with_secure_random_string
@@ -97,8 +99,11 @@ networks:
97 # # Reverse Proxy Configuration99 # # Reverse Proxy Configuration
98 # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy100 # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
99 # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy101 # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy
102+# - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container)
100 # - GORDON_PROXY_CERT_DIR=/certs103 # - GORDON_PROXY_CERT_DIR=/certs
101 # - GORDON_PROXY_EMAIL=admin@example.com104 # - GORDON_PROXY_EMAIL=admin@example.com
105+# - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable)
106+# - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable)
102 # 107 #
103 # # Session Secret for security108 # # Session Secret for security
104 # - SESSION_SECRET=replace_with_secure_random_string109 # - SESSION_SECRET=replace_with_secure_random_string
M go.mod +9 -0
@@ -6,6 +6,7 @@ require (
6 github.com/AlecAivazis/survey/v2 v2.3.76 github.com/AlecAivazis/survey/v2 v2.3.7
7 github.com/Masterminds/semver/v3 v3.3.17 github.com/Masterminds/semver/v3 v3.3.1
8 github.com/PuerkitoBio/goquery v1.10.28 github.com/PuerkitoBio/goquery v1.10.2
9+ github.com/a-h/templ v0.3.833
9 github.com/charmbracelet/bubbles v0.20.010 github.com/charmbracelet/bubbles v0.20.0
10 github.com/charmbracelet/lipgloss v1.0.011 github.com/charmbracelet/lipgloss v1.0.0
11 github.com/charmbracelet/log v0.4.012 github.com/charmbracelet/log v0.4.0
@@ -24,12 +25,20 @@ require (
24 )25 )
25 26
26 require (27 require (
28+ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
29+ github.com/andybalholm/brotli v1.1.0 // indirect
30+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
27 github.com/cespare/xxhash/v2 v2.3.0 // indirect31 github.com/cespare/xxhash/v2 v2.3.0 // indirect
32+ github.com/cli/browser v1.3.0 // indirect
33+ github.com/fsnotify/fsnotify v1.7.0 // indirect
28 github.com/klauspost/compress v1.18.0 // indirect34 github.com/klauspost/compress v1.18.0 // indirect
29 github.com/klauspost/cpuid/v2 v2.2.10 // indirect35 github.com/klauspost/cpuid/v2 v2.2.10 // indirect
36+ github.com/natefinch/atomic v1.0.1 // indirect
30 github.com/zeebo/xxh3 v1.0.2 // indirect37 github.com/zeebo/xxh3 v1.0.2 // indirect
31 go.mongodb.org/mongo-driver v1.17.3 // indirect38 go.mongodb.org/mongo-driver v1.17.3 // indirect
32 go.opentelemetry.io/auto/sdk v1.1.0 // indirect39 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
40+ golang.org/x/mod v0.24.0 // indirect
41+ golang.org/x/tools v0.31.0 // indirect
33 google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect42 google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
34 )43 )
35 44
M go.sum +14 -2 generated file: collapsed
M internal/cli/cmd/serve.go +68 -3
@@ -4,6 +4,7 @@ package cmd
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7+ "os"
7 8
8 "github.com/bnema/gordon/internal/cli/handler"9 "github.com/bnema/gordon/internal/cli/handler"
9 "github.com/bnema/gordon/internal/common"10 "github.com/bnema/gordon/internal/common"
@@ -17,6 +18,13 @@ import (
17 func NewServeCommand(a *server.App) *cobra.Command {18 func NewServeCommand(a *server.App) *cobra.Command {
18 defaultport := "1323"19 defaultport := "1323"
19 var port string20 var port string
21+ var proxyEnabled bool
22+ var proxyPort string
23+ var proxyHttpPort string
24+ var skipCertificates bool
25+ var proxyDebug bool
26+ var useFallbackBinding bool
27+ var detectUpstreamProxy bool
20 28
21 // if the server is running in a docker container, the default port is 808029 // if the server is running in a docker container, the default port is 8080
22 // (changed from 80 to avoid conflict with the reverse proxy)30 // (changed from 80 to avoid conflict with the reverse proxy)
@@ -36,9 +44,56 @@ func NewServeCommand(a *server.App) *cobra.Command {
36 fmt.Println("Continuing with Docker/Podman functionality disabled")44 fmt.Println("Continuing with Docker/Podman functionality disabled")
37 }45 }
38 46
39- // handler.StartServer will use the value of port, which will be the default47+ // Only use the port from flag if it was explicitly set by the user
40- // value if no flag is specified48+ // otherwise keep the one from configuration (which has env vars applied)
41- a.Config.Http.Port = port49+ if cmd.Flags().Changed("port") {
50+ logger.Info("Using port from command line flag", "port", port)
51+ a.Config.Http.Port = port
52+ } else {
53+ // Use the port from the config (which would have env vars applied)
54+ port = a.Config.Http.Port
55+ logger.Info("Using port from configuration", "port", port)
56+ }
57+
58+ // Apply proxy flags if set
59+ if cmd.Flags().Changed("proxy-enabled") {
60+ logger.Info("Setting proxy enabled flag from command line", "enabled", proxyEnabled)
61+ a.Config.ReverseProxy.Enabled = proxyEnabled
62+ }
63+
64+ if cmd.Flags().Changed("proxy-port") {
65+ logger.Info("Setting proxy port from command line", "port", proxyPort)
66+ a.Config.ReverseProxy.Port = proxyPort
67+ }
68+
69+ if cmd.Flags().Changed("proxy-http-port") {
70+ logger.Info("Setting proxy HTTP port from command line", "port", proxyHttpPort)
71+ a.Config.ReverseProxy.HttpPort = proxyHttpPort
72+ }
73+
74+ if cmd.Flags().Changed("skip-certificates") {
75+ logger.Info("Setting skip certificates flag from command line", "skip", skipCertificates)
76+ a.Config.ReverseProxy.SkipCertificates = skipCertificates
77+ }
78+
79+ if cmd.Flags().Changed("proxy-debug") && proxyDebug {
80+ logger.Info("Enabling proxy debug mode")
81+ // Set log level to debug for proxy-related components
82+ logger.GetLogger().SetLogLevel("debug")
83+ }
84+
85+ if cmd.Flags().Changed("use-fallback-binding") {
86+ logger.Info("Setting use fallback binding flag", "enabled", useFallbackBinding)
87+ if useFallbackBinding {
88+ os.Setenv("GORDON_USE_FALLBACK_BINDING", "true")
89+ }
90+ }
91+
92+ if cmd.Flags().Changed("detect-upstream-proxy") {
93+ logger.Info("Setting detect upstream proxy flag", "enabled", detectUpstreamProxy)
94+ a.Config.ReverseProxy.DetectUpstreamProxy = detectUpstreamProxy
95+ }
96+
42 err := handler.StartServer(a, port)97 err := handler.StartServer(a, port)
43 if err != nil {98 if err != nil {
44 logger.Error("Server error", "error", err)99 logger.Error("Server error", "error", err)
@@ -53,5 +108,15 @@ func NewServeCommand(a *server.App) *cobra.Command {
53 108
54 // Attach the flag to serveCmd and store its value in the variable port109 // Attach the flag to serveCmd and store its value in the variable port
55 serveCmd.Flags().StringVarP(&port, "port", "p", defaultport, "port to listen on")110 serveCmd.Flags().StringVarP(&port, "port", "p", defaultport, "port to listen on")
111+
112+ // Add proxy-related flags
113+ serveCmd.Flags().BoolVar(&proxyEnabled, "proxy-enabled", true, "enable or disable the reverse proxy")
114+ serveCmd.Flags().StringVar(&proxyPort, "proxy-port", "443", "port for the reverse proxy HTTPS server")
115+ serveCmd.Flags().StringVar(&proxyHttpPort, "proxy-http-port", "80", "port for the reverse proxy HTTP server")
116+ serveCmd.Flags().BoolVar(&skipCertificates, "skip-certificates", false, "skip Let's Encrypt certificate acquisition")
117+ serveCmd.Flags().BoolVar(&proxyDebug, "proxy-debug", false, "enable detailed debug logging for the proxy")
118+ serveCmd.Flags().BoolVar(&useFallbackBinding, "use-fallback-binding", false, "use fallback binding method for HTTPS ports")
119+ serveCmd.Flags().BoolVar(&detectUpstreamProxy, "detect-upstream-proxy", false, "detect and handle upstream TLS termination proxies")
120+
56 return serveCmd121 return serveCmd
57 }122 }
M internal/cli/handler/start_server.go +55 -15
@@ -10,6 +10,7 @@ import (
10 "time"10 "time"
11 11
12 "github.com/bnema/gordon/internal/httpserve"12 "github.com/bnema/gordon/internal/httpserve"
13+ "github.com/bnema/gordon/internal/proxy"
13 "github.com/bnema/gordon/internal/server"14 "github.com/bnema/gordon/internal/server"
14 "github.com/bnema/gordon/pkg/logger"15 "github.com/bnema/gordon/pkg/logger"
15 "github.com/labstack/echo/v4"16 "github.com/labstack/echo/v4"
@@ -19,6 +20,8 @@ import (
19 var (20 var (
20 globalServer *server.App21 globalServer *server.App
21 globalServerLock sync.Mutex22 globalServerLock sync.Mutex
23+ // Add a lock for server initialization
24+ serverInitLock sync.Mutex
22 )25 )
23 26
24 // SetGlobalServer sets the global server instance for shutdown handling27 // SetGlobalServer sets the global server instance for shutdown handling
@@ -38,6 +41,10 @@ func GetGlobalServer() *server.App {
38 // execute cmd/srv/main.go main function41 // execute cmd/srv/main.go main function
39 42
40 func StartServer(a *server.App, port string) error {43 func StartServer(a *server.App, port string) error {
44+ // Use lock to ensure only one server initialization happens at a time
45+ serverInitLock.Lock()
46+ defer serverInitLock.Unlock()
47+
41 // Set the global server instance for shutdown handling48 // Set the global server instance for shutdown handling
42 SetGlobalServer(a)49 SetGlobalServer(a)
43 50
@@ -54,27 +61,38 @@ func StartServer(a *server.App, port string) error {
54 logger.Error("Token initialization error", "error", err)61 logger.Error("Token initialization error", "error", err)
55 }62 }
56 63
57- // Initialize and start the reverse proxy using the already loaded configuration64+ // Create a channel to signal when the main server is ready
58- p, err := httpserve.InitializeProxy(a)65+ serverReady := make(chan struct{})
59- if err != nil {
60- logger.Error("Failed to initialize reverse proxy", "error", err)
61- // Continue even if proxy fails, as the main server should still work
62- }
63 66
67+ logger.Info("Initializing main HTTP server...")
64 e := echo.New()68 e := echo.New()
65 e.Server.ReadTimeout = 10 * time.Minute69 e.Server.ReadTimeout = 10 * time.Minute
66 e.Server.WriteTimeout = 10 * time.Minute70 e.Server.WriteTimeout = 10 * time.Minute
67 e.HideBanner = true71 e.HideBanner = true
68 e.HidePort = true72 e.HidePort = true
73+
74+ logger.Debug("About to register routes to main HTTP server")
69 e = httpserve.RegisterRoutes(e, a)75 e = httpserve.RegisterRoutes(e, a)
76+ logger.Info("Routes registered to main HTTP server")
70 77
71 // Setup a channel to capture termination signals78 // Setup a channel to capture termination signals
72 sigs := make(chan os.Signal, 1)79 sigs := make(chan os.Signal, 1)
73 signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)80 signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
74 81
75- // Start server in a goroutine82+ // Start the server in a goroutine
76 go func() {83 go func() {
77- logger.Info("Starting server", "port", port)84+ logger.Info("Starting main server on port", "port", a.Config.Http.Port)
85+
86+ // Create a separate goroutine that will signal after a short delay
87+ // This ensures the server has time to initialize and bind to the port
88+ go func() {
89+ // Wait for server to start binding
90+ time.Sleep(1 * time.Second)
91+ logger.Debug("Signaling that main server is ready")
92+ close(serverReady)
93+ }()
94+
95+ // Attempt to start the server
78 if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil {96 if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil {
79 if err.Error() != "http: Server closed" {97 if err.Error() != "http: Server closed" {
80 logger.Error("Server error", "error", err)98 logger.Error("Server error", "error", err)
@@ -82,13 +100,35 @@ func StartServer(a *server.App, port string) error {
82 }100 }
83 }()101 }()
84 102
85- // Test admin connections after the server has started (in a separate goroutine)103+ // Log information about accessing the admin WebUI
86- if p != nil {104+ adminPath := a.Config.Admin.Path
87- go func() {105+ if adminPath == "" {
88- // Add a delay to ensure the server has fully started106+ adminPath = "/admin" // Default if not set
89- time.Sleep(2 * time.Second)107+ }
90- p.TestAdminConnectionLater()108+
91- }()109+ // Log access information
110+ protocol := "https"
111+ if !a.Config.Http.Https {
112+ protocol = "http"
113+ }
114+
115+ fullDomain := a.Config.Http.FullDomain()
116+ logger.Info("Access the Admin WebUI at", "url", fmt.Sprintf("%s://%s%s", protocol, fullDomain, adminPath))
117+
118+ // Wait for the main server to be ready before initializing the proxy
119+ <-serverReady
120+
121+ // Initialize and start the reverse proxy after the main server is ready
122+ var p *proxy.Proxy
123+ logger.Info("Starting reverse proxy initialization...")
124+ p, err = httpserve.InitializeProxy(a)
125+ if err != nil {
126+ logger.Error("Failed to initialize reverse proxy", "error", err, "solution", "Check logs for specific HTTPS binding errors and container capabilities")
127+ // Alert the user that this is a critical issue
128+ fmt.Println("[gordon] | ⚠️ WARNING: Reverse proxy failed to initialize. HTTPS server will not be available!")
129+ fmt.Println("[gordon] | ⚠️ Check logs for details and verify container has correct permissions.")
130+ } else {
131+ logger.Info("Reverse proxy initialization complete")
92 }132 }
93 133
94 // Wait for interrupt signal134 // Wait for interrupt signal
M internal/common/init_config.go +170 -61
@@ -47,14 +47,15 @@ type BuildConfig struct {
47 }47 }
48 48
49 type AdminConfig struct {49 type AdminConfig struct {
50- Path string `yaml:"path"`50+ Path string `yaml:"path"`
51+ AdminWebUI bool `yaml:"-"`
51 }52 }
52 53
53 type GeneralConfig struct {54 type GeneralConfig struct {
54 StorageDir string `yaml:"storageDir"`55 StorageDir string `yaml:"storageDir"`
55 Token string `yaml:"token"`56 Token string `yaml:"token"`
56 LogLevel string `yaml:"logLevel"`57 LogLevel string `yaml:"logLevel"`
57- GordonContainerID string `yaml:"gordonContainerID"`58+ GordonContainerID string `yaml:"-"`
58 }59 }
59 60
60 type HttpConfig struct {61 type HttpConfig struct {
@@ -73,33 +74,39 @@ type ContainerEngineConfig struct {
73 }74 }
74 75
75 type ReverseProxyConfig struct {76 type ReverseProxyConfig struct {
76- Port string `yaml:"port"` // Port for the reverse proxy to listen on77+ Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled
77- HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS78+ Port string `yaml:"port"` // Port for the reverse proxy to listen on
78- CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates79+ HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
79- AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates80+ CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
80- RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates81+ AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
81- LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"82+ RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
82- Email string `yaml:"email"` // Email for Let's Encrypt83+ LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
83- CacheSize int `yaml:"cacheSize"` // Size of the certificate cache84+ Email string `yaml:"email"` // Email for Let's Encrypt
84- GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds85+ CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
85- EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true)86+ GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
86- DisableRateLimit bool `yaml:"disableRateLimit"` // Whether to disable rate limiting middleware (default: false)87+ EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging (default: true)
88+ EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware (default: false)
89+ DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies (default: false)
90+ SkipCertificates bool `yaml:"skipCertificates"` // Skip Let's Encrypt certificate acquisition when behind a TLS terminating proxy (default: false)
87 }91 }
88 92
89 // Default values93 // Default values
90 var (94 var (
91- sock = "/var/run/docker.sock"95+ sock = "/var/run/docker.sock"
92- podmansock = "/run/podman/podman.sock"96+ podmansock = "/run/podman/podman.sock"
93- reverseProxyPort = "443" // Changed to 443 for standard HTTPS97+ reverseProxyPort = "443" // Changed to 443 for standard HTTPS
94- httpPort = "80" // Standard HTTP port98+ httpPort = "80" // Standard HTTP port
95- certDir = "/certs"99+ certDir = "/certs"
96- letsEncryptMode = "staging"100+ letsEncryptMode = "staging"
97- autoRenew = true101+ autoRenew = true
98- renewBefore = 30 // days102+ proxyEnabled = true // Default to enabled for backward compatibility
99- cacheSize = 1000 // entries103+ renewBefore = 30 // days
100- gracePeriod = 30 // seconds104+ cacheSize = 1000 // entries
101- defaultLogLevel = "info" // Default log level105+ gracePeriod = 30 // seconds
102- disableRateLimit = false106+ defaultLogLevel = "info" // Default log level
107+ disableRateLimit = false
108+ detectUpstreamProxy = false // Default to disabled
109+ skipCertificates = false // Default to disabled
103 )110 )
104 111
105 // applyDefaultsToConfig applies default values to any fields that have zero values112 // applyDefaultsToConfig applies default values to any fields that have zero values
@@ -115,6 +122,12 @@ func applyDefaultsToConfig(config *Config) bool {
115 }122 }
116 123
117 // Apply defaults to ReverseProxy config124 // Apply defaults to ReverseProxy config
125+ // Set Enabled to true by default if not specified
126+ if !config.ReverseProxy.Enabled {
127+ config.ReverseProxy.Enabled = proxyEnabled
128+ logger.Debug("Applied default value for ReverseProxy.Enabled", "value", proxyEnabled)
129+ defaultsApplied = true
130+ }
118 if config.ReverseProxy.Port == "" {131 if config.ReverseProxy.Port == "" {
119 config.ReverseProxy.Port = reverseProxyPort132 config.ReverseProxy.Port = reverseProxyPort
120 logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort)133 logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort)
@@ -150,16 +163,30 @@ func applyDefaultsToConfig(config *Config) bool {
150 logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod)163 logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod)
151 defaultsApplied = true164 defaultsApplied = true
152 }165 }
153- // Set EnableLogs to true by default if not specified166+ // Set EnableHttpLogs to true by default if not specified
154- if !config.ReverseProxy.EnableLogs {167+ if !config.ReverseProxy.EnableHttpLogs {
155- config.ReverseProxy.EnableLogs = true168+ config.ReverseProxy.EnableHttpLogs = true
156- logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true)169+ logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true)
170+ defaultsApplied = true
171+ }
172+ // Set EnableRateLimit to false by default if not specified
173+ if !config.ReverseProxy.EnableRateLimit {
174+ config.ReverseProxy.EnableRateLimit = false
175+ logger.Debug("Applied default value for ReverseProxy.EnableRateLimit", "value", false)
176+ defaultsApplied = true
177+ }
178+
179+ // Set DetectUpstreamProxy to false by default if not specified
180+ if !config.ReverseProxy.DetectUpstreamProxy {
181+ config.ReverseProxy.DetectUpstreamProxy = detectUpstreamProxy
182+ logger.Debug("Applied default value for ReverseProxy.DetectUpstreamProxy", "value", detectUpstreamProxy)
157 defaultsApplied = true183 defaultsApplied = true
158 }184 }
159- // Set DisableRateLimit to false by default if not specified185+
160- if !config.ReverseProxy.DisableRateLimit {186+ // Set SkipCertificates to false by default if not specified
161- config.ReverseProxy.DisableRateLimit = false187+ if !config.ReverseProxy.SkipCertificates {
162- logger.Debug("Applied default value for ReverseProxy.DisableRateLimit", "value", false)188+ config.ReverseProxy.SkipCertificates = skipCertificates
189+ logger.Debug("Applied default value for ReverseProxy.SkipCertificates", "value", skipCertificates)
163 defaultsApplied = true190 defaultsApplied = true
164 }191 }
165 192
@@ -476,6 +503,25 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
476 logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val)503 logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val)
477 }504 }
478 }505 }
506+
507+ // New environment variables for reverse proxy port control
508+ if val := os.Getenv("GORDON_USE_FALLBACK_BINDING"); val != "" {
509+ // This is handled at runtime in the proxy server but we log it here
510+ if printLogs {
511+ logger.Info("Found environment variable GORDON_USE_FALLBACK_BINDING", "value", val)
512+ }
513+ }
514+
515+ // Handle GORDON_PROXY_ENABLED environment variable
516+ if val := os.Getenv("GORDON_PROXY_ENABLED"); val != "" {
517+ enabled, err := strconv.ParseBool(val)
518+ if err == nil {
519+ config.ReverseProxy.Enabled = enabled
520+ }
521+ if printLogs {
522+ logger.Info("Using environment variable GORDON_PROXY_ENABLED", "value", config.ReverseProxy.Enabled)
523+ }
524+ }
479 if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" {525 if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" {
480 config.ReverseProxy.CertDir = val526 config.ReverseProxy.CertDir = val
481 if printLogs {527 if printLogs {
@@ -524,22 +570,59 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
524 logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i)570 logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i)
525 }571 }
526 572
527- // Handle GORDON_PROXY_ENABLE_LOGS environment variable573+ // Handle GORDON_PROXY_ENABLE_HTTP_LOGS environment variable
528- if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" {574+ if val := os.Getenv("GORDON_PROXY_ENABLE_HTTP_LOGS"); val != "" {
529 enableLogs, err := strconv.ParseBool(val)575 enableLogs, err := strconv.ParseBool(val)
530 if err == nil {576 if err == nil {
531- config.ReverseProxy.EnableLogs = enableLogs577+ config.ReverseProxy.EnableHttpLogs = enableLogs
532 }578 }
533- logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS", "value", config.ReverseProxy.EnableLogs)579+ logger.Info("Using environment variable GORDON_PROXY_ENABLE_HTTP_LOGS", "value", config.ReverseProxy.EnableHttpLogs)
580+ } else if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" {
581+ // For backward compatibility
582+ enableLogs, err := strconv.ParseBool(val)
583+ if err == nil {
584+ config.ReverseProxy.EnableHttpLogs = enableLogs
585+ }
586+ logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS (deprecated, use GORDON_PROXY_ENABLE_HTTP_LOGS)", "value", config.ReverseProxy.EnableHttpLogs)
534 }587 }
535 588
536- // Handle GORDON_PROXY_DISABLE_RATE_LIMIT environment variable589+ // Handle GORDON_PROXY_ENABLE_RATE_LIMIT environment variable
537- if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" {590+ if val := os.Getenv("GORDON_PROXY_ENABLE_RATE_LIMIT"); val != "" {
591+ enableRateLimit, err := strconv.ParseBool(val)
592+ if err == nil {
593+ config.ReverseProxy.EnableRateLimit = enableRateLimit
594+ }
595+ logger.Info("Using environment variable GORDON_PROXY_ENABLE_RATE_LIMIT", "value", config.ReverseProxy.EnableRateLimit)
596+ } else if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" {
597+ // For backward compatibility
538 disableRateLimit, err := strconv.ParseBool(val)598 disableRateLimit, err := strconv.ParseBool(val)
539 if err == nil {599 if err == nil {
540- config.ReverseProxy.DisableRateLimit = disableRateLimit600+ // Invert the logic
601+ config.ReverseProxy.EnableRateLimit = !disableRateLimit
602+ }
603+ logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT (deprecated, use GORDON_PROXY_ENABLE_RATE_LIMIT)", "value", !config.ReverseProxy.EnableRateLimit)
604+ }
605+
606+ // Handle GORDON_PROXY_DETECT_UPSTREAM environment variable
607+ if val := os.Getenv("GORDON_PROXY_DETECT_UPSTREAM"); val != "" {
608+ detectUpstream, err := strconv.ParseBool(val)
609+ if err == nil {
610+ config.ReverseProxy.DetectUpstreamProxy = detectUpstream
611+ }
612+ if printLogs {
613+ logger.Info("Using environment variable GORDON_PROXY_DETECT_UPSTREAM", "value", config.ReverseProxy.DetectUpstreamProxy)
614+ }
615+ }
616+
617+ // Handle GORDON_PROXY_SKIP_CERTIFICATES environment variable
618+ if val := os.Getenv("GORDON_PROXY_SKIP_CERTIFICATES"); val != "" {
619+ skipCerts, err := strconv.ParseBool(val)
620+ if err == nil {
621+ config.ReverseProxy.SkipCertificates = skipCerts
622+ }
623+ if printLogs {
624+ logger.Info("Using environment variable GORDON_PROXY_SKIP_CERTIFICATES", "value", config.ReverseProxy.SkipCertificates)
541 }625 }
542- logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT", "value", config.ReverseProxy.DisableRateLimit)
543 }626 }
544 }627 }
545 628
@@ -587,16 +670,16 @@ func (config *Config) LoadConfig() (*Config, error) {
587 Network: "gordon",670 Network: "gordon",
588 },671 },
589 ReverseProxy: ReverseProxyConfig{672 ReverseProxy: ReverseProxyConfig{
590- Port: reverseProxyPort,673+ Port: reverseProxyPort,
591- HttpPort: httpPort,674+ HttpPort: httpPort,
592- CertDir: certDir,675+ CertDir: certDir,
593- AutoRenew: autoRenew,676+ AutoRenew: autoRenew,
594- RenewBefore: renewBefore,677+ RenewBefore: renewBefore,
595- LetsEncryptMode: letsEncryptMode,678+ LetsEncryptMode: letsEncryptMode,
596- CacheSize: cacheSize,679+ CacheSize: cacheSize,
597- GracePeriod: gracePeriod,680+ GracePeriod: gracePeriod,
598- EnableLogs: true,681+ EnableHttpLogs: true,
599- DisableRateLimit: false,682+ EnableRateLimit: false,
600 },683 },
601 }684 }
602 685
@@ -726,16 +809,16 @@ func (config *Config) LoadConfig() (*Config, error) {
726 Network: "gordon",809 Network: "gordon",
727 },810 },
728 ReverseProxy: ReverseProxyConfig{811 ReverseProxy: ReverseProxyConfig{
729- Port: reverseProxyPort,812+ Port: reverseProxyPort,
730- HttpPort: httpPort,813+ HttpPort: httpPort,
731- CertDir: certDir,814+ CertDir: certDir,
732- AutoRenew: autoRenew,815+ AutoRenew: autoRenew,
733- RenewBefore: renewBefore,816+ RenewBefore: renewBefore,
734- LetsEncryptMode: letsEncryptMode,817+ LetsEncryptMode: letsEncryptMode,
735- CacheSize: cacheSize,818+ CacheSize: cacheSize,
736- GracePeriod: gracePeriod,819+ GracePeriod: gracePeriod,
737- EnableLogs: true,820+ EnableHttpLogs: true,
738- DisableRateLimit: false,821+ EnableRateLimit: false,
739 },822 },
740 }823 }
741 824
@@ -808,6 +891,27 @@ func (config *Config) LoadConfig() (*Config, error) {
808 logger.Info("Loaded configuration from", "path", configFilePath)891 logger.Info("Loaded configuration from", "path", configFilePath)
809 }892 }
810 893
894+ // After loading from file or environment, check if Admin.Path is empty
895+ // This should be placed before returning the config
896+ if config.Admin.Path == "" {
897+ logger.Warn("Admin path is empty or not set, admin dashboard webui will be disabled")
898+ config.Admin.AdminWebUI = false
899+ } else {
900+ config.Admin.AdminWebUI = true
901+ }
902+
903+ // Save config file if running in container and env vars were applied
904+ // This ensures config.yml always reflects the environment variables
905+ if docker.IsRunningInContainer() && configLogsPrinted {
906+ logger.Info("Environment variables were applied, saving configuration to reflect current values...")
907+ err = config.SaveConfig()
908+ if err != nil {
909+ logger.Error("Failed to save configuration with environment variables", "error", err)
910+ } else {
911+ logger.Info("Successfully updated config.yml with environment variable values")
912+ }
913+ }
914+
811 // Apply the log level from configuration915 // Apply the log level from configuration
812 if config.General.LogLevel != "" {916 if config.General.LogLevel != "" {
813 // Set the log level using our centralized logger917 // Set the log level using our centralized logger
@@ -930,3 +1034,8 @@ func (c *HttpConfig) FullDomain() string {
930 func (c *Config) GetVersion() string {1034 func (c *Config) GetVersion() string {
931 return c.Build.BuildVersion1035 return c.Build.BuildVersion
932 }1036 }
1037+
1038+// IsAdminWebUIEnabled returns whether the admin dashboard is enabled
1039+func (c *AdminConfig) IsAdminWebUIEnabled() bool {
1040+ return c.AdminWebUI && c.Path != ""
1041+}
M internal/common/init_dockerclient.go +155 -41
@@ -19,62 +19,176 @@ func DockerInit(cc *ContainerEngineConfig) error {
19 cc.PodmanSock = cc.Sock19 cc.PodmanSock = cc.Sock
20 }20 }
21 21
22- config := NewDockerConfig(cc)22+ // If socket is empty, try to detect it automatically
23- err := docker.InitializeClient(config)23+ if cc.Sock == "" {
24- if err != nil {24+ // Try standard paths first
25- // Check if it's a "socket doesn't exist" error25+ standardPaths := []string{
26- if strings.Contains(err.Error(), "no such file or directory") {26+ "/var/run/docker.sock", // Standard Docker socket in containers
27- // Log the error27+ "/run/podman/podman.sock", // Standard Podman path
28- log.Printf("Error initializing Docker client: %s", err)28+ "/var/run/podman/podman.sock", // Alternative standard path
29-29+ }
30- // Try alternative socket paths
31- socketPaths := []string{
32- "/run/podman/podman.sock", // Standard path
33- "/var/run/podman/podman.sock", // Alternative standard path
34- os.Getenv("HOME") + "/.local/podman/podman.sock", // Some distros
35- "/run/user/1000/podman/podman.sock", // User-specific socket
36- }
37 30
38- // Add Docker socket path as a fallback31+ // Add home directory based path
39- if !isPodmanSocket {32+ homePath := os.Getenv("HOME")
40- socketPaths = append(socketPaths, "/var/run/docker.sock")33+ if homePath != "" {
41- }34+ standardPaths = append(standardPaths, homePath+"/.local/podman/podman.sock")
35+ }
36+
37+ // Check standard paths first
38+ for _, path := range standardPaths {
39+ if _, err := os.Stat(path); err == nil {
40+ log.Printf("Auto-detected container engine socket at %s", path)
42 41
43- for _, path := range socketPaths {42+ // Detect if this is a Podman socket
44- // Skip if it's the path we already tried43+ isPodmanSocket = detectPodmanSocket(path)
45- if path == cc.Sock {44+ if isPodmanSocket {
46- continue45+ cc.Podman = true
46+ cc.PodmanSock = path
47 }47 }
48 48
49- // Check if this socket exists49+ cc.Sock = path
50- if _, err := os.Stat(path); err == nil {50+ break
51- log.Printf("Found alternative socket at %s", path)51+ } else {
52+ log.Printf("Socket not found at: %s", path)
53+ }
54+ }
52 55
53- // Detect if this is a Podman socket56+ // If not found in standard paths, try common user IDs
54- isPodmanSocket = detectPodmanSocket(path)57+ if cc.Sock == "" {
55- if isPodmanSocket {58+ log.Printf("Scanning for user-specific Podman sockets in ID range 1000-1100...")
59+
60+ // Try common user IDs from 1000 to 1100 (typical range for first users on Linux)
61+ for uid := 1000; uid <= 1100; uid++ {
62+ uidStr := fmt.Sprintf("%d", uid)
63+ podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr)
64+ dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr)
65+
66+ // Check the Podman socket exists
67+ if _, err := os.Stat(podmanSocketPath); err == nil {
68+ // Check if the socket is accessible for reading/writing
69+ if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil {
70+ file.Close()
71+ log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath)
56 cc.Podman = true72 cc.Podman = true
57- cc.PodmanSock = path73+ cc.PodmanSock = podmanSocketPath
74+ cc.Sock = podmanSocketPath
75+ break
76+ } else {
77+ log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err)
58 }78 }
79+ }
59 80
60- cc.Sock = path81+ // Check Docker socket for this UID
61-82+ if _, err := os.Stat(dockerSocketPath); err == nil {
62- // Try again with the new path83+ if file, err := os.OpenFile(dockerSocketPath, os.O_RDWR, 0); err == nil {
63- newConfig := NewDockerConfig(cc)84+ file.Close()
64- if err := docker.InitializeClient(newConfig); err == nil {85+ log.Printf("Found accessible Docker socket for user ID %s at: %s", uidStr, dockerSocketPath)
65- return nil86+ cc.Sock = dockerSocketPath
87+ break
66 }88 }
67 }89 }
68 }90 }
91+ }
92+ }
69 93
70- // Return without an error, as we want to continue even if Docker is not available94+ // If we found a socket, try to initialize Docker client
71- log.Printf("Docker/Podman functionality will be limited or unavailable")95+ if cc.Sock != "" {
72- return fmt.Errorf("could not connect to container engine: %w", err)96+ config := NewDockerConfig(cc)
97+ err := docker.InitializeClient(config)
98+ if err == nil {
99+ log.Printf("Successfully initialized Docker client with socket: %s", cc.Sock)
100+ return nil
73 }101 }
74 102
75- return err103+ // Log the error but continue with fallback strategy
104+ log.Printf("Error initializing Docker client: %s", err)
105+ }
106+
107+ // If we failed to find or initialize a socket, try a comprehensive fallback scan
108+ // across all common socket locations and user IDs
109+ socketPaths := []string{
110+ "/var/run/docker.sock",
111+ "/run/podman/podman.sock",
112+ "/var/run/podman/podman.sock",
113+ }
114+
115+ // Add home directory based path
116+ homePath := os.Getenv("HOME")
117+ if homePath != "" {
118+ socketPaths = append(socketPaths, homePath+"/.local/podman/podman.sock")
119+ }
120+
121+ // Skip if we already tried this path
122+ if cc.Sock != "" {
123+ skipPath := cc.Sock
124+ for i, path := range socketPaths {
125+ if path == skipPath {
126+ socketPaths = append(socketPaths[:i], socketPaths[i+1:]...)
127+ break
128+ }
129+ }
76 }130 }
77- return nil131+
132+ // Try common user IDs from 1000 to 1100
133+ for uid := 1000; uid <= 1100; uid++ {
134+ uidStr := fmt.Sprintf("%d", uid)
135+ podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr)
136+ dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr)
137+
138+ // Check the Podman socket exists
139+ if _, err := os.Stat(podmanSocketPath); err == nil {
140+ // Check if the socket is accessible for reading/writing
141+ if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil {
142+ file.Close()
143+ log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath)
144+ socketPaths = append([]string{podmanSocketPath}, socketPaths...) // Prioritize this path
145+ } else {
146+ log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err)
147+ }
148+ }
149+
150+ // Also check Docker socket for this UID
151+ if _, err := os.Stat(dockerSocketPath); err == nil {
152+ socketPaths = append(socketPaths, dockerSocketPath)
153+ }
154+ }
155+
156+ log.Printf("Comprehensive socket scan across %d potential paths...", len(socketPaths))
157+
158+ for _, path := range socketPaths {
159+ // Skip if it's the path we already tried
160+ if path == cc.Sock {
161+ continue
162+ }
163+
164+ // Check if this socket exists
165+ if _, err := os.Stat(path); err == nil {
166+ log.Printf("Found alternative socket at %s", path)
167+
168+ // Detect if this is a Podman socket
169+ isPodmanSocket = detectPodmanSocket(path)
170+ if isPodmanSocket {
171+ cc.Podman = true
172+ cc.PodmanSock = path
173+ }
174+
175+ cc.Sock = path
176+
177+ // Try again with the new path
178+ newConfig := NewDockerConfig(cc)
179+ if err := docker.InitializeClient(newConfig); err == nil {
180+ log.Printf("Successfully initialized Docker client with socket: %s", path)
181+ return nil
182+ } else {
183+ log.Printf("Socket found but client initialization failed: %s", err)
184+ }
185+ }
186+ }
187+
188+ // If we still haven't found a valid socket, give up but don't fail the application
189+ log.Printf("No working container engine socket found after comprehensive scan")
190+ log.Printf("Docker/Podman functionality will be limited or unavailable")
191+ return fmt.Errorf("could not find or connect to any container engine socket")
78 }192 }
79 193
80 // detectPodmanSocket checks if a socket path is likely a Podman socket194 // detectPodmanSocket checks if a socket path is likely a Podman socket
M internal/httpserve/handlers/container_crud.go +35 -37
@@ -7,6 +7,8 @@ import (
7 7
8 "github.com/bnema/gordon/internal/server"8 "github.com/bnema/gordon/internal/server"
9 "github.com/bnema/gordon/internal/templating/cmdparams"9 "github.com/bnema/gordon/internal/templating/cmdparams"
10+ templcomponents "github.com/bnema/gordon/internal/templating/models/templ/components"
11+ templadmin "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
10 "github.com/bnema/gordon/internal/templating/render"12 "github.com/bnema/gordon/internal/templating/render"
11 "github.com/bnema/gordon/pkg/docker"13 "github.com/bnema/gordon/pkg/docker"
12 "github.com/bnema/gordon/pkg/sanitize"14 "github.com/bnema/gordon/pkg/sanitize"
@@ -34,7 +36,7 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
34 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)36 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
35 if !exists {37 if !exists {
36 log.Debug("No mapping found for short ID: %s", shortID)38 log.Debug("No mapping found for short ID: %s", shortID)
37- return c.String(http.StatusNotFound, "Image ID not found")39+ return RenderNotFoundPage(c, a)
38 }40 }
39 41
40 log.Debug("Found mapping: %s -> %s", shortID, fullID)42 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -49,30 +51,23 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
49 var imageName string51 var imageName string
50 if len(imageInfo.RepoTags) > 0 {52 if len(imageInfo.RepoTags) > 0 {
51 imageName = imageInfo.RepoTags[0]53 imageName = imageInfo.RepoTags[0]
54+ } else {
55+ imageName = imageInfo.ID
52 }56 }
53 57
54- data := map[string]interface{}{58+ // Setup form data
55- "Title": "Create a new container",59+ formData := templcomponents.ContainerFormData{
56- "ShortID": shortID,60+ ImageName: imageName,
57- "ImageID": fullID,61+ ImageID: fullID,
58- "ImageName": imageName,62+ ShortID: shortID,
63+ ErrorMessage: "",
59 }64 }
60 65
61- // Render the create container page66+ // Get the renderer
62- rendererData, err := render.GetHTMLRenderer("html/fragments", "createcontainer.gohtml", a.TemplateFS, a)67+ renderer := render.NewTemplRenderer(a)
63-
64- if err != nil {
65- return sendError(c, err)
66- }
67-
68- renderedHTML, err := rendererData.Render(data, a)
69-
70- if err != nil {
71- return sendError(c, err)
72- }
73-
74- return c.HTML(200, renderedHTML)
75 68
69+ // Render the component
70+ return renderer.RenderTempl(c, templcomponents.CreateContainerForm(formData))
76 }71 }
77 72
78 // CreateContainerFullGET handles the full HTML page for creating a new container73 // CreateContainerFullGET handles the full HTML page for creating a new container
@@ -84,7 +79,7 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
84 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)79 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
85 if !exists {80 if !exists {
86 log.Debug("No mapping found for short ID: %s", shortID)81 log.Debug("No mapping found for short ID: %s", shortID)
87- return c.String(http.StatusNotFound, "Image ID not found")82+ return RenderNotFoundPage(c, a)
88 }83 }
89 84
90 log.Debug("Found mapping: %s -> %s", shortID, fullID)85 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -99,29 +94,32 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
99 var imageName string94 var imageName string
100 if len(imageInfo.RepoTags) > 0 {95 if len(imageInfo.RepoTags) > 0 {
101 imageName = imageInfo.RepoTags[0]96 imageName = imageInfo.RepoTags[0]
97+ } else {
98+ imageName = imageInfo.ID
102 }99 }
103 100
104- data := map[string]interface{}{101+ // Setup form data
105- "Title": "Create a new container",102+ formData := templcomponents.ContainerFormData{
106- "ShortID": shortID,103+ ImageName: imageName,
107- "ImageID": fullID,104+ ImageID: fullID,
108- "ImageName": imageName,105+ ShortID: shortID,
109- "AdminPath": a.Config.Admin.Path,106+ ErrorMessage: "",
110 }107 }
111 108
112- // Specify both admin and fragments directories109+ // Get the renderer
113- rendererData, err := render.GetHTMLRenderer("html/admin", "createcontainerfull.gohtml", a.TemplateFS, a, "html/fragments")110+ renderer := render.NewTemplRenderer(a)
114- if err != nil {
115- return sendError(c, err)
116- }
117 111
118- renderedHTML, err := rendererData.Render(data, a)112+ // Render the createcontainerfull template
119- if err != nil {113+ pageData := templadmin.CreateContainerFullData{
120- log.Debug("Error rendering template: %v", err)114+ Title: "Create Container | Gordon",
121- return sendError(c, err)115+ BuildVersion: a.Config.Build.BuildVersion,
116+ AdminPath: a.Config.Admin.Path,
117+ UserSettings: "/user",
118+ LogoutURL: a.Config.Admin.Path + "/logout",
119+ ContainerData: formData,
122 }120 }
123 121
124- return c.HTML(200, renderedHTML)122+ return renderer.RenderTempl(c, templadmin.CreateContainerFullPage(pageData))
125 }123 }
126 124
127 // CreateContainerPOST handles the create container form submission125 // CreateContainerPOST handles the create container form submission
M internal/httpserve/handlers/container_management.go +58 -2
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/gordon/internal/templating/render"12 "github.com/bnema/gordon/internal/templating/render"
13 "github.com/bnema/gordon/pkg/docker"13 "github.com/bnema/gordon/pkg/docker"
14 "github.com/bnema/gordon/pkg/humanize"14 "github.com/bnema/gordon/pkg/humanize"
15+ "github.com/bnema/gordon/pkg/logger"
15 "github.com/docker/docker/api/types"16 "github.com/docker/docker/api/types"
16 "github.com/labstack/echo/v4"17 "github.com/labstack/echo/v4"
17 )18 )
@@ -58,6 +59,10 @@ type HumanReadableContainerImage struct {
58 SizeStr string59 SizeStr string
59 RepoDigests []string60 RepoDigests []string
60 RepoTags []string61 RepoTags []string
62+ Repository string
63+ Tag string
64+ Size string
65+ Created string
61 }66 }
62 67
63 type HumanReadableContainer struct {68 type HumanReadableContainer struct {
@@ -85,17 +90,33 @@ type ContainerDisplay struct {
85 90
86 // renderHTML is a generalized function to render HTML91 // renderHTML is a generalized function to render HTML
87 func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error {92 func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error {
93+ logger.Debug("Rendering HTML", "path", path, "template", templateName, "data_keys", getKeysFromMap(data))
94+
88 rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)95 rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)
89 if err != nil {96 if err != nil {
97+ logger.Error("Error getting HTML renderer", "error", err)
90 return err98 return err
91 }99 }
100+
92 renderedHTML, err := rendererData.Render(data, a)101 renderedHTML, err := rendererData.Render(data, a)
93 if err != nil {102 if err != nil {
103+ logger.Error("Error rendering HTML", "error", err)
94 return err104 return err
95 }105 }
106+
107+ logger.Debug("HTML rendered successfully", "content_length", len(renderedHTML))
96 return c.HTML(http.StatusOK, renderedHTML)108 return c.HTML(http.StatusOK, renderedHTML)
97 }109 }
98 110
111+// getKeysFromMap returns a slice of keys from a map
112+func getKeysFromMap(data map[string]interface{}) []string {
113+ keys := make([]string, 0, len(data))
114+ for k := range data {
115+ keys = append(keys, k)
116+ }
117+ return keys
118+}
119+
99 // ActionSuccess returns the success HTML fragment120 // ActionSuccess returns the success HTML fragment
100 func ActionSuccess(a *server.App) string {121 func ActionSuccess(a *server.App) string {
101 // Create a data structure to pass to the template122 // Create a data structure to pass to the template
@@ -114,11 +135,19 @@ func ActionSuccess(a *server.App) string {
114 135
115 // ImageManagerComponent handles the /image-manager route (HTMX route)136 // ImageManagerComponent handles the /image-manager route (HTMX route)
116 func ImageManagerComponent(c echo.Context, a *server.App) error {137 func ImageManagerComponent(c echo.Context, a *server.App) error {
138+ logger.Debug("ImageManagerComponent called", "url", c.Request().URL.String())
139+
117 images, err := docker.ListContainerImages()140 images, err := docker.ListContainerImages()
118 if err != nil {141 if err != nil {
142+ logger.Error("Error listing container images:", "error", err)
119 return sendError(c, err)143 return sendError(c, err)
120 }144 }
121 145
146+ logger.Debug("Images found", "count", len(images))
147+ for i, img := range images {
148+ logger.Debug("Image details", "index", i, "id", img.ID, "repoTags", img.RepoTags)
149+ }
150+
122 var humanReadableImages []HumanReadableContainerImage151 var humanReadableImages []HumanReadableContainerImage
123 152
124 for _, img := range images {153 for _, img := range images {
@@ -129,6 +158,14 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
129 sizeStr := humanize.BytesToReadableSize(img.Size)158 sizeStr := humanize.BytesToReadableSize(img.Size)
130 159
131 for _, repoTag := range img.RepoTags {160 for _, repoTag := range img.RepoTags {
161+ // Split the repo tag into repository and tag
162+ parts := strings.Split(repoTag, ":")
163+ repository := parts[0]
164+ tag := "latest"
165+ if len(parts) > 1 {
166+ tag = parts[1]
167+ }
168+
132 humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{169 humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{
133 ID: img.ID,170 ID: img.ID,
134 ShortID: shortID,171 ShortID: shortID,
@@ -137,6 +174,11 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
137 Name: repoTag,174 Name: repoTag,
138 RepoDigests: img.RepoDigests,175 RepoDigests: img.RepoDigests,
139 RepoTags: img.RepoTags,176 RepoTags: img.RepoTags,
177+ // Add fields that match template expectations
178+ Repository: repository,
179+ Tag: tag,
180+ Size: sizeStr,
181+ Created: createdStr,
140 })182 })
141 }183 }
142 }184 }
@@ -145,6 +187,7 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
145 "Images": humanReadableImages,187 "Images": humanReadableImages,
146 }188 }
147 189
190+ logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images")
148 return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)191 return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)
149 }192 }
150 193
@@ -170,11 +213,19 @@ func ImageManagerDelete(c echo.Context, a *server.App) error {
170 213
171 // ContainerManagerComponent handles the /container-manager route214 // ContainerManagerComponent handles the /container-manager route
172 func ContainerManagerComponent(c echo.Context, a *server.App) error {215 func ContainerManagerComponent(c echo.Context, a *server.App) error {
216+ logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String())
217+
173 containers, err := docker.ListRunningContainers()218 containers, err := docker.ListRunningContainers()
174 if err != nil {219 if err != nil {
220+ logger.Error("Error listing containers", "error", err)
175 return sendError(c, err)221 return sendError(c, err)
176 }222 }
177 223
224+ logger.Debug("Containers found", "count", len(containers))
225+ for i, container := range containers {
226+ logger.Debug("Container details", "index", i, "id", container.ID, "names", container.Names, "state", container.State)
227+ }
228+
178 var humanReadableContainers []HumanReadableContainer229 var humanReadableContainers []HumanReadableContainer
179 230
180 for _, container := range containers {231 for _, container := range containers {
@@ -212,10 +263,15 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error {
212 return err263 return err
213 }264 }
214 data := map[string]interface{}{265 data := map[string]interface{}{
215- "Lang": yamlData["Lang"],266+ "Lang": yamlData["Lang"],
216- "containers": humanReadableContainers,267+ "Containers": humanReadableContainers,
268+ "NoContainer": len(humanReadableContainers) == 0,
217 }269 }
218 270
271+ logger.Debug("Rendering containerlist template",
272+ "humanReadableContainers", len(humanReadableContainers),
273+ "noContainer", len(humanReadableContainers) == 0)
274+
219 return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)275 return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)
220 }276 }
221 277
M internal/httpserve/handlers/container_transactions.go +3 -3
@@ -2,13 +2,13 @@ package handlers
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "log"
6 "strings"5 "strings"
7 6
8 "github.com/bnema/gordon/internal/server"7 "github.com/bnema/gordon/internal/server"
9 "github.com/bnema/gordon/internal/templating/cmdparams"8 "github.com/bnema/gordon/internal/templating/cmdparams"
10 "github.com/bnema/gordon/internal/templating/render"9 "github.com/bnema/gordon/internal/templating/render"
11 "github.com/bnema/gordon/pkg/docker"10 "github.com/bnema/gordon/pkg/docker"
11+ "github.com/bnema/gordon/pkg/logger"
12 "github.com/labstack/echo/v4"12 "github.com/labstack/echo/v4"
13 "gopkg.in/yaml.v3"13 "gopkg.in/yaml.v3"
14 )14 )
@@ -132,13 +132,13 @@ func (tq *TransactionQueue) Execute() error {
132 for i, step := range tq.steps {132 for i, step := range tq.steps {
133 err := step()133 err := step()
134 if err != nil {134 if err != nil {
135- log.Printf("Error occurred at step index %d: %v", i, err)135+ logger.Error(fmt.Sprintf("Error occurred at step index %d: %v", i, err))
136 transactionErrors = append(transactionErrors, err)136 transactionErrors = append(transactionErrors, err)
137 for j := i - 1; j >= 0; j-- {137 for j := i - 1; j >= 0; j-- {
138 rollback := tq.rollbacks[j]138 rollback := tq.rollbacks[j]
139 if rollback != nil {139 if rollback != nil {
140 if rollbackErr := rollback(); rollbackErr != nil {140 if rollbackErr := rollback(); rollbackErr != nil {
141- log.Printf("Failed to execute rollback at index %d: %v", j, rollbackErr)141+ logger.Error(fmt.Sprintf("Failed to execute rollback at index %d: %v", j, rollbackErr))
142 rollbackErrors = append(rollbackErrors, rollbackErr)142 rollbackErrors = append(rollbackErrors, rollbackErr)
143 }143 }
144 }144 }
M internal/httpserve/handlers/endpoint_deploy.go +34 -34
@@ -17,7 +17,7 @@ import (
17 "github.com/bnema/gordon/internal/proxy"17 "github.com/bnema/gordon/internal/proxy"
18 "github.com/bnema/gordon/internal/server"18 "github.com/bnema/gordon/internal/server"
19 "github.com/bnema/gordon/pkg/docker"19 "github.com/bnema/gordon/pkg/docker"
20- "github.com/charmbracelet/log"20+ "github.com/bnema/gordon/pkg/logger"
21 "github.com/labstack/echo/v4"21 "github.com/labstack/echo/v4"
22 )22 )
23 23
@@ -26,7 +26,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
26 payload, err := validateAndPrepareDeployPayload(c)26 payload, err := validateAndPrepareDeployPayload(c)
27 if err != nil {27 if err != nil {
28 // Debug28 // Debug
29- log.Error("Failed to validate and prepare payload", "error", err)29+ logger.Error("Failed to validate and prepare payload", "error", err)
30 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{30 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
31 Success: false,31 Success: false,
32 Message: fmt.Sprintf("Invalid payload: %v", err),32 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -37,7 +37,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
37 _, err = saveAndImportImage(c, a, payload)37 _, err = saveAndImportImage(c, a, payload)
38 if err != nil {38 if err != nil {
39 // Debug39 // Debug
40- log.Error("Failed to save and import image", "error", err)40+ logger.Error("Failed to save and import image", "error", err)
41 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{41 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
42 Success: false,42 Success: false,
43 Message: fmt.Sprintf("Failed to save or import image: %v", err),43 Message: fmt.Sprintf("Failed to save or import image: %v", err),
@@ -48,14 +48,14 @@ func PostDeploy(c echo.Context, a *server.App) error {
48 containerID, containerName, err := createAndStartContainer(a, payload)48 containerID, containerName, err := createAndStartContainer(a, payload)
49 if err != nil {49 if err != nil {
50 if strings.Contains(err.Error(), "is already in use") {50 if strings.Contains(err.Error(), "is already in use") {
51- log.Warn("Container already exists", "error", err)51+ logger.Warn("Container already exists", "error", err)
52 existingContainerID := extractContainerID(err.Error())52 existingContainerID := extractContainerID(err.Error())
53 existingContainerName, _ := docker.GetContainerName(existingContainerID)53 existingContainerName, _ := docker.GetContainerName(existingContainerID)
54 // Remove the / from the container name54 // Remove the / from the container name
55 existingContainerName = strings.TrimLeft(existingContainerName, "/")55 existingContainerName = strings.TrimLeft(existingContainerName, "/")
56 56
57 if existingContainerID == "" || existingContainerName == "" {57 if existingContainerID == "" || existingContainerName == "" {
58- log.Error("Failed to extract container ID or Name from error message")58+ logger.Error("Failed to extract container ID or Name from error message")
59 return sendJSONResponse(c, http.StatusConflict, DeployResponse{59 return sendJSONResponse(c, http.StatusConflict, DeployResponse{
60 Success: false,60 Success: false,
61 Message: "A container for this deploy already exists but could not be identified.",61 Message: "A container for this deploy already exists but could not be identified.",
@@ -71,7 +71,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
71 StatusCode: http.StatusConflict,71 StatusCode: http.StatusConflict,
72 })72 })
73 }73 }
74- log.Error("Failed to create or start container", "error", err)74+ logger.Error("Failed to create or start container", "error", err)
75 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{75 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
76 Success: false,76 Success: false,
77 Message: fmt.Sprintf("Failed to create or start container: %v", err),77 Message: fmt.Sprintf("Failed to create or start container: %v", err),
@@ -90,11 +90,11 @@ func PostDeploy(c echo.Context, a *server.App) error {
90 containerIP := GetContainerIP(a, containerID, containerName)90 containerIP := GetContainerIP(a, containerID, containerName)
91 err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)91 err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)
92 if err != nil {92 if err != nil {
93- log.Error("Failed to add proxy route", "error", err)93+ logger.Error("Failed to add proxy route", "error", err)
94 return sendDeployErrorResponse(c, "Failed to add proxy route", err)94 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
95 }95 }
96 } else {96 } else {
97- log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)97+ logger.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)
98 }98 }
99 99
100 // Arrived here means deployment was successful100 // Arrived here means deployment was successful
@@ -124,7 +124,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
124 124
125 payload, err := validateAndPrepareDeployPayload(c)125 payload, err := validateAndPrepareDeployPayload(c)
126 if err != nil {126 if err != nil {
127- log.Error("Failed to validate and prepare payload", "error", err)127+ logger.Error("Failed to validate and prepare payload", "error", err)
128 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{128 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
129 Success: false,129 Success: false,
130 Message: fmt.Sprintf("Invalid payload: %v", err),130 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -196,7 +196,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
196 // Chunks validation steps196 // Chunks validation steps
197 //197 //
198 if len(chunkData) == 0 {198 if len(chunkData) == 0 {
199- log.Error("Received empty chunk")199+ logger.Error("Received empty chunk")
200 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{200 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
201 Success: false,201 Success: false,
202 Message: "Received empty chunk",202 Message: "Received empty chunk",
@@ -206,7 +206,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
206 206
207 // Verify chunk size matches metadata207 // Verify chunk size matches metadata
208 if int64(len(chunkData)) != metadata.ChunkSize {208 if int64(len(chunkData)) != metadata.ChunkSize {
209- log.Error("Chunk size mismatch",209+ logger.Error("Chunk size mismatch",
210 "expected", metadata.ChunkSize,210 "expected", metadata.ChunkSize,
211 "received", len(chunkData))211 "received", len(chunkData))
212 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{212 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
@@ -236,7 +236,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
236 }236 }
237 237
238 func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error {238 func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error {
239- log.Info("Starting complete deploy transfer processing",239+ logger.Info("Starting complete deploy transfer processing",
240 "transferID", transferID)240 "transferID", transferID)
241 241
242 // Get transfer data242 // Get transfer data
@@ -296,21 +296,21 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
296 // Check for existing container296 // Check for existing container
297 existingContainerID := docker.GetContainerIDByName(containerName)297 existingContainerID := docker.GetContainerIDByName(containerName)
298 if existingContainerID != "" {298 if existingContainerID != "" {
299- log.Warn("Container already exists",299+ logger.Warn("Container already exists",
300 "name", containerName,300 "name", containerName,
301 "id", existingContainerID)301 "id", existingContainerID)
302 return sendDeployErrorResponse(c, "Container already exists", nil)302 return sendDeployErrorResponse(c, "Container already exists", nil)
303 }303 }
304 304
305 // Import the image305 // Import the image
306- log.Info("Importing image from file",306+ logger.Info("Importing image from file",
307 "path", tmpFile.Name(),307 "path", tmpFile.Name(),
308 "imageName", imageName)308 "imageName", imageName)
309 309
310 imageID, err := docker.ImportImageToEngine(tmpFile.Name())310 imageID, err := docker.ImportImageToEngine(tmpFile.Name())
311 if err != nil {311 if err != nil {
312 if strings.Contains(err.Error(), "platform compatibility check failed") {312 if strings.Contains(err.Error(), "platform compatibility check failed") {
313- log.Error("Platform compatibility check failed",313+ logger.Error("Platform compatibility check failed",
314 "error", err,314 "error", err,
315 "imageName", imageName)315 "imageName", imageName)
316 return sendDeployErrorResponse(c, "Platform compatibility check failed", err)316 return sendDeployErrorResponse(c, "Platform compatibility check failed", err)
@@ -343,7 +343,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
343 // Cleanup transfer data343 // Cleanup transfer data
344 cleanupTransfer(transferID)344 cleanupTransfer(transferID)
345 345
346- log.Info("Deployment completed successfully",346+ logger.Info("Deployment completed successfully",
347 "containerID", containerID,347 "containerID", containerID,
348 "containerName", containerName,348 "containerName", containerName,
349 "domain", targetDomain)349 "domain", targetDomain)
@@ -352,7 +352,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
352 containerIP := GetContainerIP(a, containerID, containerName)352 containerIP := GetContainerIP(a, containerID, containerName)
353 err = AddProxyRoute(a, containerID, containerIP, port, targetDomain)353 err = AddProxyRoute(a, containerID, containerIP, port, targetDomain)
354 if err != nil {354 if err != nil {
355- log.Error("Failed to add proxy route", "error", err)355+ logger.Error("Failed to add proxy route", "error", err)
356 return sendDeployErrorResponse(c, "Failed to add proxy route", err)356 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
357 }357 }
358 358
@@ -422,7 +422,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
422 422
423 // Double check that we have a valid container IP - critical for recreated containers423 // Double check that we have a valid container IP - critical for recreated containers
424 if containerIP == "" || containerIP == containerID || containerIP == "localhost" {424 if containerIP == "" || containerIP == containerID || containerIP == "localhost" {
425- log.Warn("Invalid container IP detected, attempting to get correct IP",425+ logger.Warn("Invalid container IP detected, attempting to get correct IP",
426 "containerID", containerID,426 "containerID", containerID,
427 "invalid_ip", containerIP)427 "invalid_ip", containerIP)
428 428
@@ -437,7 +437,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
437 // Get the correct container IP with retry logic437 // Get the correct container IP with retry logic
438 newContainerIP := GetContainerIP(a, containerID, containerName)438 newContainerIP := GetContainerIP(a, containerID, containerName)
439 if newContainerIP != containerIP && newContainerIP != containerName {439 if newContainerIP != containerIP && newContainerIP != containerName {
440- log.Info("Updated container IP for proxy route",440+ logger.Info("Updated container IP for proxy route",
441 "containerID", containerID,441 "containerID", containerID,
442 "old_ip", containerIP,442 "old_ip", containerIP,
443 "new_ip", newContainerIP)443 "new_ip", newContainerIP)
@@ -448,12 +448,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
448 // Get container info to check labels448 // Get container info to check labels
449 containerInfo, err := docker.GetContainerInfo(containerID)449 containerInfo, err := docker.GetContainerInfo(containerID)
450 if err != nil {450 if err != nil {
451- log.Warn("Failed to get container info", "error", err)451+ logger.Warn("Failed to get container info", "error", err)
452 } else {452 } else {
453 // Check if the container has a gordon.proxy.port label that might override containerPort453 // Check if the container has a gordon.proxy.port label that might override containerPort
454 if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {454 if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
455 if portLabel != containerPort {455 if portLabel != containerPort {
456- log.Info("Using container label port instead of provided port",456+ logger.Info("Using container label port instead of provided port",
457 "label_port", portLabel,457 "label_port", portLabel,
458 "provided_port", containerPort)458 "provided_port", containerPort)
459 containerPort = portLabel459 containerPort = portLabel
@@ -467,7 +467,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
467 // Check if the target domain specifies HTTPS467 // Check if the target domain specifies HTTPS
468 if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {468 if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {
469 protocol = "https"469 protocol = "https"
470- log.Debug("Using HTTPS protocol for proxy route based on target domain",470+ logger.Debug("Using HTTPS protocol for proxy route based on target domain",
471 "domain", targetDomain,471 "domain", targetDomain,
472 "protocol", protocol)472 "protocol", protocol)
473 } else {473 } else {
@@ -477,7 +477,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
477 if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&477 if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
478 (sslValue == "true" || sslValue == "1" || sslValue == "yes") {478 (sslValue == "true" || sslValue == "1" || sslValue == "yes") {
479 protocol = "https"479 protocol = "https"
480- log.Debug("Using HTTPS protocol for proxy route based on container labels",480+ logger.Debug("Using HTTPS protocol for proxy route based on container labels",
481 "domain", targetDomain,481 "domain", targetDomain,
482 "containerID", containerID,482 "containerID", containerID,
483 "label_value", sslValue,483 "label_value", sslValue,
@@ -496,16 +496,16 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
496 cleanDomain = strings.TrimPrefix(cleanDomain, ".")496 cleanDomain = strings.TrimPrefix(cleanDomain, ".")
497 497
498 // Log that we're adding the domain for Let's Encrypt498 // Log that we're adding the domain for Let's Encrypt
499- log.Debug("Adding domain for Let's Encrypt verification",499+ logger.Debug("Adding domain for Let's Encrypt verification",
500 "domain", cleanDomain)500 "domain", cleanDomain)
501 501
502 // Get container name for better logging502 // Get container name for better logging
503 containerName, err := docker.GetContainerName(containerID)503 containerName, err := docker.GetContainerName(containerID)
504 if err != nil {504 if err != nil {
505- log.Warn("Unable to get container name", "containerID", containerID, "error", err)505+ logger.Warn("Unable to get container name", "containerID", containerID, "error", err)
506 } else {506 } else {
507 containerName = strings.TrimPrefix(containerName, "/")507 containerName = strings.TrimPrefix(containerName, "/")
508- log.Debug("Container name for proxy route", "name", containerName)508+ logger.Debug("Container name for proxy route", "name", containerName)
509 }509 }
510 510
511 // Check the database for any existing routes with this domain, regardless of container ID511 // Check the database for any existing routes with this domain, regardless of container ID
@@ -526,7 +526,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
526 526
527 if err == nil {527 if err == nil {
528 // Domain exists in database, check if this is a recreated container528 // Domain exists in database, check if this is a recreated container
529- log.Info("Found existing proxy route for domain",529+ logger.Info("Found existing proxy route for domain",
530 "domain", cleanDomain,530 "domain", cleanDomain,
531 "old_container_id", existingContainerID,531 "old_container_id", existingContainerID,
532 "new_container_id", containerID)532 "new_container_id", containerID)
@@ -538,7 +538,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
538 networkName := a.Config.ContainerEngine.Network538 networkName := a.Config.ContainerEngine.Network
539 if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists &&539 if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists &&
540 networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP {540 networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP {
541- log.Info("Updating container IP before database update",541+ logger.Info("Updating container IP before database update",
542 "domain", cleanDomain,542 "domain", cleanDomain,
543 "old_ip", containerIP,543 "old_ip", containerIP,
544 "new_ip", networkSettings.IPAddress)544 "new_ip", networkSettings.IPAddress)
@@ -559,7 +559,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
559 return fmt.Errorf("failed to update proxy route: %w", err)559 return fmt.Errorf("failed to update proxy route: %w", err)
560 }560 }
561 561
562- log.Info("Updated proxy route for recreated container",562+ logger.Info("Updated proxy route for recreated container",
563 "domain", cleanDomain,563 "domain", cleanDomain,
564 "old_container_id", existingContainerID,564 "old_container_id", existingContainerID,
565 "new_container_id", containerID,565 "new_container_id", containerID,
@@ -579,11 +579,11 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
579 WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP)579 WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP)
580 580
581 if err == nil && updatedContainerIP != containerIP {581 if err == nil && updatedContainerIP != containerIP {
582- log.Warn("Database verification failed: container IP mismatch",582+ logger.Warn("Database verification failed: container IP mismatch",
583 "expected", containerIP,583 "expected", containerIP,
584 "actual", updatedContainerIP)584 "actual", updatedContainerIP)
585 } else {585 } else {
586- log.Debug("Database verification passed: container IP updated correctly",586+ logger.Debug("Database verification passed: container IP updated correctly",
587 "container_ip", containerIP)587 "container_ip", containerIP)
588 }588 }
589 589
@@ -596,12 +596,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
596 routes := p.GetRoutes()596 routes := p.GetRoutes()
597 if route, exists := routes[cleanDomain]; exists {597 if route, exists := routes[cleanDomain]; exists {
598 if route.ContainerIP != containerIP {598 if route.ContainerIP != containerIP {
599- log.Warn("Route reload verification failed: container IP mismatch",599+ logger.Warn("Route reload verification failed: container IP mismatch",
600 "domain", cleanDomain,600 "domain", cleanDomain,
601 "expected", containerIP,601 "expected", containerIP,
602 "actual", route.ContainerIP)602 "actual", route.ContainerIP)
603 } else {603 } else {
604- log.Debug("Route reload verification passed: container IP loaded correctly",604+ logger.Debug("Route reload verification passed: container IP loaded correctly",
605 "domain", cleanDomain,605 "domain", cleanDomain,
606 "container_ip", containerIP)606 "container_ip", containerIP)
607 }607 }
@@ -623,7 +623,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
623 // and avoid database locking conflicts623 // and avoid database locking conflicts
624 p.RegisterNewlyCreatedContainer(containerID)624 p.RegisterNewlyCreatedContainer(containerID)
625 625
626- log.Debug("Added proxy route",626+ logger.Debug("Added proxy route",
627 "domain", targetDomain,627 "domain", targetDomain,
628 "containerIP", containerIP,628 "containerIP", containerIP,
629 "containerPort", containerPort,629 "containerPort", containerPort,
M internal/httpserve/handlers/endpoint_helpers.go +24 -24
@@ -18,7 +18,7 @@ import (
18 "github.com/bnema/gordon/internal/templating/cmdparams"18 "github.com/bnema/gordon/internal/templating/cmdparams"
19 "github.com/bnema/gordon/pkg/docker"19 "github.com/bnema/gordon/pkg/docker"
20 "github.com/bnema/gordon/pkg/filestore"20 "github.com/bnema/gordon/pkg/filestore"
21- "github.com/charmbracelet/log"21+ "github.com/bnema/gordon/pkg/logger"
22 "github.com/labstack/echo/v4"22 "github.com/labstack/echo/v4"
23 )23 )
24 24
@@ -71,7 +71,7 @@ func (info *InfoResponse) Populate(a *server.App) {
71 func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {71 func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
72 err := c.JSON(statusCode, response)72 err := c.JSON(statusCode, response)
73 if err != nil {73 if err != nil {
74- log.Error("Failed to send JSON response",74+ logger.Error("Failed to send JSON response",
75 "error", err,75 "error", err,
76 "statusCode", statusCode)76 "statusCode", statusCode)
77 }77 }
@@ -152,7 +152,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
152 imageID, err = docker.ImportImageToEngine(imagePath)152 imageID, err = docker.ImportImageToEngine(imagePath)
153 if err != nil {153 if err != nil {
154 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {154 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {
155- log.Error("Failed to remove temporary image file after import failure",155+ logger.Error("Failed to remove temporary image file after import failure",
156 "error", removeErr,156 "error", removeErr,
157 "path", imagePath)157 "path", imagePath)
158 }158 }
@@ -161,7 +161,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
161 161
162 if imageID == "" {162 if imageID == "" {
163 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {163 if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {
164- log.Error("Failed to remove temporary image file after empty ID",164+ logger.Error("Failed to remove temporary image file after empty ID",
165 "error", removeErr,165 "error", removeErr,
166 "path", imagePath)166 "path", imagePath)
167 }167 }
@@ -170,7 +170,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
170 170
171 err = filestore.RemoveFromStorage(imagePath)171 err = filestore.RemoveFromStorage(imagePath)
172 if err != nil {172 if err != nil {
173- log.Error("Failed to remove temporary image file after successful import",173+ logger.Error("Failed to remove temporary image file after successful import",
174 "error", err,174 "error", err,
175 "path", imagePath)175 "path", imagePath)
176 return "", fmt.Errorf("failed to remove temporary image file: %v", err)176 return "", fmt.Errorf("failed to remove temporary image file: %v", err)
@@ -181,7 +181,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
181 181
182 // createAndStartContainer creates a container from the image, starts it and returns the container ID182 // createAndStartContainer creates a container from the image, starts it and returns the container ID
183 func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {183 func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {
184- log.Info("Starting container creation process",184+ logger.Info("Starting container creation process",
185 "imageName", payload.ImageName,185 "imageName", payload.ImageName,
186 "imageID", payload.ImageID,186 "imageID", payload.ImageID,
187 "port", payload.Port)187 "port", payload.Port)
@@ -191,21 +191,21 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
191 return "", "", fmt.Errorf("failed to create command parameters: %v", err)191 return "", "", fmt.Errorf("failed to create command parameters: %v", err)
192 }192 }
193 193
194- log.Debug("Container parameters prepared", "params", params)194+ logger.Debug("Container parameters prepared", "params", params)
195 195
196 containerID, err := docker.CreateContainer(params)196 containerID, err := docker.CreateContainer(params)
197 if err != nil {197 if err != nil {
198 return "", "", fmt.Errorf("failed to create container: %v", err)198 return "", "", fmt.Errorf("failed to create container: %v", err)
199 }199 }
200 200
201- log.Info("Container created successfully", "containerID", containerID)201+ logger.Info("Container created successfully", "containerID", containerID)
202 202
203 containerName, err := docker.GetContainerName(containerID)203 containerName, err := docker.GetContainerName(containerID)
204 if err != nil {204 if err != nil {
205 return "", "", fmt.Errorf("failed to get container name: %v", err)205 return "", "", fmt.Errorf("failed to get container name: %v", err)
206 }206 }
207 207
208- log.Info("Starting container",208+ logger.Info("Starting container",
209 "containerID", containerID,209 "containerID", containerID,
210 "name", containerName)210 "name", containerName)
211 211
@@ -215,7 +215,7 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
215 // Get container logs if start failed215 // Get container logs if start failed
216 logs, logErr := docker.GetContainerLogs(containerID)216 logs, logErr := docker.GetContainerLogs(containerID)
217 if logErr == nil {217 if logErr == nil {
218- log.Error("Container start failed - container logs",218+ logger.Error("Container start failed - container logs",
219 "logs", logs,219 "logs", logs,
220 "containerID", containerID)220 "containerID", containerID)
221 }221 }
@@ -223,14 +223,14 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
223 // Cleanup the failed container223 // Cleanup the failed container
224 cleanupErr := docker.RemoveContainer(containerID)224 cleanupErr := docker.RemoveContainer(containerID)
225 if cleanupErr != nil {225 if cleanupErr != nil {
226- log.Error("Failed to cleanup container after start failure",226+ logger.Error("Failed to cleanup container after start failure",
227 "containerID", containerID,227 "containerID", containerID,
228 "error", cleanupErr)228 "error", cleanupErr)
229 }229 }
230 return "", "", fmt.Errorf("failed to start container: %v", err)230 return "", "", fmt.Errorf("failed to start container: %v", err)
231 }231 }
232 232
233- log.Info("Container started successfully",233+ logger.Info("Container started successfully",
234 "containerID", containerID,234 "containerID", containerID,
235 "name", containerName)235 "name", containerName)
236 236
@@ -292,14 +292,14 @@ func isTransferComplete(transferID string) bool {
292 292
293 metadata := chunkStore.metadata[transferID]293 metadata := chunkStore.metadata[transferID]
294 if metadata == nil {294 if metadata == nil {
295- log.Warn("No metadata found for transfer", "transferID", transferID)295+ logger.Warn("No metadata found for transfer", "transferID", transferID)
296 return false296 return false
297 }297 }
298 298
299 chunksReceived := len(chunkStore.chunks[transferID])299 chunksReceived := len(chunkStore.chunks[transferID])
300 isComplete := chunksReceived == metadata.TotalChunks300 isComplete := chunksReceived == metadata.TotalChunks
301 301
302- log.Debug("Transfer completion check",302+ logger.Debug("Transfer completion check",
303 "transferID", transferID,303 "transferID", transferID,
304 "chunksReceived", chunksReceived,304 "chunksReceived", chunksReceived,
305 "totalExpected", metadata.TotalChunks,305 "totalExpected", metadata.TotalChunks,
@@ -328,7 +328,7 @@ func cleanupOldTransfers() {
328 chunkStore.mu.Lock()328 chunkStore.mu.Lock()
329 for id, startTime := range chunkStore.started {329 for id, startTime := range chunkStore.started {
330 if now.Sub(startTime) > transferTimeout {330 if now.Sub(startTime) > transferTimeout {
331- log.Debug("Cleaning up stale transfer",331+ logger.Debug("Cleaning up stale transfer",
332 "transferID", id,332 "transferID", id,
333 "age", now.Sub(startTime))333 "age", now.Sub(startTime))
334 cleanupTransfer(id)334 cleanupTransfer(id)
@@ -376,7 +376,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
376 totalSize += int64(len(chunk))376 totalSize += int64(len(chunk))
377 }377 }
378 378
379- log.Debug("Starting chunk assembly",379+ logger.Debug("Starting chunk assembly",
380 "totalChunks", totalChunks,380 "totalChunks", totalChunks,
381 "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024)))381 "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024)))
382 382
@@ -435,7 +435,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
435 return fmt.Errorf("failed to reset file pointer: %w", err)435 return fmt.Errorf("failed to reset file pointer: %w", err)
436 }436 }
437 437
438- log.Debug("Chunk assembly completed successfully",438+ logger.Debug("Chunk assembly completed successfully",
439 "totalBytesWritten", written,439 "totalBytesWritten", written,
440 "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024)))440 "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024)))
441 441
@@ -464,7 +464,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
464 // If this is a retry, add an increasing delay464 // If this is a retry, add an increasing delay
465 if retry > 0 {465 if retry > 0 {
466 sleepTime := time.Duration(500*retry) * time.Millisecond466 sleepTime := time.Duration(500*retry) * time.Millisecond
467- log.Debug("Retrying to get container IP",467+ logger.Debug("Retrying to get container IP",
468 "attempt", retry+1,468 "attempt", retry+1,
469 "containerID", containerID,469 "containerID", containerID,
470 "delay", sleepTime)470 "delay", sleepTime)
@@ -486,7 +486,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
486 refreshedInfo, err := docker.GetContainerInfo(containerID)486 refreshedInfo, err := docker.GetContainerInfo(containerID)
487 if err == nil {487 if err == nil {
488 containerInfo = refreshedInfo488 containerInfo = refreshedInfo
489- log.Debug("Refreshed container info for IP retrieval",489+ logger.Debug("Refreshed container info for IP retrieval",
490 "containerName", containerName,490 "containerName", containerName,
491 "containerID", containerID)491 "containerID", containerID)
492 } else {492 } else {
@@ -501,7 +501,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
501 networkName := a.Config.ContainerEngine.Network501 networkName := a.Config.ContainerEngine.Network
502 if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {502 if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
503 containerIP = networkSettings.IPAddress503 containerIP = networkSettings.IPAddress
504- log.Debug("Found container IP",504+ logger.Debug("Found container IP",
505 "containerName", containerName,505 "containerName", containerName,
506 "containerID", containerID,506 "containerID", containerID,
507 "containerIP", containerIP,507 "containerIP", containerIP,
@@ -513,7 +513,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
513 for networkName, networkSettings := range containerInfo.NetworkSettings.Networks {513 for networkName, networkSettings := range containerInfo.NetworkSettings.Networks {
514 if networkSettings.IPAddress != "" {514 if networkSettings.IPAddress != "" {
515 containerIP = networkSettings.IPAddress515 containerIP = networkSettings.IPAddress
516- log.Debug("Found container IP in alternative network",516+ logger.Debug("Found container IP in alternative network",
517 "containerName", containerName,517 "containerName", containerName,
518 "containerID", containerID,518 "containerID", containerID,
519 "containerIP", containerIP,519 "containerIP", containerIP,
@@ -533,7 +533,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
533 // Extract just the IP address from the CIDR format533 // Extract just the IP address from the CIDR format
534 ipCIDR := endpoint.IPv4Address534 ipCIDR := endpoint.IPv4Address
535 containerIP = strings.Split(ipCIDR, "/")[0]535 containerIP = strings.Split(ipCIDR, "/")[0]
536- log.Debug("Found container IP via network inspection",536+ logger.Debug("Found container IP via network inspection",
537 "containerName", containerName,537 "containerName", containerName,
538 "containerID", containerID,538 "containerID", containerID,
539 "containerIP", containerIP,539 "containerIP", containerIP,
@@ -546,12 +546,12 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
546 546
547 // If we've tried multiple times and couldn't get an IP547 // If we've tried multiple times and couldn't get an IP
548 if lastErr != nil {548 if lastErr != nil {
549- log.Warn("Failed to get container info after multiple attempts",549+ logger.Warn("Failed to get container info after multiple attempts",
550 "error", lastErr,550 "error", lastErr,
551 "containerID", containerID)551 "containerID", containerID)
552 }552 }
553 553
554- log.Warn("Could not find container IP, using container name as fallback",554+ logger.Warn("Could not find container IP, using container name as fallback",
555 "containerName", containerName,555 "containerName", containerName,
556 "containerID", containerID)556 "containerID", containerID)
557 557
A internal/httpserve/handlers/error_handlers.go +36 -0
@@ -0,0 +1,36 @@
1+package handlers
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/server"
7+ "github.com/bnema/gordon/internal/templating/models/templ/pages/errors"
8+ "github.com/bnema/gordon/internal/templating/render"
9+ "github.com/labstack/echo/v4"
10+)
11+
12+// RenderNotFoundPage renders a 404 error page using templ
13+func RenderNotFoundPage(c echo.Context, a *server.App) error {
14+ data := errors.ErrorPageData{
15+ Title: "Page Not Found | Gordon",
16+ AdminPath: a.Config.Admin.Path,
17+ StatusCode: http.StatusNotFound,
18+ Message: "Sorry, the page you are looking for does not exist.",
19+ }
20+
21+ renderer := render.NewTemplRenderer(a)
22+ return renderer.RenderTempl(c, errors.NotFoundPage(data))
23+}
24+
25+// RenderForbiddenPage renders a 403 error page using templ
26+func RenderForbiddenPage(c echo.Context, a *server.App) error {
27+ data := errors.ErrorPageData{
28+ Title: "Access Forbidden | Gordon",
29+ AdminPath: a.Config.Admin.Path,
30+ StatusCode: http.StatusForbidden,
31+ Message: "You don't have permission to access this resource.",
32+ }
33+
34+ renderer := render.NewTemplRenderer(a)
35+ return renderer.RenderTempl(c, errors.ForbiddenPage(data))
36+} ⏎̸
A internal/httpserve/handlers/templ_handlers.go +212 -0
@@ -0,0 +1,212 @@
1+package handlers
2+
3+import (
4+ "github.com/bnema/gordon/internal/server"
5+ "github.com/bnema/gordon/internal/templating/models/templ/components"
6+ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
7+ "github.com/bnema/gordon/internal/templating/models/templ/pages/login"
8+ "github.com/bnema/gordon/internal/templating/render"
9+ "github.com/bnema/gordon/pkg/docker"
10+ "github.com/docker/docker/api/types"
11+ "github.com/labstack/echo/v4"
12+ "strings"
13+ "time"
14+ "github.com/bnema/gordon/pkg/humanize"
15+ "net/http"
16+)
17+
18+// RenderTemplLoginPage renders the login page using templ
19+func RenderTemplLoginPage(c echo.Context, a *server.App) error {
20+ // Check for token in query param for backward compatibility
21+ urlToken := c.QueryParam("token")
22+ _ = urlToken // Not using this yet, but kept for compatibility
23+
24+ // Check for error message
25+ errorMsg := c.QueryParam("error")
26+
27+ // Create a templ renderer
28+ renderer := render.NewTemplRenderer(a)
29+
30+ // Render the login page
31+ return renderer.RenderTempl(c, login.LoginPage("Login", a.Config.Build.BuildVersion, errorMsg))
32+}
33+
34+// RenderTemplDashboardPage renders the dashboard page using templ
35+func RenderTemplDashboardPage(c echo.Context, a *server.App) error {
36+ // Create a templ renderer
37+ renderer := render.NewTemplRenderer(a)
38+
39+ // Get user-specific URLs
40+ adminPath := a.Config.Admin.Path
41+ userSettingsURL := adminPath + "/settings"
42+ logoutURL := adminPath + "/logout"
43+
44+ // Render the dashboard page
45+ return renderer.RenderTempl(c, admin.DashboardPage(
46+ "Dashboard",
47+ a.Config.Build.BuildVersion,
48+ adminPath,
49+ userSettingsURL,
50+ logoutURL,
51+ ))
52+}
53+
54+// RenderTemplManagerPage renders the manager page using templ
55+func RenderTemplManagerPage(c echo.Context, a *server.App) error {
56+ // Create a templ renderer
57+ renderer := render.NewTemplRenderer(a)
58+
59+ // Get container and image counts
60+ containerCount, _ := GetContainerCount(a)
61+ imageCount, _ := GetImageCount(a)
62+
63+ // Get user-specific URLs
64+ adminPath := a.Config.Admin.Path
65+ userSettingsURL := adminPath + "/settings"
66+ logoutURL := adminPath + "/logout"
67+
68+ // Render the manager page
69+ return renderer.RenderTempl(c, admin.ManagerPage(
70+ "Container Manager",
71+ a.Config.Build.BuildVersion,
72+ adminPath,
73+ userSettingsURL,
74+ logoutURL,
75+ containerCount,
76+ imageCount,
77+ ))
78+}
79+
80+// RenderTemplContainerList renders the container list component
81+func RenderTemplContainerList(c echo.Context, a *server.App) error {
82+ // Create a templ renderer
83+ renderer := render.NewTemplRenderer(a)
84+
85+ // Get the containers
86+ containers, noContainer, err := GetContainers(a)
87+ if err != nil {
88+ return err
89+ }
90+
91+ // Render the container list component
92+ return renderer.RenderTempl(c, components.ContainerList(containers, noContainer))
93+}
94+
95+// RenderTemplImageList renders the image list component
96+func RenderTemplImageList(c echo.Context, a *server.App) error {
97+ // Create a templ renderer
98+ renderer := render.NewTemplRenderer(a)
99+
100+ // Get the images
101+ images, err := docker.ListContainerImages()
102+ if err != nil {
103+ return err
104+ }
105+
106+ // Convert to human-readable format
107+ var humanReadableImages []components.HumanReadableImage
108+ for _, img := range images {
109+ shortID := img.ID[7:19]
110+
111+ for _, repoTag := range img.RepoTags {
112+ // Split the repo tag into repository and tag
113+ parts := strings.Split(repoTag, ":")
114+ repository := parts[0]
115+ tag := "latest"
116+ if len(parts) > 1 {
117+ tag = parts[1]
118+ }
119+
120+ humanReadableImages = append(humanReadableImages, components.HumanReadableImage{
121+ ID: img.ID,
122+ ShortID: shortID,
123+ Name: repoTag,
124+ Repository: repository,
125+ Tag: tag,
126+ Size: humanize.BytesToReadableSize(img.Size),
127+ Created: humanize.TimeAgo(time.Unix(img.Created, 0)),
128+ RepoDigests: img.RepoDigests,
129+ RepoTags: img.RepoTags,
130+ })
131+ }
132+ }
133+
134+ // Render the image list component
135+ return renderer.RenderTempl(c, components.ImageList(humanReadableImages))
136+}
137+
138+// RenderTemplUploadImageForm renders the upload image form component
139+func RenderTemplUploadImageForm(c echo.Context, a *server.App) error {
140+ // Create a templ renderer
141+ renderer := render.NewTemplRenderer(a)
142+
143+ // Render the upload image form component
144+ return renderer.RenderTempl(c, components.UploadImageForm())
145+}
146+
147+// RenderTemplCreateContainerForm renders the create container form component
148+func RenderTemplCreateContainerForm(c echo.Context, a *server.App) error {
149+ // Create a templ renderer
150+ renderer := render.NewTemplRenderer(a)
151+
152+ // Get the image ID from the URL parameter
153+ shortID := c.Param("ID")
154+
155+ // Try to fetch the image info
156+ imageID, exists := safelyInteractWithIDMap(Fetch, shortID)
157+ if !exists {
158+ return c.String(http.StatusBadRequest, "Invalid image ID")
159+ }
160+
161+ // Get image info
162+ image, err := docker.GetImageInfo(imageID)
163+ if err != nil {
164+ return err
165+ }
166+
167+ // Prepare image name from repo tags
168+ imageName := "unknown"
169+ if len(image.RepoTags) > 0 {
170+ imageName = image.RepoTags[0]
171+ }
172+
173+ // Prepare form data
174+ formData := components.ContainerFormData{
175+ ImageName: imageName,
176+ ImageID: imageID,
177+ ShortID: shortID,
178+ // Default values can be set here if needed
179+ ProxyPort: "80", // Default proxy port
180+ }
181+
182+ // Render the create container form component
183+ return renderer.RenderTempl(c, components.CreateContainerForm(formData))
184+}
185+
186+// GetContainerCount returns the count of containers
187+func GetContainerCount(a *server.App) (int, error) {
188+ containers, err := docker.ListRunningContainers()
189+ if err != nil {
190+ return 0, err
191+ }
192+ return len(containers), nil
193+}
194+
195+// GetImageCount returns the count of images
196+func GetImageCount(a *server.App) (int, error) {
197+ images, err := docker.ListContainerImages()
198+ if err != nil {
199+ return 0, err
200+ }
201+ return len(images), nil
202+}
203+
204+// GetContainers returns the list of containers
205+func GetContainers(a *server.App) ([]types.Container, bool, error) {
206+ containers, err := docker.ListRunningContainers()
207+ if err != nil {
208+ return nil, false, err
209+ }
210+ noContainer := len(containers) == 0
211+ return containers, noContainer, nil
212+}⏎̸
A internal/httpserve/middleware/error_middleware.go +36 -0
@@ -0,0 +1,36 @@
1+package middleware
2+
3+import (
4+ "net/http"
5+
6+ "github.com/bnema/gordon/internal/httpserve/handlers"
7+ "github.com/bnema/gordon/internal/server"
8+ "github.com/labstack/echo/v4"
9+)
10+
11+// CustomHTTPErrorHandler is a custom error handler that uses templ to render error pages
12+func CustomHTTPErrorHandler(a *server.App) echo.HTTPErrorHandler {
13+ return func(err error, c echo.Context) {
14+ code := http.StatusInternalServerError
15+ if he, ok := err.(*echo.HTTPError); ok {
16+ code = he.Code
17+ }
18+
19+ // Handle specific error codes with our templates
20+ switch code {
21+ case http.StatusNotFound:
22+ if err := handlers.RenderNotFoundPage(c, a); err != nil {
23+ c.Logger().Error(err)
24+ }
25+ return
26+ case http.StatusForbidden:
27+ if err := handlers.RenderForbiddenPage(c, a); err != nil {
28+ c.Logger().Error(err)
29+ }
30+ return
31+ default:
32+ // For other errors, use Echo's default error handler
33+ c.Echo().DefaultHTTPErrorHandler(err, c)
34+ }
35+ }
36+} ⏎̸
M internal/httpserve/proxy_init.go +6 -0
@@ -1,6 +1,8 @@
1 package httpserve1 package httpserve
2 2
3 import (3 import (
4+ "time"
5+
4 "github.com/bnema/gordon/internal/proxy"6 "github.com/bnema/gordon/internal/proxy"
5 "github.com/bnema/gordon/internal/server"7 "github.com/bnema/gordon/internal/server"
6 "github.com/charmbracelet/log"8 "github.com/charmbracelet/log"
@@ -21,6 +23,10 @@ func InitializeProxy(a *server.App) (*proxy.Proxy, error) {
21 return nil, err23 return nil, err
22 }24 }
23 25
26+ // Add a small delay to ensure router initialization is complete
27+ // This helps prevent race conditions during startup
28+ time.Sleep(500 * time.Millisecond)
29+
24 log.Info("Reverse proxy initialized and started")30 log.Info("Reverse proxy initialized and started")
25 return p, nil31 return p, nil
26 }32 }
M internal/httpserve/router.go +47 -8
@@ -12,9 +12,13 @@ import (
12 12
13 // RegisterRoutes registers all routes and middlewares13 // RegisterRoutes registers all routes and middlewares
14 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {14 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
15- // Add default Echo logger middleware
16- e.Use(echomid.Logger())
17 e.Use(echomid.Recover())15 e.Use(echomid.Recover())
16+
17+ // Log admin status
18+ log.Info("Admin WebUI status",
19+ "enabled", a.Config.Admin.IsAdminWebUIEnabled(),
20+ "path", a.Config.Admin.Path)
21+
18 // Initiate the session middleware22 // Initiate the session middleware
19 e.Use(middleware.InitSessionMiddleware(a))23 e.Use(middleware.InitSessionMiddleware(a))
20 24
@@ -22,7 +26,10 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
22 e.Use(middleware.LanguageDetection)26 e.Use(middleware.LanguageDetection)
23 27
24 AdminPath := a.Config.Admin.Path28 AdminPath := a.Config.Admin.Path
29+ log.Debug("Binding login routes with AdminPath", "path", AdminPath)
25 bindLoginRoute(e, a, AdminPath)30 bindLoginRoute(e, a, AdminPath)
31+
32+ log.Debug("Binding API endpoints")
26 bindAPIEndpoints(e, a)33 bindAPIEndpoints(e, a)
27 34
28 // SetCommonDataMiddleware will pass data to the renderer35 // SetCommonDataMiddleware will pass data to the renderer
@@ -37,15 +44,29 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
37 // Color scheme detection for dark/light mode44 // Color scheme detection for dark/light mode
38 e.Use(middleware.ColorSchemeDetection)45 e.Use(middleware.ColorSchemeDetection)
39 46
47+ // Set custom error handler
48+ e.HTTPErrorHandler = middleware.CustomHTTPErrorHandler(a)
49+
50+ log.Debug("Binding admin routes with AdminPath", "path", AdminPath)
40 bindAdminRoute(e, a, AdminPath)51 bindAdminRoute(e, a, AdminPath)
52+
53+ log.Debug("Binding static routes")
41 bindStaticRoute(e, a, "/*")54 bindStaticRoute(e, a, "/*")
42- bindHTMXEndpoints(e, a)55+
56+ // Only bind HTMX endpoints if Admin WebUI is enabled
57+ if a.Config.Admin.IsAdminWebUIEnabled() {
58+ log.Info("Admin WebUI is enabled, binding HTMX endpoints")
59+ bindHTMXEndpoints(e, a)
60+ } else {
61+ log.Info("Admin WebUI is disabled, skipping HTMX endpoints")
62+ }
43 63
44 // Protect the root path with a 40364 // Protect the root path with a 403
45 e.GET("/", func(c echo.Context) error {65 e.GET("/", func(c echo.Context) error {
46- return c.String(403, "403 Forbidden")66+ return handlers.RenderForbiddenPage(c, a)
47 })67 })
48 68
69+ log.Info("All routes registered successfully")
49 return e70 return e
50 }71 }
51 72
@@ -101,8 +122,14 @@ func bindStaticRoute(e *echo.Echo, a *server.App, path string) {
101 122
102 // bindLoginRoute binds all login routes123 // bindLoginRoute binds all login routes
103 func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {124 func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
125+ // Skip binding admin login routes if admin webUI is disabled
126+ if !a.Config.Admin.IsAdminWebUIEnabled() {
127+ log.Debug("Admin WebUI is disabled, skipping admin login routes")
128+ return
129+ }
130+
104 e.GET(adminPath+"/login", func(c echo.Context) error {131 e.GET(adminPath+"/login", func(c echo.Context) error {
105- return handlers.RenderLoginPage(c, a)132+ return handlers.RenderTemplLoginPage(c, a)
106 })133 })
107 e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {134 e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {
108 return handlers.HandleTokenSubmission(c, a)135 return handlers.HandleTokenSubmission(c, a)
@@ -120,6 +147,12 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
120 147
121 // bindAdminRoute binds all admin routes148 // bindAdminRoute binds all admin routes
122 func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {149 func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
150+ // Skip binding admin routes if admin webUI is disabled
151+ if !a.Config.Admin.IsAdminWebUIEnabled() {
152+ log.Debug("Admin WebUI is disabled, skipping admin routes")
153+ return
154+ }
155+
123 adminGroup := e.Group(adminPath)156 adminGroup := e.Group(adminPath)
124 // Since login is behind the admin path, we cannot use group middleware157 // Since login is behind the admin path, we cannot use group middleware
125 adminGroup.GET("", func(c echo.Context) error {158 adminGroup.GET("", func(c echo.Context) error {
@@ -127,7 +160,7 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
127 }, middleware.RequireLogin(a))160 }, middleware.RequireLogin(a))
128 161
129 adminGroup.GET("/manager", func(c echo.Context) error {162 adminGroup.GET("/manager", func(c echo.Context) error {
130- return handlers.AdminManagerRoute(c, a)163+ return handlers.RenderTemplManagerPage(c, a)
131 }, middleware.RequireLogin(a))164 }, middleware.RequireLogin(a))
132 adminGroup.GET("/cc/:ID", func(c echo.Context) error {165 adminGroup.GET("/cc/:ID", func(c echo.Context) error {
133 return handlers.CreateContainerFullGET(c, a)166 return handlers.CreateContainerFullGET(c, a)
@@ -146,7 +179,13 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
146 179
147 // bindHTMXEndpoints binds all HTMX endpoints180 // bindHTMXEndpoints binds all HTMX endpoints
148 func bindHTMXEndpoints(e *echo.Echo, a *server.App) {181 func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
149- // Create a group for /htmx endpoints182+ // Skip if AdminWebUI is disabled
183+ if !a.Config.Admin.IsAdminWebUIEnabled() {
184+ log.Debug("Admin WebUI is disabled, skipping HTMX endpoints")
185+ return
186+ }
187+
188+ // Create a group for /htmx endpoints
150 htmxGroup := e.Group("/htmx")189 htmxGroup := e.Group("/htmx")
151 190
152 // Apply middleware to the group, so all endpoints are protected191 // Apply middleware to the group, so all endpoints are protected
@@ -163,7 +202,7 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
163 202
164 // List all containers203 // List all containers
165 htmxGroup.GET("/container-manager", func(c echo.Context) error {204 htmxGroup.GET("/container-manager", func(c echo.Context) error {
166- return handlers.ContainerManagerComponent(c, a)205+ return handlers.RenderTemplContainerList(c, a)
167 })206 })
168 // Stop a container207 // Stop a container
169 htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error {208 htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error {
M internal/proxy/certificates.go +126 -0
@@ -24,8 +24,116 @@ import (
24 "golang.org/x/crypto/acme/autocert"24 "golang.org/x/crypto/acme/autocert"
25 )25 )
26 26
27+// detectUpstreamProxy checks for signs of an upstream TLS-terminating proxy
28+func (p *Proxy) detectUpstreamProxy() bool {
29+ // Check if detection is explicitly disabled
30+ if !p.config.DetectUpstreamProxy {
31+ return false
32+ }
33+
34+ // First check our cached detection status - if we already detected a proxy previously
35+ p.upstreamProxyMu.RLock()
36+ alreadyDetected := p.upstreamProxyDetected
37+ p.upstreamProxyMu.RUnlock()
38+
39+ if alreadyDetected {
40+ return true
41+ }
42+
43+ // Check for known HTTP headers that indicate a TLS termination proxy
44+ // This is a best-effort approach since we're analyzing HTTP headers from
45+ // previous requests stored in our context (if available)
46+
47+ // Look for common proxy headers in our recent requests
48+ // This is a static check at startup; ideally we would check incoming
49+ // requests dynamically, but for this implementation we're using
50+ // known proxy environment indicators
51+
52+ // Most common proxy headers: X-Forwarded-Proto, X-Forwarded-SSL, X-Forwarded-For
53+ // Environment variables: HTTPS=on, HTTP_X_FORWARDED_PROTO=https
54+
55+ // Check for common environment variables set by upstream proxies
56+ if os.Getenv("HTTPS") == "on" ||
57+ os.Getenv("HTTP_X_FORWARDED_PROTO") == "https" ||
58+ os.Getenv("HTTP_X_FORWARDED_SSL") == "on" {
59+ logger.Info("Upstream TLS-terminating proxy detected via environment variables",
60+ "detection_method", "environment_variables",
61+ "https", os.Getenv("HTTPS"),
62+ "x_forwarded_proto", os.Getenv("HTTP_X_FORWARDED_PROTO"),
63+ "x_forwarded_ssl", os.Getenv("HTTP_X_FORWARDED_SSL"))
64+
65+ // Cache this result
66+ p.upstreamProxyMu.Lock()
67+ p.upstreamProxyDetected = true
68+ p.upstreamProxyMu.Unlock()
69+
70+ return true
71+ }
72+
73+ // Check if Cloudflare headers are present in environment
74+ // Cloudflare sets environment variables like HTTP_CF_VISITOR={"scheme":"https"}
75+ if cfVisitor := os.Getenv("HTTP_CF_VISITOR"); cfVisitor != "" {
76+ if strings.Contains(cfVisitor, "\"scheme\":\"https\"") {
77+ logger.Info("Cloudflare proxy detected via environment variables",
78+ "detection_method", "cloudflare_headers",
79+ "cf_visitor", cfVisitor)
80+
81+ // Cache this result
82+ p.upstreamProxyMu.Lock()
83+ p.upstreamProxyDetected = true
84+ p.upstreamProxyMu.Unlock()
85+
86+ return true
87+ }
88+ }
89+
90+ // Not detected, but log if explicit configuration is set
91+ if p.config.SkipCertificates {
92+ logger.Info("No upstream proxy detected automatically, but certificate acquisition will be skipped due to configuration",
93+ "detection_result", "false",
94+ "skip_certificates", "true (forced via config)")
95+ }
96+
97+ return false
98+}
99+
27 // setupCertManager configures the autocert manager for Let's Encrypt100 // setupCertManager configures the autocert manager for Let's Encrypt
28 func (p *Proxy) setupCertManager() {101 func (p *Proxy) setupCertManager() {
102+ // First check for upstream TLS-terminating proxies if detection is enabled
103+ isProxied := p.detectUpstreamProxy()
104+
105+ // Skip certificate acquisition if we're behind a TLS-terminating proxy
106+ // and the skip certificates option is enabled
107+ if isProxied && p.config.SkipCertificates {
108+ logger.Info("Upstream TLS-terminating proxy detected, skipping Let's Encrypt certificate acquisition",
109+ "upstream_proxy", "detected",
110+ "certificate_acquisition", "disabled")
111+ // We still setup a fallback cert for the admin domain
112+ adminDomain := p.app.GetConfig().Http.FullDomain()
113+ rootDomain := p.app.GetConfig().Http.Domain
114+
115+ var fallbackDomains []string
116+ fallbackDomains = append(fallbackDomains, adminDomain)
117+
118+ // Include root domain in fallback certificate if it's different from admin domain
119+ if rootDomain != "" && rootDomain != adminDomain {
120+ fallbackDomains = append(fallbackDomains, rootDomain)
121+ }
122+
123+ fallbackCert, err := generateFallbackCertificates(fallbackDomains)
124+ if err != nil {
125+ logger.Warn("Failed to generate fallback certificate", "error", err)
126+ } else {
127+ logger.Info("Generated fallback self-signed certificate for admin domain behind proxy",
128+ "domain", adminDomain,
129+ "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05"))
130+ // Store the fallback certificate
131+ p.fallbackCert = fallbackCert
132+ }
133+
134+ return
135+ }
136+
29 // Create a cache directory if it doesn't exist137 // Create a cache directory if it doesn't exist
30 dir := p.config.CertDir138 dir := p.config.CertDir
31 if dir == "" {139 if dir == "" {
@@ -384,6 +492,15 @@ func (p *Proxy) requestAdminCertificate() {
384 logger.Debug("HTTPS is disabled, skipping admin certificate request")492 logger.Debug("HTTPS is disabled, skipping admin certificate request")
385 return493 return
386 }494 }
495+
496+ // Check for upstream proxy with certificate skipping enabled
497+ isProxied := p.detectUpstreamProxy()
498+ if isProxied && p.config.SkipCertificates {
499+ logger.Info("Upstream TLS-terminating proxy detected, skipping admin certificate request",
500+ "domain", adminDomain,
501+ "skip_reason", "behind TLS-terminating proxy")
502+ return
503+ }
387 504
388 // Extract hostname from admin domain, resolving it to see if it's publicly accessible505 // Extract hostname from admin domain, resolving it to see if it's publicly accessible
389 host := adminDomain506 host := adminDomain
@@ -539,6 +656,15 @@ func (p *Proxy) requestDomainCertificate(domain string) {
539 logger.Warn("Empty domain provided to requestDomainCertificate")656 logger.Warn("Empty domain provided to requestDomainCertificate")
540 return657 return
541 }658 }
659+
660+ // Check for upstream proxy with certificate skipping enabled
661+ isProxied := p.detectUpstreamProxy()
662+ if isProxied && p.config.SkipCertificates {
663+ logger.Info("Upstream TLS-terminating proxy detected, skipping domain certificate request",
664+ "domain", domain,
665+ "skip_reason", "behind TLS-terminating proxy")
666+ return
667+ }
542 668
543 // Extract hostname from domain, resolving it to see if it's publicly accessible669 // Extract hostname from domain, resolving it to see if it's publicly accessible
544 ips, err := net.LookupIP(domain)670 ips, err := net.LookupIP(domain)
M internal/proxy/containers.go +7 -5
@@ -1,7 +1,6 @@
1 package proxy1 package proxy
2 2
3 import (3 import (
4- "fmt"
5 "os"4 "os"
6 "strings"5 "strings"
7 6
@@ -12,11 +11,11 @@ import (
12 // scanContainersAndCheckCertificates scans all containers from the Gordon network11 // scanContainersAndCheckCertificates scans all containers from the Gordon network
13 // and checks if we have certificates for their domains and subdomains12 // and checks if we have certificates for their domains and subdomains
14 func (p *Proxy) scanContainersAndCheckCertificates() error {13 func (p *Proxy) scanContainersAndCheckCertificates() error {
15- // Get the network name from app config
16 networkName := p.app.GetConfig().ContainerEngine.Network14 networkName := p.app.GetConfig().ContainerEngine.Network
17-
18 if networkName == "" {15 if networkName == "" {
19- return fmt.Errorf("container network name is not configured")16+ // If no network is defined, skip container scanning
17+ logger.Info("No container network defined, skipping certificate checks")
18+ return nil
20 }19 }
21 20
22 logger.Info("Scanning containers in network for certificate checks",21 logger.Info("Scanning containers in network for certificate checks",
@@ -25,7 +24,10 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
25 // Try to get network info using the docker client24 // Try to get network info using the docker client
26 networkInfo, err := docker.GetNetworkInfo(networkName)25 networkInfo, err := docker.GetNetworkInfo(networkName)
27 if err != nil {26 if err != nil {
28- return fmt.Errorf("failed to get network info: %w", err)27+ logger.Warn("Failed to get network info, skipping certificate checks",
28+ "error", err,
29+ "network", networkName)
30+ return nil // Return nil instead of error to continue execution
29 }31 }
30 32
31 // Skip admin cert check if environment variable is set33 // Skip admin cert check if environment variable is set
M internal/proxy/core.go +34 -6
@@ -22,7 +22,6 @@ import (
22 // - certificates.go - Certificate management functions22 // - certificates.go - Certificate management functions
23 // - routes.go - Route management functions23 // - routes.go - Route management functions
24 // - middleware.go - HTTP middleware functions24 // - middleware.go - HTTP middleware functions
25-// - blacklist.go - IP blacklisting functionality
26 // - server.go - Server startup and shutdown25 // - server.go - Server startup and shutdown
27 // - utils.go - Utility functions26 // - utils.go - Utility functions
28 27
@@ -58,6 +57,10 @@ type Proxy struct {
58 recentContainersMu sync.RWMutex57 recentContainersMu sync.RWMutex
59 containerCooldownPeriod time.Duration58 containerCooldownPeriod time.Duration
60 59
60+ // Upstream proxy detection
61+ upstreamProxyDetected bool
62+ upstreamProxyMu sync.RWMutex
63+
61 // SQL queries64 // SQL queries
62 queries *queries.ProxyQueries65 queries *queries.ProxyQueries
63 }66 }
@@ -66,8 +69,36 @@ type Proxy struct {
66 func NewProxy(app interfaces.AppInterface) (*Proxy, error) {69 func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
67 logger.Debug("Initializing reverse proxy")70 logger.Debug("Initializing reverse proxy")
68 71
69- // Log important configuration information72+ // Set up the config
70- logger.Debug("All internal connections to containers will use HTTP protocol regardless of external protocol")73+ config := app.GetConfig().ReverseProxy
74+
75+ // Check if proxy is disabled through configuration
76+ if !config.Enabled {
77+ logger.Info("Reverse proxy is disabled via configuration")
78+ // Return a non-active proxy instance
79+ return &Proxy{
80+ config: config,
81+ app: app,
82+ serverStarted: false,
83+ shutdown: make(chan struct{}),
84+ }, nil
85+ }
86+
87+ // Check if running inside a container
88+ isContainer := docker.IsRunningInContainer()
89+ logger.Debug("Container environment check", "is_running_in_container", isContainer)
90+
91+ if !isContainer {
92+ logger.Info("Gordon is not running in a container, proxy will be disabled automatically")
93+ // Override the enabled setting to false
94+ config.Enabled = false
95+ return &Proxy{
96+ config: config,
97+ app: app,
98+ serverStarted: false,
99+ shutdown: make(chan struct{}),
100+ }, nil
101+ }
71 102
72 // Set up the echo server for HTTPS traffic103 // Set up the echo server for HTTPS traffic
73 httpsServer := echo.New()104 httpsServer := echo.New()
@@ -77,9 +108,6 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
77 httpServer := echo.New()108 httpServer := echo.New()
78 httpServer.HideBanner = true109 httpServer.HideBanner = true
79 110
80- // Set up the config
81- config := app.GetConfig().ReverseProxy
82-
83 // Detect and store our container ID (if we're running in a container)111 // Detect and store our container ID (if we're running in a container)
84 var ourContainerID string112 var ourContainerID string
85 if docker.IsRunningInContainer() {113 if docker.IsRunningInContainer() {
M internal/proxy/middleware.go +90 -8
@@ -2,6 +2,7 @@ package proxy
2 2
3 import (3 import (
4 "net/http"4 "net/http"
5+ "strings"
5 "time"6 "time"
6 7
7 "github.com/bnema/gordon/pkg/logger"8 "github.com/bnema/gordon/pkg/logger"
@@ -30,8 +31,8 @@ func (p *Proxy) setupMiddleware() {
30 p.httpsServer.Use(p.createRequestIDMiddleware())31 p.httpsServer.Use(p.createRequestIDMiddleware())
31 p.httpServer.Use(p.createRequestIDMiddleware())32 p.httpServer.Use(p.createRequestIDMiddleware())
32 33
33- // Check if rate limiting is disabled via configuration34+ // Check if rate limiting is enabled via configuration
34- if !p.config.DisableRateLimit {35+ if p.config.EnableRateLimit {
35 // Create a new Starskey rate limiter store for HTTPS server36 // Create a new Starskey rate limiter store for HTTPS server
36 httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(37 httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(
37 "./data/ratelimiter/https", // Path to store rate limit data38 "./data/ratelimiter/https", // Path to store rate limit data
@@ -98,12 +99,12 @@ func (p *Proxy) setupMiddleware() {
98 }99 }
99 100
100 // Add custom logger middleware with request ID if enabled in config101 // Add custom logger middleware with request ID if enabled in config
101- if p.config.EnableLogs {102+ if p.config.EnableHttpLogs {
102 p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{103 p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
103 Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",104 Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
104 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",105 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
105 Skipper: func(c echo.Context) bool {106 Skipper: func(c echo.Context) bool {
106- return !p.config.EnableLogs107+ return !p.config.EnableHttpLogs
107 },108 },
108 }))109 }))
109 logger.Debug("HTTP request logging enabled for HTTPS server")110 logger.Debug("HTTP request logging enabled for HTTPS server")
@@ -111,8 +112,8 @@ func (p *Proxy) setupMiddleware() {
111 logger.Debug("HTTP request logging disabled for HTTPS server")112 logger.Debug("HTTP request logging disabled for HTTPS server")
112 }113 }
113 114
114- // Check if rate limiting is disabled via configuration115+ // Check if rate limiting is enabled via configuration
115- if !p.config.DisableRateLimit {116+ if p.config.EnableRateLimit {
116 // Create a new Starskey rate limiter store for HTTP server117 // Create a new Starskey rate limiter store for HTTP server
117 httpRateLimiter, err := kv.NewStarskeyRateLimiterStore(118 httpRateLimiter, err := kv.NewStarskeyRateLimiterStore(
118 "./data/ratelimiter/http", // Path to store rate limit data119 "./data/ratelimiter/http", // Path to store rate limit data
@@ -179,12 +180,12 @@ func (p *Proxy) setupMiddleware() {
179 }180 }
180 181
181 // Add custom logger middleware with request ID for HTTP server if enabled in config182 // Add custom logger middleware with request ID for HTTP server if enabled in config
182- if p.config.EnableLogs {183+ if p.config.EnableHttpLogs {
183 p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{184 p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
184 Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",185 Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
185 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",186 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
186 Skipper: func(c echo.Context) bool {187 Skipper: func(c echo.Context) bool {
187- return !p.config.EnableLogs188+ return !p.config.EnableHttpLogs
188 },189 },
189 }))190 }))
190 logger.Debug("HTTP request logging enabled for HTTP server")191 logger.Debug("HTTP request logging enabled for HTTP server")
@@ -206,8 +207,89 @@ func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc {
206 // Add it as a response header207 // Add it as a response header
207 c.Response().Header().Set(echo.HeaderXRequestID, requestID)208 c.Response().Header().Set(echo.HeaderXRequestID, requestID)
208 209
210+ // If proxy detection is enabled, check for upstream proxy headers
211+ if p.config.DetectUpstreamProxy {
212+ p.detectUpstreamProxyFromRequest(c)
213+ }
214+
209 // Continue processing215 // Continue processing
210 return next(c)216 return next(c)
211 }217 }
212 }218 }
213 }219 }
220+
221+// detectUpstreamProxyFromRequest checks for headers that indicate TLS termination upstream
222+// This allows real-time detection as requests come in
223+func (p *Proxy) detectUpstreamProxyFromRequest(c echo.Context) {
224+ // Only check if detection is enabled
225+ if !p.config.DetectUpstreamProxy {
226+ return
227+ }
228+
229+ // First check our cached detection status
230+ p.upstreamProxyMu.RLock()
231+ alreadyDetected := p.upstreamProxyDetected
232+ p.upstreamProxyMu.RUnlock()
233+
234+ // Skip if we already detected a proxy previously
235+ if alreadyDetected {
236+ return
237+ }
238+
239+ req := c.Request()
240+ headers := req.Header
241+ proxyDetected := false
242+ detectionMethod := ""
243+ headerName := ""
244+ headerValue := ""
245+
246+ // Check for common TLS termination proxy headers
247+ // X-Forwarded-Proto: https indicates an upstream proxy terminated TLS
248+ if proto := headers.Get("X-Forwarded-Proto"); proto == "https" {
249+ proxyDetected = true
250+ detectionMethod = "request_header"
251+ headerName = "X-Forwarded-Proto"
252+ headerValue = proto
253+ }
254+
255+ // X-Forwarded-SSL: on indicates an upstream proxy terminated TLS
256+ if !proxyDetected {
257+ if ssl := headers.Get("X-Forwarded-SSL"); ssl == "on" {
258+ proxyDetected = true
259+ detectionMethod = "request_header"
260+ headerName = "X-Forwarded-SSL"
261+ headerValue = ssl
262+ }
263+ }
264+
265+ // Cloudflare specific headers
266+ if !proxyDetected {
267+ if cf := headers.Get("CF-Visitor"); cf != "" && strings.Contains(cf, "\"scheme\":\"https\"") {
268+ proxyDetected = true
269+ detectionMethod = "cloudflare_header"
270+ headerName = "CF-Visitor"
271+ headerValue = cf
272+ }
273+ }
274+
275+ // If we detected a proxy, store the detection and log it
276+ if proxyDetected {
277+ // Store the detection in our state with lock protection
278+ p.upstreamProxyMu.Lock()
279+ p.upstreamProxyDetected = true
280+ p.upstreamProxyMu.Unlock()
281+
282+ logger.Info("Upstream TLS-terminating proxy detected from request headers",
283+ "detection_method", detectionMethod,
284+ "header", headerName,
285+ "value", headerValue,
286+ "host", req.Host)
287+
288+ // If certificate skipping is enabled, log it
289+ if p.config.SkipCertificates {
290+ logger.Info("Upstream TLS-terminating proxy detected during request, future certificate requests will be skipped",
291+ "host", req.Host,
292+ "certificate_acquisition", "will be skipped for new domains")
293+ }
294+ }
295+}
M internal/proxy/routes.go +105 -732
@@ -3,15 +3,12 @@ package proxy
3 import (3 import (
4 "database/sql"4 "database/sql"
5 "fmt"5 "fmt"
6- "net"
7- "net/http"
8- "net/http/httputil"
9 "net/url"6 "net/url"
10 "os"7 "os"
11 "strings"8 "strings"
12 "time"9 "time"
13 10
14- "github.com/bnema/gordon/pkg/docker"11+ docker "github.com/bnema/gordon/pkg/docker"
15 "github.com/bnema/gordon/pkg/logger"12 "github.com/bnema/gordon/pkg/logger"
16 "github.com/labstack/echo/v4"13 "github.com/labstack/echo/v4"
17 )14 )
@@ -42,12 +39,7 @@ func (p *Proxy) loadRoutes() error {
42 }39 }
43 40
44 // Save the admin domain route if it exists41 // Save the admin domain route if it exists
45- adminDomain := p.app.GetConfig().Http.FullDomain()42+ adminRoute := p.saveAdminRoute()
46- var adminRoute *ProxyRouteInfo
47- if route, exists := p.routes[adminDomain]; exists {
48- adminRoute = route
49- logger.Debug("Preserving admin domain route", "domain", adminDomain)
50- }
51 43
52 // Query the database for active proxy routes using retry mechanism44 // Query the database for active proxy routes using retry mechanism
53 rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes)45 rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes)
@@ -58,8 +50,8 @@ func (p *Proxy) loadRoutes() error {
58 // Restore admin route if it was saved50 // Restore admin route if it was saved
59 if adminRoute != nil {51 if adminRoute != nil {
60 p.routes = make(map[string]*ProxyRouteInfo)52 p.routes = make(map[string]*ProxyRouteInfo)
61- p.routes[adminDomain] = adminRoute53+ p.restoreAdminRoute(adminRoute, p.routes)
62- logger.Debug("Restored admin domain route after empty database query", "domain", adminDomain)54+ logger.Debug("Restored admin domain route after empty database query", "domain", p.app.GetConfig().Http.FullDomain())
63 }55 }
64 56
65 return nil57 return nil
@@ -72,10 +64,7 @@ func (p *Proxy) loadRoutes() error {
72 p.routes = make(map[string]*ProxyRouteInfo)64 p.routes = make(map[string]*ProxyRouteInfo)
73 65
74 // Restore admin route if it was saved66 // Restore admin route if it was saved
75- if adminRoute != nil {67+ p.restoreAdminRoute(adminRoute, p.routes)
76- p.routes[adminDomain] = adminRoute
77- logger.Debug("Restored admin domain route after clearing routes map", "domain", adminDomain)
78- }
79 68
80 // Populate the routes map69 // Populate the routes map
81 for rows.Next() {70 for rows.Next() {
@@ -85,19 +74,11 @@ func (p *Proxy) loadRoutes() error {
85 return fmt.Errorf("failed to scan row: %w", err)74 return fmt.Errorf("failed to scan row: %w", err)
86 }75 }
87 76
88- // Normalize domain (lowercase, no trailing dot)77+ // Normalize domain
89- domain = strings.TrimSuffix(strings.ToLower(domain), ".")78+ domain = normalizeDomain(domain)
90 79
91 // Add the route to the map80 // Add the route to the map
92- p.routes[domain] = &ProxyRouteInfo{81+ p.routes[domain] = createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path, active)
93- Domain: domain,
94- ContainerID: containerID,
95- ContainerIP: containerIP,
96- ContainerPort: containerPort,
97- Protocol: protocol,
98- Path: path,
99- Active: active,
100- }
101 82
102 logger.Debug("Loaded proxy route",83 logger.Debug("Loaded proxy route",
103 "domain", domain,84 "domain", domain,
@@ -112,114 +93,29 @@ func (p *Proxy) loadRoutes() error {
112 93
113 // configureRoutes sets up the HTTP and HTTPS routes94 // configureRoutes sets up the HTTP and HTTPS routes
114 func (p *Proxy) configureRoutes() {95 func (p *Proxy) configureRoutes() {
96+ // Ensure only one goroutine can configure routes at a time
97+ p.mu.Lock()
98+ defer p.mu.Unlock()
99+
115 // Get the admin domain from the config100 // Get the admin domain from the config
116 adminDomain := p.app.GetConfig().Http.FullDomain()101 adminDomain := p.app.GetConfig().Http.FullDomain()
117 logger.Debug("Configuring proxy routes", "admin_domain", adminDomain)102 logger.Debug("Configuring proxy routes", "admin_domain", adminDomain)
118 103
119 // Verify admin domain is in routes - if not, we need to add it104 // Verify admin domain is in routes - if not, we need to add it
120- p.mu.RLock()
121 _, adminRouteExists := p.routes[adminDomain]105 _, adminRouteExists := p.routes[adminDomain]
122- p.mu.RUnlock()
123 106
124 if !adminRouteExists {107 if !adminRouteExists {
125- logger.Warn("Admin domain route is missing, attempting to recreate it")108+ // Create new admin route
126- p.mu.Lock()109+ p.routes[adminDomain] = p.createAdminRoute()
127- // Auto-detect the Gordon container name
128- containerName := p.detectGordonContainer()
129-
130- // In a container environment, we need to use the host container IP
131- // instead of 127.0.0.1 because each container has its own localhost
132- containerIP := "localhost" // Default to localhost for most reliable connectivity
133-
134- // Fall back options for container IP
135- if os.Getenv("GORDON_ADMIN_HOST") != "" {
136- // Allow explicit configuration via env var
137- containerIP = os.Getenv("GORDON_ADMIN_HOST")
138- } else if os.Getenv("HOSTNAME") != "" {
139- // Use container's own hostname as they're on the same network
140- containerIP = os.Getenv("HOSTNAME")
141- }
142-
143- p.routes[adminDomain] = &ProxyRouteInfo{
144- Domain: adminDomain,
145- ContainerIP: containerIP,
146- ContainerPort: p.app.GetConfig().Http.Port,
147- ContainerID: containerName,
148- Protocol: "http", // Gordon server uses HTTP internally
149- Path: "/",
150- Active: true,
151- }
152 logger.Info("Recreated admin domain route",110 logger.Info("Recreated admin domain route",
153 "domain", adminDomain,111 "domain", adminDomain,
154- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))112+ "target", fmt.Sprintf("http://%s:%s", p.routes[adminDomain].ContainerIP, p.routes[adminDomain].ContainerPort))
155- p.mu.Unlock()
156 } else {113 } else {
157 logger.Debug("Admin domain route exists", "domain", adminDomain)114 logger.Debug("Admin domain route exists", "domain", adminDomain)
158 }115 }
159 116
160- // Handler function - will be assigned to both HTTP and HTTPS servers117+ // Create handler function for both HTTP and HTTPS servers
161- handler := echo.HandlerFunc(func(c echo.Context) error {118+ handler := p.createProxyHandler()
162- host := c.Request().Host
163-
164- // Strip port if present in the host
165- if hostParts := strings.Split(host, ":"); len(hostParts) > 1 {
166- host = hostParts[0]
167- }
168-
169- // Normalize host (trim trailing dot, convert to lowercase)
170- host = strings.TrimSuffix(strings.ToLower(host), ".")
171-
172- // Check if this is the admin domain
173- adminDomain := p.app.GetConfig().Http.FullDomain()
174- if strings.EqualFold(host, adminDomain) {
175- // Always prioritize admin domain
176- p.mu.RLock()
177- adminRoute, adminOk := p.routes[adminDomain]
178- p.mu.RUnlock()
179-
180- if adminOk {
181- return p.proxyRequest(c, adminRoute)
182- }
183- }
184-
185- // Important: Get the route freshly from the map for every request
186- // This ensures we always use the most up-to-date IP
187- p.mu.RLock()
188- route, ok := p.routes[host]
189- p.mu.RUnlock()
190-
191- if !ok {
192- // Check if the host is an IP address - silently handle without logging warnings
193- if net.ParseIP(host) != nil {
194- // For IP-based requests, just return a 404 without logging warnings
195- return c.String(http.StatusNotFound, "Domain not found")
196- }
197-
198- // Create list of available domains for debugging
199- availableDomains := make([]string, 0, len(p.routes))
200- p.mu.RLock()
201- for d := range p.routes {
202- availableDomains = append(availableDomains, d)
203- }
204- p.mu.RUnlock()
205-
206- // Log warning for non-IP hosts that aren't configured
207- logger.Warn("Request with unknown host",
208- "requested_host", host,
209- "client_ip", c.RealIP(),
210- "available_domains", strings.Join(availableDomains, ", "))
211- return c.String(http.StatusNotFound, "Domain not found")
212- }
213-
214- // Check if the route is active
215- if !route.Active {
216- logger.Warn("Route is not active",
217- "domain", host,
218- "client_ip", c.RealIP())
219- return c.String(http.StatusServiceUnavailable, "Route is not active")
220- }
221- return p.proxyRequest(c, route)
222- })
223 119
224 // Add the handler to the HTTPS server120 // Add the handler to the HTTPS server
225 if p.httpsServer != nil {121 if p.httpsServer != nil {
@@ -237,8 +133,8 @@ func (p *Proxy) configureRoutes() {
237 // Note: Regardless of the external protocol, all internal connections to containers use HTTP.133 // Note: Regardless of the external protocol, all internal connections to containers use HTTP.
238 // The HTTPS protocol is only used for external connections and certificates.134 // The HTTPS protocol is only used for external connections and certificates.
239 func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error {135 func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error {
240- // Normalize domain name (lowercase, no trailing dot)136+ // Normalize domain name
241- domainName = strings.TrimSuffix(strings.ToLower(domainName), ".")137+ domainName = normalizeDomain(domainName)
242 138
243 logger.Debug("Adding proxy route",139 logger.Debug("Adding proxy route",
244 "domain", domainName,140 "domain", domainName,
@@ -256,15 +152,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
256 152
257 // Add the route to memory immediately for better UX153 // Add the route to memory immediately for better UX
258 p.mu.Lock()154 p.mu.Lock()
259- p.routes[domainName] = &ProxyRouteInfo{155+ p.routes[domainName] = createRouteInfo(domainName, containerID, containerIP, containerPort, protocol, path, true)
260- Domain: domainName,
261- ContainerID: containerID,
262- ContainerIP: containerIP,
263- ContainerPort: containerPort,
264- Protocol: protocol,
265- Path: path,
266- Active: true,
267- }
268 p.mu.Unlock()156 p.mu.Unlock()
269 157
270 // Return success - the event listener will handle the database update158 // Return success - the event listener will handle the database update
@@ -324,37 +212,8 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
324 logger.Debug("Added domain to database for Let's Encrypt",212 logger.Debug("Added domain to database for Let's Encrypt",
325 "domain", hostname)213 "domain", hostname)
326 214
327- // Also add the subdomain.domain format to the database if this is a domain with components215+ // Process subdomain if needed
328- hostParts := strings.Split(hostname, ".")216+ p.processNewSubdomain(tx, hostname, accountID, now)
329- if len(hostParts) >= 3 {
330- // This appears to be in format subdomain.domain.tld
331- // Extract subdomain and domain parts
332- subdomain := hostParts[0]
333- domainPart := strings.Join(hostParts[1:], ".")
334-
335- // Check if the domain part already exists
336- var domainPartID string
337- err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainPart).Scan(&domainPartID)
338- if err != nil {
339- if err == sql.ErrNoRows {
340- // Domain part doesn't exist, create it
341- domainPartID = proxyGenerateUUID()
342- _, err = txExecWithRetry(tx,
343- p.queries.InsertDomain,
344- domainPartID, domainPart, accountID, now, now,
345- )
346- if err != nil {
347- logger.Warn("Failed to insert domain part, continuing anyway",
348- "domain_part", domainPart,
349- "error", err)
350- } else {
351- logger.Debug("Added domain part to database for Let's Encrypt",
352- "domain_part", domainPart,
353- "subdomain", subdomain)
354- }
355- }
356- }
357- }
358 } else {217 } else {
359 logger.Error("Failed to query domain",218 logger.Error("Failed to query domain",
360 "domain", hostname,219 "domain", hostname,
@@ -439,27 +298,23 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
439 // Register this container to prevent duplicate processing298 // Register this container to prevent duplicate processing
440 p.RegisterNewlyCreatedContainer(containerID)299 p.RegisterNewlyCreatedContainer(containerID)
441 300
442- // Update the in-memory routes map immediately for better UX301+ // Lock to update the in-memory routes map and reload routes
443 p.mu.Lock()302 p.mu.Lock()
444- p.routes[hostname] = &ProxyRouteInfo{303+
445- Domain: hostname,304+ // Update the in-memory routes map
446- ContainerID: containerID,305+ hostname = domainName
447- ContainerIP: containerIP,306+ p.routes[hostname] = createRouteInfo(hostname, containerID, containerIP, containerPort, protocol, path, true)
448- ContainerPort: containerPort,307+
449- Protocol: protocol,308+ // Reload the routes directly while holding the lock
450- Path: path,309+ // This avoids the race condition from concurrent reload operations
451- Active: true,310+ if err := p.Reload(); err != nil {
311+ logger.Error("Failed to reload routes after adding route",
312+ "domain", hostname,
313+ "error", err)
452 }314 }
453- p.mu.Unlock()
454 315
455- // Reload the routes in the background to avoid blocking316+ // Unlock before continuing with certificate operations
456- go func() {317+ p.mu.Unlock()
457- if err := p.Reload(); err != nil {
458- logger.Error("Failed to reload routes after adding route",
459- "domain", hostname,
460- "error", err)
461- }
462- }()
463 318
464 logger.Info("Added proxy route",319 logger.Info("Added proxy route",
465 "domain", hostname,320 "domain", hostname,
@@ -488,10 +343,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
488 // RemoveRoute removes a route from the database and reloads the proxy343 // RemoveRoute removes a route from the database and reloads the proxy
489 func (p *Proxy) RemoveRoute(domainName string) error {344 func (p *Proxy) RemoveRoute(domainName string) error {
490 // Prevent removing the admin domain345 // Prevent removing the admin domain
491- adminDomain := p.app.GetConfig().Http.FullDomain()346+ if err := p.checkAdminDomainProtection(domainName); err != nil {
492- if domainName == adminDomain {
493- logger.Warn("Attempt to remove admin domain route prevented",
494- "domain", domainName)
495 return fmt.Errorf("cannot remove admin domain route: %s", domainName)347 return fmt.Errorf("cannot remove admin domain route: %s", domainName)
496 }348 }
497 349
@@ -512,16 +364,9 @@ func (p *Proxy) RemoveRoute(domainName string) error {
512 return fmt.Errorf("failed to query domain: %w", err)364 return fmt.Errorf("failed to query domain: %w", err)
513 }365 }
514 366
515- // Delete the route with retry367+ // Delete the route and domain from the database
516- _, err = txExecWithRetry(tx, p.queries.DeleteRouteByDomainID, domainID)368+ if err := p.deleteRouteWithTransaction(tx, domainID); err != nil {
517- if err != nil {369+ return err
518- return fmt.Errorf("failed to delete route: %w", err)
519- }
520-
521- // Delete the domain with retry
522- _, err = txExecWithRetry(tx, p.queries.DeleteDomainByID, domainID)
523- if err != nil {
524- return fmt.Errorf("failed to delete domain: %w", err)
525 }370 }
526 371
527 // Commit the transaction372 // Commit the transaction
@@ -556,27 +401,17 @@ func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo {
556 func (p *Proxy) Reload() error {401 func (p *Proxy) Reload() error {
557 logger.Info("Reloading proxy routes from database")402 logger.Info("Reloading proxy routes from database")
558 403
559- // Get admin domain - this should be the full subdomain.domain format404+ // Get admin domain
560 adminDomain := p.app.GetConfig().Http.FullDomain()405 adminDomain := p.app.GetConfig().Http.FullDomain()
561- // Normalize the admin domain (lowercase, no trailing dot)406+ // Normalize the admin domain
562- adminDomain = strings.TrimSuffix(strings.ToLower(adminDomain), ".")407+ adminDomain = normalizeDomain(adminDomain)
408+
409+ // Lock for the entire reload operation to prevent concurrent modifications
410+ p.mu.Lock()
411+ defer p.mu.Unlock()
563 412
564 // Save existing admin route if it exists413 // Save existing admin route if it exists
565- p.mu.RLock()414+ adminRoute := p.saveAdminRoute()
566- var adminRoute *ProxyRouteInfo
567- if route, exists := p.routes[adminDomain]; exists {
568- adminRoute = &ProxyRouteInfo{
569- Domain: route.Domain,
570- ContainerID: route.ContainerID,
571- ContainerIP: route.ContainerIP,
572- ContainerPort: route.ContainerPort,
573- Protocol: route.Protocol,
574- Path: route.Path,
575- Active: route.Active,
576- }
577- logger.Debug("Preserved admin domain route during reload", "domain", adminDomain)
578- }
579- p.mu.RUnlock()
580 415
581 // Create a new route map instead of modifying the existing one416 // Create a new route map instead of modifying the existing one
582 // This ensures a clean reload without any potential race conditions417 // This ensures a clean reload without any potential race conditions
@@ -602,19 +437,11 @@ func (p *Proxy) Reload() error {
602 return fmt.Errorf("failed to scan row during reload: %w", err)437 return fmt.Errorf("failed to scan row during reload: %w", err)
603 }438 }
604 439
605- // Normalize domain (lowercase, no trailing dot)440+ // Normalize domain
606- domain = strings.TrimSuffix(strings.ToLower(domain), ".")441+ domain = normalizeDomain(domain)
607 442
608 // Add the route to the new map443 // Add the route to the new map
609- newRoutes[domain] = &ProxyRouteInfo{444+ newRoutes[domain] = createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path, active)
610- Domain: domain,
611- ContainerID: containerID,
612- ContainerIP: containerIP,
613- ContainerPort: containerPort,
614- Protocol: protocol,
615- Path: path,
616- Active: active,
617- }
618 445
619 logger.Debug("Loaded route during reload",446 logger.Debug("Loaded route during reload",
620 "domain", domain,447 "domain", domain,
@@ -654,38 +481,18 @@ func (p *Proxy) Reload() error {
654 481
655 // If admin domain is still missing, recreate it482 // If admin domain is still missing, recreate it
656 if _, exists := newRoutes[adminDomain]; !exists {483 if _, exists := newRoutes[adminDomain]; !exists {
657- logger.Warn("Admin domain route is missing, recreating it", "domain", adminDomain)484+ // Create a new admin route
658-485+ adminRoute := p.createAdminRoute()
659- // Auto-detect the Gordon container information486+ newRoutes[adminDomain] = adminRoute
660- containerName := p.detectGordonContainer()
661- containerIP := "localhost" // Default to localhost
662-
663- // Fall back options for container IP
664- if os.Getenv("GORDON_ADMIN_HOST") != "" {
665- containerIP = os.Getenv("GORDON_ADMIN_HOST")
666- } else if os.Getenv("HOSTNAME") != "" {
667- containerIP = os.Getenv("HOSTNAME")
668- }
669-
670- // Add to in-memory routes
671- newRoutes[adminDomain] = &ProxyRouteInfo{
672- Domain: adminDomain,
673- ContainerID: containerName,
674- ContainerIP: containerIP,
675- ContainerPort: p.app.GetConfig().Http.Port,
676- Protocol: "http", // Gordon server uses HTTP internally
677- Path: "/",
678- Active: true,
679- }
680 487
681 // Add to database488 // Add to database
682 err := p.AddRoute(489 err := p.AddRoute(
683 adminDomain,490 adminDomain,
684- containerName,491+ adminRoute.ContainerID,
685- containerIP,492+ adminRoute.ContainerIP,
686- p.app.GetConfig().Http.Port,493+ adminRoute.ContainerPort,
687- "http",494+ adminRoute.Protocol,
688- "/",495+ adminRoute.Path,
689 )496 )
690 497
691 if err != nil {498 if err != nil {
@@ -697,24 +504,22 @@ func (p *Proxy) Reload() error {
697 }504 }
698 }505 }
699 506
700- // Atomically swap the new routes map with the old one507+ // Update the routes map with the new one
701- // This ensures that no requests use a partially updated map
702- p.mu.Lock()
703 p.routes = newRoutes508 p.routes = newRoutes
704- p.mu.Unlock()509+ // Note: We don't need to call configureRoutes() here because it would acquire the same lock
510+ // that we already hold. Instead, we do the work directly.
705 511
706- // Rebuild route configuration512+ // Define the handler using our helper function
707- p.configureRoutes()513+ handler := p.createProxyHandler()
708 514
709- // Force recreation of the route handlers if servers are initialized515+ // Add the handler to the HTTPS server
710- if p.httpServer != nil {516+ if p.httpsServer != nil {
711- logger.Debug("Refreshing HTTP routes")517+ p.httpsServer.Any("/*", handler)
712- p.httpServer.Routes()
713 }518 }
714 519
715- if p.httpsServer != nil {520+ // Also add the handler to the HTTP server for HTTP-01 challenges
716- logger.Debug("Refreshing HTTPS routes")521+ if p.httpServer != nil {
717- p.httpsServer.Routes()522+ p.httpServer.Any("/*", handler)
718 }523 }
719 524
720 return nil525 return nil
@@ -743,91 +548,14 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error {
743 "old_ip", route.ContainerIP,548 "old_ip", route.ContainerIP,
744 "new_ip", newIP)549 "new_ip", newIP)
745 550
746- // Double-check that the container exists and is running551+ // Verify and normalize the container IP
747- containerInfo, err := docker.GetContainerInfo(containerID)552+ newIP = p.verifyAndNormalizeContainerIP(containerID, newIP)
748- if err != nil {
749- logger.Warn("Container not found when updating route IP, might have been recreated",
750- "domain", domain,
751- "container_id", containerID,
752- "error", err)
753- // Continue with the update anyway, as the container ID might still be valid
754- } else {
755- logger.Debug("Container exists when updating route IP",
756- "domain", domain,
757- "container_id", containerID,
758- "container_state", containerInfo.State.Status)
759-
760- // Verify the new IP matches what's in the container
761- networkName := p.app.GetConfig().ContainerEngine.Network
762- if containerInfo.NetworkSettings != nil && containerInfo.NetworkSettings.Networks != nil {
763- if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists &&
764- networkSettings.IPAddress != "" && networkSettings.IPAddress != newIP {
765- logger.Warn("New IP doesn't match container network IP, using container's actual IP",
766- "domain", domain,
767- "provided_ip", newIP,
768- "container_actual_ip", networkSettings.IPAddress)
769- newIP = networkSettings.IPAddress
770- }
771- }
772- }
773 553
774 // First, update the in-memory route immediately554 // First, update the in-memory route immediately
775- p.mu.Lock()555+ p.forceUpdateRouteIPInMemory(domain, newIP)
776- if r, ok := p.routes[domain]; ok {
777- oldIP := r.ContainerIP
778- r.ContainerIP = newIP
779- // Make sure the route is also marked as active
780- r.Active = true
781- logger.Info("Force updated route IP in-memory",
782- "domain", domain,
783- "old_ip", oldIP,
784- "new_ip", newIP)
785- }
786- p.mu.Unlock()
787 556
788 // Next, update the database557 // Next, update the database
789- // Get the domain ID with retry558+ return p.updateRouteIPInDatabase(domain, containerID, newIP)
790- var domainID string
791- rows, err := p.app.GetDB().Query(p.queries.GetDomainByName, domain)
792- if err != nil {
793- logger.Error("Failed to query domain for IP update", "domain", domain, "error", err)
794- return fmt.Errorf("failed to query domain: %w", err)
795- }
796-
797- if !rows.Next() {
798- rows.Close()
799- logger.Error("Domain not found in database when updating IP", "domain", domain)
800- return fmt.Errorf("domain not found: %s", domain)
801- }
802-
803- if err := rows.Scan(&domainID); err != nil {
804- rows.Close()
805- logger.Error("Failed to scan domain ID for IP update", "domain", domain, "error", err)
806- return fmt.Errorf("failed to scan domain ID: %w", err)
807- }
808- rows.Close()
809-
810- // Update the proxy_route record with retry and ensure it's marked as active
811- now := time.Now().Format(time.RFC3339)
812- result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID)
813-
814- if err != nil {
815- logger.Error("Failed to update IP in database", "domain", domain, "error", err)
816- return fmt.Errorf("failed to update database: %w", err)
817- }
818-
819- rowsAffected, _ := result.RowsAffected()
820- if rowsAffected == 0 {
821- logger.Warn("No rows affected when updating route IP in database", "domain", domain)
822- return fmt.Errorf("no rows affected when updating route in database")
823- }
824-
825- logger.Info("Force updated route IP in database",
826- "domain", domain,
827- "container_id", containerID,
828- "new_ip", newIP)
829-
830- return nil
831 }559 }
832 560
833 // FindRoutesByContainerName finds routes associated with a specific container name561 // FindRoutesByContainerName finds routes associated with a specific container name
@@ -992,8 +720,8 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
992 720
993 // First approach: check if there are any domains that should point to this container based on labels721 // First approach: check if there are any domains that should point to this container based on labels
994 if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" {722 if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" {
995- // Normalize domain (lowercase, no trailing dot)723+ // Normalize domain
996- domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".")724+ domainLabel = normalizeDomain(domainLabel)
997 725
998 // Check if there's already a route for this domain726 // Check if there's already a route for this domain
999 p.mu.RLock()727 p.mu.RLock()
@@ -1008,28 +736,10 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
1008 "old_container_id", route.ContainerID,736 "old_container_id", route.ContainerID,
1009 "new_container_id", containerID)737 "new_container_id", containerID)
1010 738
1011- // Get the port to use for the proxy from the container labels739+ // Update route for this container
1012- containerPort := "80" // Default740+ err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, route.Path)
1013- if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
1014- containerPort = portLabel
1015- }
1016-
1017- // Get the protocol to use
1018- protocol := "http" // Default
1019- if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
1020- (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") {
1021- protocol = "https"
1022- }
1023-
1024- // Update the route with the new container ID and IP
1025- err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, route.Path)
1026 if err != nil {741 if err != nil {
1027 logger.Error("Failed to update route for recreated container", "error", err)742 logger.Error("Failed to update route for recreated container", "error", err)
1028- } else {
1029- logger.Info("Updated route for recreated container",
1030- "domain", domainLabel,
1031- "container_id", containerID,
1032- "container_ip", containerIP)
1033 }743 }
1034 } else {744 } else {
1035 // Same container ID but maybe IP changed745 // Same container ID but maybe IP changed
@@ -1053,27 +763,8 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
1053 "domain", domainLabel,763 "domain", domainLabel,
1054 "container_id", containerID)764 "container_id", containerID)
1055 765
1056- // Get the port to use for the proxy from the container labels
1057- containerPort := "80" // Default
1058- if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
1059- containerPort = portLabel
1060- }
1061-
1062- // Get the protocol to use
1063- protocol := "http" // Default
1064- if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
1065- (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") {
1066- protocol = "https"
1067- }
1068-
1069 // Add a new route for this container766 // Add a new route for this container
1070- logger.Info("Adding missing route for Gordon container",767+ err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, "")
1071- "domain", domainLabel,
1072- "container_id", containerID,
1073- "container_ip", containerIP,
1074- "container_port", containerPort)
1075-
1076- err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, "/")
1077 if err != nil {768 if err != nil {
1078 logger.Error("Failed to add missing route", "error", err)769 logger.Error("Failed to add missing route", "error", err)
1079 }770 }
@@ -1093,33 +784,10 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
1093 "old_container_id", oldRoute.ContainerID,784 "old_container_id", oldRoute.ContainerID,
1094 "new_container_id", containerID)785 "new_container_id", containerID)
1095 786
1096- // Get the port to use for the proxy from the container labels787+ // Update route for this container, preserving the old path
1097- containerPort := oldRoute.ContainerPort // Use the existing port by default788+ err = p.updateRouteForContainer(domain, containerID, containerIP, containerInfo, oldRoute.Path)
1098- if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
1099- // But prefer the container label if present
1100- containerPort = portLabel
1101- }
1102-
1103- // Get the protocol to use
1104- protocol := oldRoute.Protocol // Use existing protocol by default
1105- if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists {
1106- // But prefer the container label if present
1107- if sslLabel == "true" || sslLabel == "1" || sslLabel == "yes" {
1108- protocol = "https"
1109- } else {
1110- protocol = "http"
1111- }
1112- }
1113-
1114- // Update the route
1115- err = p.AddRoute(domain, containerID, containerIP, containerPort, protocol, oldRoute.Path)
1116 if err != nil {789 if err != nil {
1117 logger.Error("Failed to update route for recreated container", "error", err)790 logger.Error("Failed to update route for recreated container", "error", err)
1118- } else {
1119- logger.Info("Updated route for recreated container",
1120- "domain", domain,
1121- "container_id", containerID,
1122- "container_ip", containerIP)
1123 }791 }
1124 }792 }
1125 }793 }
@@ -1143,150 +811,13 @@ func (p *Proxy) StartContainerEventListener() error {
1143 "container_name", containerName,811 "container_name", containerName,
1144 "container_ip", containerIP)812 "container_ip", containerIP)
1145 813
1146- // Skip processing if container is in cooldown period
1147- if p.IsContainerInCooldownPeriod(containerID) {
1148- logger.Debug("Skipping container event processing - container in cooldown period",
1149- "container_id", containerID,
1150- "container_name", containerName)
1151- return
1152- }
1153-
1154- // Register this container as recently created to prevent duplicate processing
1155- p.RegisterNewlyCreatedContainer(containerID)
1156-
1157 // Use a goroutine to handle the event asynchronously to avoid blocking the event listener814 // Use a goroutine to handle the event asynchronously to avoid blocking the event listener
1158 go func() {815 go func() {
1159 // Add a small delay to allow any direct API calls to complete first816 // Add a small delay to allow any direct API calls to complete first
1160 time.Sleep(500 * time.Millisecond)817 time.Sleep(500 * time.Millisecond)
1161 818
1162- // Check if this is the Gordon container (admin domain)819+ // Process the container event
1163- isAdminContainer := false820+ p.processContainerEvent(containerID, containerName, containerIP)
1164- adminDomain := p.app.GetConfig().Http.FullDomain()
1165-
1166- p.mu.RLock()
1167- if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID {
1168- isAdminContainer = true
1169- logger.Debug("Admin container event detected", "container_id", containerID)
1170- }
1171- p.mu.RUnlock()
1172-
1173- // First, check if there are any existing routes for this container ID
1174- var existingRoutes = make(map[string]*ProxyRouteInfo)
1175- p.mu.RLock()
1176- for domain, route := range p.routes {
1177- if route.ContainerID == containerID {
1178- existingRoutes[domain] = route
1179- }
1180- }
1181- p.mu.RUnlock()
1182-
1183- // If we found existing routes, check if the IP has changed
1184- if len(existingRoutes) > 0 {
1185- logger.Debug("Found existing routes for container",
1186- "container_id", containerID,
1187- "route_count", len(existingRoutes))
1188-
1189- // Verify the container IP with an additional check
1190- verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName)
1191- if err == nil && verifiedIP != "" && verifiedIP != containerIP {
1192- logger.Warn("Container IP mismatch from event vs. direct network check",
1193- "container_id", containerID,
1194- "event_ip", containerIP,
1195- "network_ip", verifiedIP)
1196-
1197- // Use the directly checked IP as it's more reliable
1198- containerIP = verifiedIP
1199- }
1200-
1201- // Check and update each route if IP has changed
1202- for domain, route := range existingRoutes {
1203- // Handle admin domain specially to avoid path issues
1204- if domain == adminDomain && isAdminContainer {
1205- // For admin domain, only update the IP if it has changed
1206- if route.ContainerIP != containerIP {
1207- logger.Info("Admin container restarted with new IP - updating route",
1208- "container_id", containerID,
1209- "container_name", containerName,
1210- "domain", domain,
1211- "old_ip", route.ContainerIP,
1212- "new_ip", containerIP)
1213-
1214- // Update IP in memory only first
1215- p.mu.Lock()
1216- if r, ok := p.routes[domain]; ok {
1217- r.ContainerIP = containerIP
1218- r.Active = true
1219- }
1220- p.mu.Unlock()
1221-
1222- // Update DB in background
1223- go func(domain, containerID, containerIP string) {
1224- // Get the domain ID
1225- var domainID string
1226- rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain)
1227- if err != nil {
1228- logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err)
1229- return
1230- }
1231-
1232- if !rows.Next() {
1233- rows.Close()
1234- logger.Error("Admin domain not found in database", "domain", domain)
1235- return
1236- }
1237-
1238- if err := rows.Scan(&domainID); err != nil {
1239- rows.Close()
1240- logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err)
1241- return
1242- }
1243- rows.Close()
1244-
1245- // Update the IP in the database directly
1246- now := time.Now().Format(time.RFC3339)
1247- _, err = p.dbExecWithRetry(p.queries.UpdateRouteIP,
1248- containerIP, now, domainID, containerID)
1249-
1250- if err != nil {
1251- logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err)
1252- } else {
1253- logger.Info("Updated admin container IP in database",
1254- "domain", domain,
1255- "container_id", containerID,
1256- "new_ip", containerIP)
1257- }
1258- }(domain, containerID, containerIP)
1259- }
1260- } else if route.ContainerIP != containerIP {
1261- // For non-admin domains, use the standard route update
1262- logger.Info("Container restarted with new IP - updating route",
1263- "container_id", containerID,
1264- "container_name", containerName,
1265- "domain", domain,
1266- "old_ip", route.ContainerIP,
1267- "new_ip", containerIP)
1268-
1269- // Update the route IP
1270- err = p.ForceUpdateRouteIP(domain, containerIP)
1271- if err != nil {
1272- logger.Error("Failed to update IP for restarted container",
1273- "domain", domain,
1274- "error", err)
1275- }
1276- } else {
1277- // Even if IP is the same, make sure the route is active
1278- if !route.Active {
1279- logger.Info("Reactivating route for restarted container",
1280- "container_id", containerID,
1281- "container_name", containerName,
1282- "domain", domain)
1283-
1284- // Just reuse ForceUpdateRouteIP with the same IP to activate the route
1285- p.ForceUpdateRouteIP(domain, containerIP)
1286- }
1287- }
1288- }
1289- }
1290 821
1291 // Call the standard recreation handler for additional checks822 // Call the standard recreation handler for additional checks
1292 p.HandleContainerRecreation(containerID, containerName, containerIP)823 p.HandleContainerRecreation(containerID, containerName, containerIP)
@@ -1339,60 +870,15 @@ func (p *Proxy) StartContainerEventListener() error {
1339 870
1340 // checkRoutesForIPConsistency verifies that all active routes have the correct container IP871 // checkRoutesForIPConsistency verifies that all active routes have the correct container IP
1341 func (p *Proxy) checkRoutesForIPConsistency() {872 func (p *Proxy) checkRoutesForIPConsistency() {
873+ // Get the network name from app config
1342 networkName := p.app.GetConfig().ContainerEngine.Network874 networkName := p.app.GetConfig().ContainerEngine.Network
1343 875
1344- // Get a copy of the current routes876+ // Get a copy of all active routes
1345- p.mu.RLock()877+ routesToCheck := p.getActiveRoutes()
1346- routesToCheck := make(map[string]*ProxyRouteInfo)
1347- for domain, route := range p.routes {
1348- if route.Active {
1349- routesToCheck[domain] = &ProxyRouteInfo{
1350- Domain: route.Domain,
1351- ContainerID: route.ContainerID,
1352- ContainerIP: route.ContainerIP,
1353- ContainerPort: route.ContainerPort,
1354- Protocol: route.Protocol,
1355- Path: route.Path,
1356- Active: route.Active,
1357- }
1358- }
1359- }
1360- p.mu.RUnlock()
1361 878
1362- // Check each active route879+ // Check each active route for IP consistency
1363 for domain, route := range routesToCheck {880 for domain, route := range routesToCheck {
1364- // Skip checking admin domain881+ p.checkAndUpdateIPIfNeeded(domain, route, networkName)
1365- if domain == p.app.GetConfig().Http.FullDomain() {
1366- continue
1367- }
1368-
1369- // Verify the current container IP
1370- currentIP, err := docker.GetContainerIPFromNetwork(route.ContainerID, networkName)
1371- if err != nil {
1372- // Container might not exist anymore
1373- logger.Warn("Failed to get current IP for container during consistency check",
1374- "domain", domain,
1375- "container_id", route.ContainerID,
1376- "error", err)
1377- continue
1378- }
1379-
1380- // If IP has changed, update the route
1381- if currentIP != "" && currentIP != route.ContainerIP {
1382- logger.Info("Detected IP mismatch during consistency check",
1383- "domain", domain,
1384- "container_id", route.ContainerID,
1385- "old_ip", route.ContainerIP,
1386- "current_ip", currentIP)
1387-
1388- // Update the route
1389- err = p.ForceUpdateRouteIP(domain, currentIP)
1390- if err != nil {
1391- logger.Error("Failed to update IP during consistency check",
1392- "domain", domain,
1393- "error", err)
1394- }
1395- }
1396 }882 }
1397 }883 }
1398 884
@@ -1402,28 +888,14 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1402 return888 return
1403 }889 }
1404 890
1405- // Get the admin domain to protect it
1406- adminDomain := p.app.GetConfig().Http.FullDomain()
1407-
1408 // Filter out admin domain from domains to mark inactive891 // Filter out admin domain from domains to mark inactive
1409- filteredDomains := make([]string, 0, len(domains))892+ filteredDomains := p.filterOutAdminDomain(domains)
1410- for _, domain := range domains {
1411- if domain != adminDomain {
1412- filteredDomains = append(filteredDomains, domain)
1413- } else {
1414- logger.Warn("Prevented admin domain from being marked inactive",
1415- "domain", adminDomain)
1416- }
1417- }
1418 893
1419 // If all domains were filtered out, nothing to do894 // If all domains were filtered out, nothing to do
1420 if len(filteredDomains) == 0 {895 if len(filteredDomains) == 0 {
1421 return896 return
1422 }897 }
1423 898
1424- // Use the filtered domains from here on
1425- domains = filteredDomains
1426-
1427 // Begin a transaction899 // Begin a transaction
1428 tx, err := p.app.GetDB().Begin()900 tx, err := p.app.GetDB().Begin()
1429 if err != nil {901 if err != nil {
@@ -1432,31 +904,17 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1432 }904 }
1433 defer tx.Rollback()905 defer tx.Rollback()
1434 906
1435- // Update each route in the database907+ // Update each route in the database and memory
1436- for _, domain := range domains {908+ for _, domain := range filteredDomains {
1437- // First, get the domain ID909+ // Update in database
1438- var domainID string910+ err := p.markRouteInactiveInDatabase(tx, domain)
1439- err := tx.QueryRow(p.queries.GetDomainByName, domain).Scan(&domainID)
1440- if err != nil {
1441- logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err)
1442- continue
1443- }
1444-
1445- // Update the route to mark it as inactive
1446- now := time.Now().Format(time.RFC3339)
1447- _, err = tx.Exec(p.queries.MarkRouteInactive, now, domainID)
1448 if err != nil {911 if err != nil {
1449- logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err)912+ // Skip to next domain if there was an error
1450 continue913 continue
1451 }914 }
1452 915
1453- // Also update the in-memory route916+ // Update in memory
1454- p.mu.Lock()917+ p.markRouteInactiveInMemory(domain)
1455- if route, exists := p.routes[domain]; exists {
1456- route.Active = false
1457- logger.Info("Marked route as inactive", "domain", domain)
1458- }
1459- p.mu.Unlock()
1460 }918 }
1461 919
1462 // Commit the transaction920 // Commit the transaction
@@ -1465,7 +923,7 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1465 return923 return
1466 }924 }
1467 925
1468- logger.Info("Successfully marked orphaned routes as inactive", "count", len(domains))926+ logger.Info("Successfully marked orphaned routes as inactive", "count", len(filteredDomains))
1469 }927 }
1470 928
1471 // DiscoverMissingRoutes finds Gordon-managed containers that don't have routes yet929 // DiscoverMissingRoutes finds Gordon-managed containers that don't have routes yet
@@ -1531,8 +989,8 @@ func (p *Proxy) DiscoverMissingRoutes() {
1531 continue989 continue
1532 }990 }
1533 991
1534- // Normalize domain (lowercase, no trailing dot)992+ // Normalize domain
1535- domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".")993+ domainLabel = normalizeDomain(domainLabel)
1536 994
1537 // Skip if this is the admin domain995 // Skip if this is the admin domain
1538 if domainLabel == p.app.GetConfig().Http.FullDomain() {996 if domainLabel == p.app.GetConfig().Http.FullDomain() {
@@ -1559,27 +1017,13 @@ func (p *Proxy) DiscoverMissingRoutes() {
1559 // Extract just the IP part from the CIDR notation (e.g., "192.168.1.2/24" → "192.168.1.2")1017 // Extract just the IP part from the CIDR notation (e.g., "192.168.1.2/24" → "192.168.1.2")
1560 containerIP = strings.Split(containerIP, "/")[0]1018 containerIP = strings.Split(containerIP, "/")[0]
1561 1019
1562- // Get the port to use for the proxy from the container labels
1563- containerPort := "80" // Default
1564- if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
1565- containerPort = portLabel
1566- }
1567-
1568- // Get the protocol to use
1569- protocol := "http" // Default
1570- if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
1571- (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") {
1572- protocol = "https"
1573- }
1574-
1575 // Add a new route for this container1020 // Add a new route for this container
1576 logger.Info("Adding missing route for Gordon container",1021 logger.Info("Adding missing route for Gordon container",
1577 "domain", domainLabel,1022 "domain", domainLabel,
1578 "container_id", containerID,1023 "container_id", containerID,
1579- "container_ip", containerIP,1024+ "container_ip", containerIP)
1580- "container_port", containerPort)
1581 1025
1582- err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, "/")1026+ err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, "")
1583 if err != nil {1027 if err != nil {
1584 logger.Error("Failed to add missing route", "error", err)1028 logger.Error("Failed to add missing route", "error", err)
1585 }1029 }
@@ -1737,6 +1181,7 @@ func (p *Proxy) LogGordonIdentity() {
1737 }1181 }
1738 }1182 }
1739 1183
1184+// detectGordonContainer attempts to find the Gordon container automatically
1740 // detectGordonContainer attempts to find the Gordon container automatically1185 // detectGordonContainer attempts to find the Gordon container automatically
1741 func (p *Proxy) detectGordonContainer() string {1186 func (p *Proxy) detectGordonContainer() string {
1742 logger.Debug("Starting Gordon container detection")1187 logger.Debug("Starting Gordon container detection")
@@ -1880,83 +1325,11 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error {
1880 // Create the target URL1325 // Create the target URL
1881 targetURL := &url.URL{1326 targetURL := &url.URL{
1882 Scheme: targetProtocol,1327 Scheme: targetProtocol,
1328+ Host: fmt.Sprintf("%s:%s", formatIPAddress(containerIP), containerPort),
1883 }1329 }
1884 1330
1885- // Format host properly for both IPv4 and IPv61331+ // Create a reverse proxy using the helper function
1886- if strings.Contains(containerIP, ":") {1332+ proxy := p.createReverseProxy(targetURL, host, containerID, isAdminDomain)
1887- // IPv6 address needs to be wrapped in brackets
1888- containerIP = "[" + containerIP + "]"
1889- }
1890- targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort)
1891-
1892- // Create a reverse proxy
1893- proxy := httputil.NewSingleHostReverseProxy(targetURL)
1894-
1895- // Use our custom transport with longer timeouts
1896- if p.reverseProxyClient != nil {
1897- proxy.Transport = p.reverseProxyClient.Transport
1898- }
1899-
1900- // Set up request director to modify the request before it's sent
1901- originalDirector := proxy.Director
1902- proxy.Director = func(req *http.Request) {
1903- // Call the original director first
1904- originalDirector(req)
1905-
1906- // Keep the original host in the Host header
1907- req.Host = host
1908-
1909- // Set X-Forwarded headers to preserve client information
1910- if clientIP := c.RealIP(); clientIP != "" {
1911- req.Header.Set("X-Forwarded-For", clientIP)
1912- req.Header.Set("X-Real-IP", clientIP)
1913- }
1914-
1915- // Set X-Forwarded-Proto to the original protocol
1916- req.Header.Set("X-Forwarded-Proto", c.Scheme())
1917-
1918- // Set X-Forwarded-Host to the original host
1919- req.Header.Set("X-Forwarded-Host", host)
1920- }
1921-
1922- // Log errors that occur during proxying
1923- proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
1924- logger.Error("Proxy error",
1925- "host", host,
1926- "target", targetURL.String(),
1927- "error", err)
1928-
1929- // Skip container down detection for admin domain
1930- if !isAdminDomain {
1931- // Check if this is a connection refused error
1932- if strings.Contains(err.Error(), "connection refused") ||
1933- strings.Contains(err.Error(), "no route to host") ||
1934- strings.Contains(err.Error(), "i/o timeout") {
1935- // Container might be down or unreachable - mark the route as inactive
1936- logger.Warn("Container appears to be down, marking route as inactive",
1937- "domain", host,
1938- "container_id", containerID)
1939-
1940- // Find domains for this container and mark them inactive
1941- var domains []string
1942- p.mu.RLock()
1943- for d, r := range p.routes {
1944- if r.ContainerID == containerID {
1945- domains = append(domains, d)
1946- }
1947- }
1948- p.mu.RUnlock()
1949-
1950- if len(domains) > 0 {
1951- go p.markRoutesInactive(domains)
1952- }
1953- }
1954- }
1955-
1956- // Return a 502 Bad Gateway error
1957- c.Response().WriteHeader(http.StatusBadGateway)
1958- c.Response().Write([]byte("Bad Gateway: Container is unreachable"))
1959- }
1960 1333
1961 // Serve the request using the reverse proxy1334 // Serve the request using the reverse proxy
1962 proxy.ServeHTTP(c.Response(), c.Request())1335 proxy.ServeHTTP(c.Response(), c.Request())
A internal/proxy/routes_helpers.go +723 -0
@@ -0,0 +1,723 @@
1+package proxy
2+
3+import (
4+ "crypto/rand"
5+ "database/sql"
6+ "fmt"
7+ "net"
8+ "net/http"
9+ "net/http/httputil"
10+ "net/url"
11+ "os"
12+ "strings"
13+ "time"
14+
15+ "github.com/bnema/gordon/pkg/docker"
16+ "github.com/bnema/gordon/pkg/logger"
17+ "github.com/docker/docker/api/types"
18+ "github.com/google/uuid"
19+ "github.com/labstack/echo/v4"
20+)
21+
22+// proxyGenerateUUID generates a random UUID for use in various identifiers
23+// This is a renamed version to avoid conflicts until we clean up proxy.go
24+func proxyGenerateUUID() string {
25+ id, err := uuid.NewRandom()
26+ if err != nil {
27+ // If we can't generate a UUID using the standard library,
28+ // fall back to our own implementation
29+ b := make([]byte, 16)
30+ _, err := rand.Read(b)
31+ if err != nil {
32+ return fmt.Sprintf("%d", time.Now().UnixNano())
33+ }
34+ return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
35+ }
36+ return id.String()
37+}
38+
39+// normalizeDomain converts a domain to lowercase and removes trailing dot
40+func normalizeDomain(domain string) string {
41+ return strings.TrimSuffix(strings.ToLower(domain), ".")
42+}
43+
44+// createProxyHandler creates a standard Echo handler function for both HTTP and HTTPS servers
45+func (p *Proxy) createProxyHandler() echo.HandlerFunc {
46+ return func(c echo.Context) error {
47+ host := c.Request().Host
48+
49+ // Strip port if present in the host
50+ if hostParts := strings.Split(host, ":"); len(hostParts) > 1 {
51+ host = hostParts[0]
52+ }
53+
54+ // Normalize host (trim trailing dot, convert to lowercase)
55+ host = normalizeDomain(host)
56+
57+ // Check if this is the admin domain
58+ adminDomain := p.app.GetConfig().Http.FullDomain()
59+ if strings.EqualFold(host, adminDomain) {
60+ // Always prioritize admin domain
61+ adminRoute, adminOk := p.routes[adminDomain]
62+
63+ if adminOk {
64+ return p.proxyRequest(c, adminRoute)
65+ }
66+ }
67+
68+ // Important: Get the route freshly from the map for every request
69+ // This ensures we always use the most up-to-date IP
70+ route, ok := p.routes[host]
71+
72+ if !ok {
73+ // Check if the host is an IP address - silently handle without logging warnings
74+ if net.ParseIP(host) != nil {
75+ // For IP-based requests, just return a 404 without logging warnings
76+ return c.String(http.StatusNotFound, "Domain not found")
77+ }
78+
79+ // Create list of available domains for debugging
80+ availableDomains := make([]string, 0, len(p.routes))
81+ for d := range p.routes {
82+ availableDomains = append(availableDomains, d)
83+ }
84+
85+ // Log warning for non-IP hosts that aren't configured
86+ logger.Warn("Request with unknown host",
87+ "requested_host", host,
88+ "client_ip", c.RealIP(),
89+ "available_domains", strings.Join(availableDomains, ", "))
90+ return c.String(http.StatusNotFound, "Domain not found")
91+ }
92+
93+ // Check if the route is active
94+ if !route.Active {
95+ logger.Warn("Route is not active",
96+ "domain", host,
97+ "client_ip", c.RealIP())
98+ return c.String(http.StatusServiceUnavailable, "Route is not active")
99+ }
100+ return p.proxyRequest(c, route)
101+ }
102+}
103+
104+// createReverseProxy creates a reverse proxy for a given target URL
105+func (p *Proxy) createReverseProxy(targetURL *url.URL, host string, containerID string, isAdminDomain bool) *httputil.ReverseProxy {
106+ proxy := httputil.NewSingleHostReverseProxy(targetURL)
107+
108+ // Use our custom transport with longer timeouts
109+ if p.reverseProxyClient != nil {
110+ proxy.Transport = p.reverseProxyClient.Transport
111+ }
112+
113+ // Set up request director to modify the request before it's sent
114+ originalDirector := proxy.Director
115+ proxy.Director = func(req *http.Request) {
116+ // Call the original director first
117+ originalDirector(req)
118+
119+ // Keep the original host in the Host header
120+ req.Host = host
121+
122+ // Set X-Forwarded headers to preserve client information
123+ if clientIP := req.Header.Get("X-Real-IP"); clientIP != "" {
124+ req.Header.Set("X-Forwarded-For", clientIP)
125+ }
126+
127+ // Set X-Forwarded-Proto based on the request scheme
128+ if req.TLS != nil {
129+ req.Header.Set("X-Forwarded-Proto", "https")
130+ } else {
131+ req.Header.Set("X-Forwarded-Proto", "http")
132+ }
133+
134+ // Set X-Forwarded-Host to the original host
135+ req.Header.Set("X-Forwarded-Host", host)
136+ }
137+
138+ // Create error handler for proxy errors
139+ proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
140+ logger.Error("Proxy error",
141+ "host", host,
142+ "target", targetURL.String(),
143+ "error", err)
144+
145+ // Skip container down detection for admin domain
146+ if !isAdminDomain {
147+ // Check if this is a connection refused error
148+ if strings.Contains(err.Error(), "connection refused") ||
149+ strings.Contains(err.Error(), "no route to host") ||
150+ strings.Contains(err.Error(), "i/o timeout") {
151+ // Container might be down or unreachable - mark the route as inactive
152+ logger.Warn("Container appears to be down, marking route as inactive",
153+ "domain", host,
154+ "container_id", containerID)
155+
156+ // Find domains for this container and mark them inactive
157+ var domains []string
158+ p.mu.RLock()
159+ for d, r := range p.routes {
160+ if r.ContainerID == containerID {
161+ domains = append(domains, d)
162+ }
163+ }
164+ p.mu.RUnlock()
165+
166+ if len(domains) > 0 {
167+ go p.markRoutesInactive(domains)
168+ }
169+ }
170+ }
171+
172+ // Return a 502 Bad Gateway error
173+ rw.WriteHeader(http.StatusBadGateway)
174+ rw.Write([]byte("Bad Gateway: Container is unreachable"))
175+ }
176+
177+ return proxy
178+}
179+
180+// saveAdminRoute preserves the admin route during route operations
181+func (p *Proxy) saveAdminRoute() *ProxyRouteInfo {
182+ adminDomain := p.app.GetConfig().Http.FullDomain()
183+ if route, exists := p.routes[adminDomain]; exists {
184+ return &ProxyRouteInfo{
185+ Domain: route.Domain,
186+ ContainerID: route.ContainerID,
187+ ContainerIP: route.ContainerIP,
188+ ContainerPort: route.ContainerPort,
189+ Protocol: route.Protocol,
190+ Path: route.Path,
191+ Active: route.Active,
192+ }
193+ }
194+ return nil
195+}
196+
197+// restoreAdminRoute adds back the admin route to the routes map
198+func (p *Proxy) restoreAdminRoute(adminRoute *ProxyRouteInfo, routes map[string]*ProxyRouteInfo) {
199+ if adminRoute != nil {
200+ adminDomain := p.app.GetConfig().Http.FullDomain()
201+ routes[adminDomain] = adminRoute
202+ logger.Debug("Restored admin domain route", "domain", adminDomain)
203+ }
204+}
205+
206+// createAdminRoute creates a new admin route with default settings
207+func (p *Proxy) createAdminRoute() *ProxyRouteInfo {
208+ adminDomain := p.app.GetConfig().Http.FullDomain()
209+ logger.Warn("Admin domain route is missing, recreating it", "domain", adminDomain)
210+
211+ // Auto-detect the Gordon container information
212+ containerName := p.detectGordonContainer()
213+ containerIP := "localhost" // Default to localhost
214+
215+ // Fall back options for container IP
216+ if os.Getenv("GORDON_ADMIN_HOST") != "" {
217+ containerIP = os.Getenv("GORDON_ADMIN_HOST")
218+ } else if os.Getenv("HOSTNAME") != "" {
219+ containerIP = os.Getenv("HOSTNAME")
220+ }
221+
222+ return &ProxyRouteInfo{
223+ Domain: adminDomain,
224+ ContainerID: containerName,
225+ ContainerIP: containerIP,
226+ ContainerPort: p.app.GetConfig().Http.Port,
227+ Protocol: "http", // Gordon server uses HTTP internally
228+ Path: "/",
229+ Active: true,
230+ }
231+}
232+
233+// processNewSubdomain extracts subdomain and parent domain and adds them to the database
234+func (p *Proxy) processNewSubdomain(tx *sql.Tx, hostname, accountID string, now string) error {
235+ hostParts := strings.Split(hostname, ".")
236+ if len(hostParts) >= 3 {
237+ // This appears to be in format subdomain.domain.tld
238+ // Extract subdomain and domain parts
239+ subdomain := hostParts[0]
240+ domainPart := strings.Join(hostParts[1:], ".")
241+
242+ // Check if the domain part already exists
243+ var domainPartID string
244+ err := tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainPart).Scan(&domainPartID)
245+ if err != nil {
246+ if err == sql.ErrNoRows {
247+ // Domain part doesn't exist, create it
248+ domainPartID = proxyGenerateUUID()
249+ _, err = txExecWithRetry(tx,
250+ p.queries.InsertDomain,
251+ domainPartID, domainPart, accountID, now, now,
252+ )
253+ if err != nil {
254+ logger.Warn("Failed to insert domain part, continuing anyway",
255+ "domain_part", domainPart,
256+ "error", err)
257+ } else {
258+ logger.Debug("Added domain part to database for Let's Encrypt",
259+ "domain_part", domainPart,
260+ "subdomain", subdomain)
261+ }
262+ }
263+ }
264+ }
265+ return nil
266+}
267+
268+// getContainerInfoFromLabels extracts container configuration from Docker labels
269+func getContainerInfoFromLabels(containerInfo types.ContainerJSON) (string, string, string) {
270+ // Get the port to use for the proxy from the container labels
271+ containerPort := "80" // Default
272+ if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
273+ containerPort = portLabel
274+ }
275+
276+ // Get the protocol to use
277+ protocol := "http" // Default
278+ if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
279+ (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") {
280+ protocol = "https"
281+ }
282+
283+ // Get path if specified
284+ path := "/" // Default
285+ if pathLabel, exists := containerInfo.Config.Labels["gordon.proxy.path"]; exists && pathLabel != "" {
286+ path = pathLabel
287+ }
288+
289+ return containerPort, protocol, path
290+}
291+
292+// updateRouteForContainer updates or creates a route for a container using label information
293+func (p *Proxy) updateRouteForContainer(
294+ domainLabel, containerID, containerIP string,
295+ containerInfo types.ContainerJSON,
296+ existingPath string) error {
297+
298+ // Get container configuration from labels
299+ containerPort, protocol, path := getContainerInfoFromLabels(containerInfo)
300+
301+ // Use existing path if provided and path from labels is default
302+ if existingPath != "" && path == "/" {
303+ path = existingPath
304+ }
305+
306+ // Add or update the route
307+ logger.Info("Updating route for container",
308+ "domain", domainLabel,
309+ "container_id", containerID,
310+ "container_ip", containerIP)
311+
312+ return p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, path)
313+}
314+
315+// processContainerEvent handles a container start/restart event
316+func (p *Proxy) processContainerEvent(containerID, containerName, containerIP string) {
317+ logger.Debug("Processing container event",
318+ "container_id", containerID,
319+ "container_name", containerName,
320+ "container_ip", containerIP)
321+
322+ // Skip processing if container is in cooldown period
323+ if p.IsContainerInCooldownPeriod(containerID) {
324+ logger.Debug("Skipping container event processing - container in cooldown period",
325+ "container_id", containerID,
326+ "container_name", containerName)
327+ return
328+ }
329+
330+ // Register this container as recently created to prevent duplicate processing
331+ p.RegisterNewlyCreatedContainer(containerID)
332+
333+ // Check if this is the admin domain
334+ isAdminContainer := false
335+ adminDomain := p.app.GetConfig().Http.FullDomain()
336+
337+ p.mu.RLock()
338+ if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID {
339+ isAdminContainer = true
340+ logger.Debug("Admin container event detected", "container_id", containerID)
341+ }
342+ p.mu.RUnlock()
343+
344+ // First, check if there are any existing routes for this container ID
345+ var existingRoutes = make(map[string]*ProxyRouteInfo)
346+ p.mu.RLock()
347+ for domain, route := range p.routes {
348+ if route.ContainerID == containerID {
349+ existingRoutes[domain] = route
350+ }
351+ }
352+ p.mu.RUnlock()
353+
354+ // If we found existing routes, check if the IP has changed
355+ if len(existingRoutes) > 0 {
356+ logger.Debug("Found existing routes for container",
357+ "container_id", containerID,
358+ "route_count", len(existingRoutes))
359+
360+ // Verify the container IP with an additional check
361+ networkName := p.app.GetConfig().ContainerEngine.Network
362+ verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName)
363+ if err == nil && verifiedIP != "" && verifiedIP != containerIP {
364+ logger.Warn("Container IP mismatch from event vs. direct network check",
365+ "container_id", containerID,
366+ "event_ip", containerIP,
367+ "network_ip", verifiedIP)
368+
369+ // Use the directly checked IP as it's more reliable
370+ containerIP = verifiedIP
371+ }
372+
373+ // Check and update each route if IP has changed
374+ for domain, route := range existingRoutes {
375+ // Handle admin domain specially to avoid path issues
376+ if domain == adminDomain && isAdminContainer {
377+ // For admin domain, only update the IP if it has changed
378+ if route.ContainerIP != containerIP {
379+ p.updateAdminContainerIP(domain, containerID, containerIP)
380+ }
381+ } else if route.ContainerIP != containerIP {
382+ // For non-admin domains, use the standard route update
383+ logger.Info("Container restarted with new IP - updating route",
384+ "container_id", containerID,
385+ "container_name", containerName,
386+ "domain", domain,
387+ "old_ip", route.ContainerIP,
388+ "new_ip", containerIP)
389+
390+ // Update the route IP
391+ err := p.ForceUpdateRouteIP(domain, containerIP)
392+ if err != nil {
393+ logger.Error("Failed to update IP for restarted container",
394+ "domain", domain,
395+ "error", err)
396+ }
397+ } else {
398+ // Even if IP is the same, make sure the route is active
399+ if !route.Active {
400+ logger.Info("Reactivating route for restarted container",
401+ "container_id", containerID,
402+ "container_name", containerName,
403+ "domain", domain)
404+
405+ // Just reuse ForceUpdateRouteIP with the same IP to activate the route
406+ p.ForceUpdateRouteIP(domain, containerIP)
407+ }
408+ }
409+ }
410+ }
411+}
412+
413+// updateAdminContainerIP updates the admin container's IP in memory and in the database
414+func (p *Proxy) updateAdminContainerIP(domain, containerID, containerIP string) {
415+ logger.Info("Admin container restarted with new IP - updating route",
416+ "container_id", containerID,
417+ "domain", domain,
418+ "new_ip", containerIP)
419+
420+ // Update IP in memory only first
421+ p.mu.Lock()
422+ if r, ok := p.routes[domain]; ok {
423+ r.ContainerIP = containerIP
424+ r.Active = true
425+ }
426+ p.mu.Unlock()
427+
428+ // Update DB in background
429+ go func(domain, containerID, containerIP string) {
430+ // Get the domain ID
431+ var domainID string
432+ rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain)
433+ if err != nil {
434+ logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err)
435+ return
436+ }
437+
438+ if !rows.Next() {
439+ rows.Close()
440+ logger.Error("Admin domain not found in database", "domain", domain)
441+ return
442+ }
443+
444+ if err := rows.Scan(&domainID); err != nil {
445+ rows.Close()
446+ logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err)
447+ return
448+ }
449+ rows.Close()
450+
451+ // Update the IP in the database directly
452+ now := time.Now().Format(time.RFC3339)
453+ _, err = p.dbExecWithRetry(p.queries.UpdateRouteIP,
454+ containerIP, now, domainID, containerID)
455+
456+ if err != nil {
457+ logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err)
458+ } else {
459+ logger.Info("Updated admin container IP in database",
460+ "domain", domain,
461+ "container_id", containerID,
462+ "new_ip", containerIP)
463+ }
464+ }(domain, containerID, containerIP)
465+}
466+
467+// formatIPAddress handles proper formatting for both IPv4 and IPv6 addresses
468+func formatIPAddress(ip string) string {
469+ if strings.Contains(ip, ":") {
470+ // IPv6 address needs to be wrapped in brackets
471+ return "[" + ip + "]"
472+ }
473+ return ip
474+}
475+
476+// createRouteInfo creates a new ProxyRouteInfo struct from parameters
477+func createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path string, active bool) *ProxyRouteInfo {
478+ return &ProxyRouteInfo{
479+ Domain: domain,
480+ ContainerID: containerID,
481+ ContainerIP: containerIP,
482+ ContainerPort: containerPort,
483+ Protocol: protocol,
484+ Path: path,
485+ Active: active,
486+ }
487+}
488+
489+// forceUpdateRouteIPInMemory updates a route's IP address in the in-memory routes map
490+func (p *Proxy) forceUpdateRouteIPInMemory(domain, newIP string) {
491+ p.mu.Lock()
492+ defer p.mu.Unlock()
493+
494+ if r, ok := p.routes[domain]; ok {
495+ oldIP := r.ContainerIP
496+ r.ContainerIP = newIP
497+ // Make sure the route is also marked as active
498+ r.Active = true
499+ logger.Info("Force updated route IP in-memory",
500+ "domain", domain,
501+ "old_ip", oldIP,
502+ "new_ip", newIP)
503+ }
504+}
505+
506+// updateRouteIPInDatabase updates a route's IP address in the database
507+func (p *Proxy) updateRouteIPInDatabase(domain, containerID, newIP string) error {
508+ // Get the domain ID with retry
509+ var domainID string
510+ rows, err := p.app.GetDB().Query(p.queries.GetDomainByName, domain)
511+ if err != nil {
512+ logger.Error("Failed to query domain for IP update", "domain", domain, "error", err)
513+ return fmt.Errorf("failed to query domain: %w", err)
514+ }
515+
516+ if !rows.Next() {
517+ rows.Close()
518+ logger.Error("Domain not found in database when updating IP", "domain", domain)
519+ return fmt.Errorf("domain not found: %s", domain)
520+ }
521+
522+ if err := rows.Scan(&domainID); err != nil {
523+ rows.Close()
524+ logger.Error("Failed to scan domain ID for IP update", "domain", domain, "error", err)
525+ return fmt.Errorf("failed to scan domain ID: %w", err)
526+ }
527+ rows.Close()
528+
529+ // Update the proxy_route record with retry and ensure it's marked as active
530+ now := time.Now().Format(time.RFC3339)
531+ result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID)
532+
533+ if err != nil {
534+ logger.Error("Failed to update IP in database", "domain", domain, "error", err)
535+ return fmt.Errorf("failed to update database: %w", err)
536+ }
537+
538+ rowsAffected, _ := result.RowsAffected()
539+ if rowsAffected == 0 {
540+ logger.Warn("No rows affected when updating route IP in database", "domain", domain)
541+ return fmt.Errorf("no rows affected when updating route in database")
542+ }
543+
544+ logger.Info("Force updated route IP in database",
545+ "domain", domain,
546+ "container_id", containerID,
547+ "new_ip", newIP)
548+
549+ return nil
550+}
551+
552+// verifyAndNormalizeContainerIP verifies that a container's IP address is correct
553+// and returns the normalized IP address
554+func (p *Proxy) verifyAndNormalizeContainerIP(containerID, providedIP string) string {
555+ // Double-check that the container exists and is running
556+ containerInfo, err := docker.GetContainerInfo(containerID)
557+ if err != nil {
558+ logger.Warn("Container not found when updating route IP, might have been recreated",
559+ "container_id", containerID,
560+ "error", err)
561+ // Continue with the update anyway, as the container ID might still be valid
562+ return providedIP
563+ }
564+
565+ logger.Debug("Container exists when updating route IP",
566+ "container_id", containerID,
567+ "container_state", containerInfo.State.Status)
568+
569+ // Verify the new IP matches what's in the container
570+ networkName := p.app.GetConfig().ContainerEngine.Network
571+ if containerInfo.NetworkSettings != nil && containerInfo.NetworkSettings.Networks != nil {
572+ if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists &&
573+ networkSettings.IPAddress != "" && networkSettings.IPAddress != providedIP {
574+ logger.Warn("New IP doesn't match container network IP, using container's actual IP",
575+ "provided_ip", providedIP,
576+ "container_actual_ip", networkSettings.IPAddress)
577+ return networkSettings.IPAddress
578+ }
579+ }
580+
581+ return providedIP
582+}
583+
584+// filterOutAdminDomain removes the admin domain from a list of domains
585+func (p *Proxy) filterOutAdminDomain(domains []string) []string {
586+ if len(domains) == 0 {
587+ return domains
588+ }
589+
590+ // Get the admin domain to protect it
591+ adminDomain := p.app.GetConfig().Http.FullDomain()
592+
593+ // Filter out admin domain from domains
594+ filteredDomains := make([]string, 0, len(domains))
595+ for _, domain := range domains {
596+ if domain != adminDomain {
597+ filteredDomains = append(filteredDomains, domain)
598+ } else {
599+ logger.Warn("Prevented admin domain from being modified",
600+ "domain", adminDomain)
601+ }
602+ }
603+
604+ return filteredDomains
605+}
606+
607+// markRouteInactiveInDatabase marks a route as inactive in the database
608+func (p *Proxy) markRouteInactiveInDatabase(tx *sql.Tx, domain string) error {
609+ // Get the domain ID
610+ var domainID string
611+ err := tx.QueryRow(p.queries.GetDomainByName, domain).Scan(&domainID)
612+ if err != nil {
613+ logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err)
614+ return err
615+ }
616+
617+ // Update the route to mark it as inactive
618+ now := time.Now().Format(time.RFC3339)
619+ _, err = tx.Exec(p.queries.MarkRouteInactive, now, domainID)
620+ if err != nil {
621+ logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err)
622+ return err
623+ }
624+
625+ return nil
626+}
627+
628+// markRouteInactiveInMemory marks a route as inactive in the in-memory routes map
629+func (p *Proxy) markRouteInactiveInMemory(domain string) {
630+ p.mu.Lock()
631+ defer p.mu.Unlock()
632+
633+ if route, exists := p.routes[domain]; exists {
634+ route.Active = false
635+ logger.Info("Marked route as inactive", "domain", domain)
636+ }
637+}
638+
639+// getActiveRoutes returns a copy of all active routes in the routes map
640+func (p *Proxy) getActiveRoutes() map[string]*ProxyRouteInfo {
641+ p.mu.RLock()
642+ defer p.mu.RUnlock()
643+
644+ routesToCheck := make(map[string]*ProxyRouteInfo)
645+ for domain, route := range p.routes {
646+ if route.Active {
647+ routesToCheck[domain] = &ProxyRouteInfo{
648+ Domain: route.Domain,
649+ ContainerID: route.ContainerID,
650+ ContainerIP: route.ContainerIP,
651+ ContainerPort: route.ContainerPort,
652+ Protocol: route.Protocol,
653+ Path: route.Path,
654+ Active: route.Active,
655+ }
656+ }
657+ }
658+
659+ return routesToCheck
660+}
661+
662+// checkAndUpdateIPIfNeeded checks if a container's IP address has changed and updates it if needed
663+func (p *Proxy) checkAndUpdateIPIfNeeded(domain string, route *ProxyRouteInfo, networkName string) {
664+ // Skip checking admin domain
665+ if domain == p.app.GetConfig().Http.FullDomain() {
666+ return
667+ }
668+
669+ // Verify the current container IP
670+ currentIP, err := docker.GetContainerIPFromNetwork(route.ContainerID, networkName)
671+ if err != nil {
672+ // Container might not exist anymore
673+ logger.Warn("Failed to get current IP for container during consistency check",
674+ "domain", domain,
675+ "container_id", route.ContainerID,
676+ "error", err)
677+ return
678+ }
679+
680+ // If IP has changed, update the route
681+ if currentIP != "" && currentIP != route.ContainerIP {
682+ logger.Info("Detected IP mismatch during consistency check",
683+ "domain", domain,
684+ "container_id", route.ContainerID,
685+ "old_ip", route.ContainerIP,
686+ "current_ip", currentIP)
687+
688+ // Update the route
689+ err = p.ForceUpdateRouteIP(domain, currentIP)
690+ if err != nil {
691+ logger.Error("Failed to update IP during consistency check",
692+ "domain", domain,
693+ "error", err)
694+ }
695+ }
696+}
697+
698+// checkAdminDomainProtection checks if a domain is the admin domain and returns an error if it is
699+func (p *Proxy) checkAdminDomainProtection(domain string) error {
700+ adminDomain := p.app.GetConfig().Http.FullDomain()
701+ if domain == adminDomain {
702+ logger.Warn("Attempt to modify admin domain prevented", "domain", domain)
703+ return fmt.Errorf("cannot modify admin domain: %s", domain)
704+ }
705+ return nil
706+}
707+
708+// deleteRouteWithTransaction deletes a route and domain from the database within a transaction
709+func (p *Proxy) deleteRouteWithTransaction(tx *sql.Tx, domainID string) error {
710+ // Delete the route with retry
711+ _, err := txExecWithRetry(tx, p.queries.DeleteRouteByDomainID, domainID)
712+ if err != nil {
713+ return fmt.Errorf("failed to delete route: %w", err)
714+ }
715+
716+ // Delete the domain with retry
717+ _, err = txExecWithRetry(tx, p.queries.DeleteDomainByID, domainID)
718+ if err != nil {
719+ return fmt.Errorf("failed to delete domain: %w", err)
720+ }
721+
722+ return nil
723+}
M internal/proxy/server.go +513 -186
@@ -4,82 +4,152 @@ import (
4 "context"4 "context"
5 "crypto/tls"5 "crypto/tls"
6 "fmt"6 "fmt"
7+ "log"
7 "net/http"8 "net/http"
9+ "os"
10+ "os/exec"
11+ "strconv"
12+ "strings"
13+ "sync"
8 "time"14 "time"
9 15
16+ "net"
17+
10 "github.com/bnema/gordon/pkg/docker"18 "github.com/bnema/gordon/pkg/docker"
11 "github.com/bnema/gordon/pkg/logger"19 "github.com/bnema/gordon/pkg/logger"
12 "github.com/labstack/echo/v4"20 "github.com/labstack/echo/v4"
13 "golang.org/x/crypto/acme"21 "golang.org/x/crypto/acme"
14 )22 )
15 23
24+// Global mutex for admin connection testing to prevent concurrent executions
25+var adminTestMutex sync.Mutex
26+
16 // This file contains server operations functions for the proxy.27 // This file contains server operations functions for the proxy.
17 // These methods may need to be deleted from proxy.go after validation.28 // These methods may need to be deleted from proxy.go after validation.
18 29
19 // Start loads the routes from the database and starts the proxy server30 // Start loads the routes from the database and starts the proxy server
20 func (p *Proxy) Start() error {31 func (p *Proxy) Start() error {
21- // First, load routes from the database32+ // Check if proxy is disabled via configuration
33+ if !p.config.Enabled {
34+ logger.Info("Proxy server is disabled via configuration, not starting")
35+ return nil
36+ }
37+
38+ // Configure proxy ports based on environment
39+ p.configureProxyPorts()
40+
41+ // Load routes from the database
22 if err := p.loadRoutes(); err != nil {42 if err := p.loadRoutes(); err != nil {
23 return fmt.Errorf("failed to load routes: %w", err)43 return fmt.Errorf("failed to load routes: %w", err)
24 }44 }
25 45
26- // Now log Gordon container identity for debugging (after routes are loaded)46+ // Log Gordon container identity and prepare admin route
27 p.LogGordonIdentity()47 p.LogGordonIdentity()
48+ p.setupAdminRoute()
28 49
29- // Scan Gordon network containers and check for certificates50+ // Scan containers for certificates
30 if err := p.scanContainersAndCheckCertificates(); err != nil {51 if err := p.scanContainersAndCheckCertificates(); err != nil {
31 logger.Warn("Failed to scan containers for certificate checks", "error", err)52 logger.Warn("Failed to scan containers for certificate checks", "error", err)
32 }53 }
33 54
34- // Run an immediate route verification to ensure all routes are valid on startup55+ // Discover missing routes and log active containers
35- logger.Info("Performing initial route verification on startup")
36-
37- // Discover any containers that should have routes but don't
38 p.DiscoverMissingRoutes()56 p.DiscoverMissingRoutes()
39-
40- // Log all active containers and their routes for visibility
41 p.LogActiveContainersAndRoutes()57 p.LogActiveContainersAndRoutes()
42 58
43- // Start the container event listener for real-time updates59+ // Start container event listener
44 if err := p.StartContainerEventListener(); err != nil {60 if err := p.StartContainerEventListener(); err != nil {
45 logger.Warn("Failed to start container event listener", "error", err)61 logger.Warn("Failed to start container event listener", "error", err)
46- // Continue anyway, this is non-critical
47 }62 }
48 63
49- // Check if there might be port conflicts with the main server64+ // Start the admin route verification in a separate goroutine
50- mainServerPort := p.app.GetConfig().Http.Port65+ go p.StartAdminRouteVerification()
51 66
52- // Check HTTP port conflict67+ // Check for port conflicts with the main server
53- if p.config.HttpPort == mainServerPort {68+ if p.checkForPortConflicts() {
54- logger.Warn("HTTP port for reverse proxy conflicts with main server port",
55- "port", p.config.HttpPort,
56- "solution", "reverse proxy HTTP server will be disabled")
57 return nil69 return nil
58 }70 }
59 71
60- // Check HTTPS port conflict (less common, but still possible)72+ // Set up middleware and configure routes
61- if p.config.Port == mainServerPort {73+ p.setupMiddleware()
62- logger.Warn("HTTPS port for reverse proxy conflicts with main server port",74+ p.configureRoutes()
63- "port", p.config.Port,75+
64- "solution", "reverse proxy HTTPS server will be disabled")76+ // Configure HTTP server for Let's Encrypt challenges
65- return nil77+ p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil)))
78+
79+ // Create and start HTTP and HTTPS servers
80+ p.serverStarted = true
81+
82+ // Start servers in separate goroutines
83+ go p.startHTTPSServer()
84+ go p.startHTTPServer()
85+
86+ // Log details about the proxy server
87+ logger.Info("Proxy server started",
88+ "http_port", p.config.HttpPort,
89+ "https_port", p.config.Port,
90+ "admin_domain", p.app.GetConfig().Http.FullDomain(),
91+ "container_id", p.gordonContainerID,
92+ )
93+
94+ // Give servers time to start up
95+ logger.Info("Waiting for servers to complete startup...")
96+ time.Sleep(3 * time.Second)
97+
98+ // Test admin connection after server starts
99+ go p.TestAdminConnectionLater()
100+
101+ return nil
102+}
103+
104+// configureProxyPorts configures the proxy ports based on the environment
105+func (p *Proxy) configureProxyPorts() {
106+ // Detect container environment
107+ containerEnv := os.Getenv("container")
108+ _, isContainer := os.LookupEnv("container")
109+
110+ // Log container information if applicable
111+ if isContainer {
112+ logger.Info("Running in container environment", "container_type", containerEnv, "uid", os.Getuid())
113+ }
114+
115+ // When running as non-root user, check for privileged ports
116+ if !isContainer && os.Getuid() != 0 {
117+ // Non-root user - check for privileged ports
118+ httpPort, _ := strconv.Atoi(p.config.HttpPort)
119+ httpsPort, _ := strconv.Atoi(p.config.Port)
120+
121+ // Use non-privileged ports if trying to bind to privileged ports without root
122+ if httpPort < 1024 {
123+ logger.Warn("Using alternative HTTP port 8080 instead of privileged port", "original", p.config.HttpPort)
124+ p.config.HttpPort = "8080"
125+ }
126+
127+ if httpsPort < 1024 {
128+ logger.Warn("Using alternative HTTPS port 8443 instead of privileged port", "original", p.config.Port)
129+ p.config.Port = "8443"
130+ }
66 }131 }
67 132
68- // Add a special route for the admin domain (Gordon itself)133+ // Log final port configuration
134+ logger.Info("Final proxy port configuration",
135+ "http_port", p.config.HttpPort,
136+ "https_port", p.config.Port,
137+ "in_container", isContainer)
138+}
139+
140+// setupAdminRoute adds a special route for the admin domain (Gordon itself)
141+func (p *Proxy) setupAdminRoute() {
69 adminDomain := p.app.GetConfig().Http.FullDomain()142 adminDomain := p.app.GetConfig().Http.FullDomain()
143+ logger.Info("Setting up admin domain route", "domain", adminDomain)
70 144
71 // Auto-detect the Gordon container name and ID145 // Auto-detect the Gordon container name and ID
72 containerName := p.detectGordonContainer()146 containerName := p.detectGordonContainer()
73-
74- // Get the network name from config
75 networkName := p.app.GetConfig().ContainerEngine.Network147 networkName := p.app.GetConfig().ContainerEngine.Network
76 148
77- // Initialize container IP149+ // Get the IP address from the container's network
78- var containerIP string
79-
80- // Try to get the actual container IP from the network
81 if p.gordonContainerID == "" {150 if p.gordonContainerID == "" {
82- return fmt.Errorf("failed to detect Gordon container ID, cannot add admin route")151+ logger.Error("Failed to detect Gordon container ID, cannot add admin route")
152+ return
83 }153 }
84 154
85 // Get the IP address from the container's network155 // Get the IP address from the container's network
@@ -89,147 +159,171 @@ func (p *Proxy) Start() error {
89 "container_id", p.gordonContainerID,159 "container_id", p.gordonContainerID,
90 "network", networkName,160 "network", networkName,
91 "error", err)161 "error", err)
92- return fmt.Errorf("failed to get Gordon container IP from network %s: %w", networkName, err)162+ return
93 }163 }
94 164
95- containerIP = ip165+ // Add the admin route to the database
96- logger.Info("Using Gordon container IP from network",
97- "container_id", p.gordonContainerID,
98- "network", networkName,
99- "ip", containerIP)
100-
101- // Check if admin route exists in database and create/update it
102 err = p.AddRoute(166 err = p.AddRoute(
103 adminDomain,167 adminDomain,
104 containerName,168 containerName,
105- containerIP,169+ ip,
106 p.app.GetConfig().Http.Port,170 p.app.GetConfig().Http.Port,
107 "http", // Gordon server uses HTTP internally171 "http", // Gordon server uses HTTP internally
108 "/",172 "/",
109 )173 )
110 174
111 if err != nil {175 if err != nil {
112- logger.Error("Failed to add admin route to database",176+ logger.Error("Failed to add admin route to database", "error", err, "domain", adminDomain)
113- "error", err,
114- "domain", adminDomain)
115 } else {177 } else {
116- logger.Info("Ensured admin domain route is in database",178+ logger.Info("Ensured admin domain route is in database", "domain", adminDomain)
117- "domain", adminDomain,
118- "container", containerName,
119- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
120 }179 }
121 180
181+ // Add to in-memory routes if it doesn't already exist
122 p.mu.Lock()182 p.mu.Lock()
123- // Only add if it doesn't already exist in memory
124 if _, exists := p.routes[adminDomain]; !exists {183 if _, exists := p.routes[adminDomain]; !exists {
125 p.routes[adminDomain] = &ProxyRouteInfo{184 p.routes[adminDomain] = &ProxyRouteInfo{
126 Domain: adminDomain,185 Domain: adminDomain,
127- ContainerIP: containerIP,186+ ContainerIP: ip,
128 ContainerPort: p.app.GetConfig().Http.Port,187 ContainerPort: p.app.GetConfig().Http.Port,
129- ContainerID: containerName, // Use auto-detected container name188+ ContainerID: containerName,
130- Protocol: "http", // Gordon server uses HTTP internally189+ Protocol: "http", // Gordon server uses HTTP internally
131 Path: "/",190 Path: "/",
132 Active: true,191 Active: true,
133 }192 }
134- logger.Info("Added special route for admin domain",193+ logger.Info("Added special route for admin domain", "domain", adminDomain)
135- "domain", adminDomain,
136- "container", containerName,
137- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
138- } else {
139- logger.Debug("Admin domain route already exists",
140- "domain", adminDomain,
141- "route", fmt.Sprintf("%s://%s:%s",
142- p.routes[adminDomain].Protocol,
143- p.routes[adminDomain].ContainerIP,
144- p.routes[adminDomain].ContainerPort))
145 }194 }
146 p.mu.Unlock()195 p.mu.Unlock()
196+}
147 197
148- // Set up middleware198+// checkForPortConflicts checks if there are port conflicts with the main server
149- p.setupMiddleware()199+// Returns true if conflicts are found and the proxy should not be started
200+func (p *Proxy) checkForPortConflicts() bool {
201+ mainServerPort := p.app.GetConfig().Http.Port
150 202
151- // Configure routes203+ // Check HTTP port conflict
152- p.configureRoutes()204+ if p.config.HttpPort == mainServerPort {
205+ logger.Warn("HTTP port for reverse proxy conflicts with main server port",
206+ "port", p.config.HttpPort,
207+ "solution", "reverse proxy HTTP server will be disabled")
208+ return true
209+ }
153 210
154- // Configure HTTP server to handle Let's Encrypt HTTP-01 challenges211+ // Check HTTPS port conflict
155- // and redirect everything else to HTTPS212+ if p.config.Port == mainServerPort {
156- p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil)))213+ logger.Warn("HTTPS port for reverse proxy conflicts with main server port",
214+ "port", p.config.Port,
215+ "solution", "reverse proxy HTTPS server will be disabled")
216+ return true
217+ }
218+
219+ return false
220+}
157 221
158- // Create a custom HTTPS server with proper TLS config222+// startHTTPSServer starts the HTTPS server
223+func (p *Proxy) startHTTPSServer() {
224+ // Create HTTPS server
159 httpsServer := &http.Server{225 httpsServer := &http.Server{
160 Addr: fmt.Sprintf(":%s", p.config.Port),226 Addr: fmt.Sprintf(":%s", p.config.Port),
161 Handler: p.httpsServer,227 Handler: p.httpsServer,
162 TLSConfig: &tls.Config{228 TLSConfig: &tls.Config{
163 GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {229 GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
164- // Log SNI information in debug mode
165- if hello.ServerName != "" {
166- logger.Debug("TLS handshake with SNI",
167- "server_name", hello.ServerName)
168- }
169-
170 // Try to get the certificate from the autocert manager230 // Try to get the certificate from the autocert manager
171 cert, err := p.certManager.GetCertificate(hello)231 cert, err := p.certManager.GetCertificate(hello)
172 232
173- // If we can't get a certificate and we have a fallback, use it for the admin domain233+ // If no cert available and we have a fallback, use it for admin domain
234+ adminDomain := p.app.GetConfig().Http.FullDomain()
174 if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil {235 if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil {
175- logger.Debug("Using fallback certificate for admin domain",236+ logger.Info("Using fallback certificate for admin domain", "domain", adminDomain)
176- "domain", adminDomain,
177- "error", err)
178 return p.fallbackCert, nil237 return p.fallbackCert, nil
179 }238 }
180 239
181 return cert, err240 return cert, err
182 },241 },
183 MinVersion: tls.VersionTLS12,242 MinVersion: tls.VersionTLS12,
184- // Add server name to use when client doesn't send SNI
185 ServerName: p.app.GetConfig().Http.FullDomain(),243 ServerName: p.app.GetConfig().Http.FullDomain(),
186- // Add support for TLS-ALPN-01 challenges
187 NextProtos: []string{acme.ALPNProto},244 NextProtos: []string{acme.ALPNProto},
188 },245 },
189- // Add timeouts to prevent hanging connections
190 ReadTimeout: 30 * time.Second,246 ReadTimeout: 30 * time.Second,
191 WriteTimeout: 30 * time.Second,247 WriteTimeout: 30 * time.Second,
192 IdleTimeout: 120 * time.Second,248 IdleTimeout: 120 * time.Second,
249+ ErrorLog: log.New(os.Stderr, "[HTTPS] ", log.LstdFlags),
250+ }
251+
252+ // Try to start the server with different approaches depending on environment
253+ logger.Info("Starting HTTPS server", "port", p.config.Port)
254+
255+ // First try explicit IPv4 binding
256+ httpsAddr := fmt.Sprintf("0.0.0.0:%s", p.config.Port)
257+ tcpListener, err := net.Listen("tcp4", httpsAddr)
258+
259+ if err != nil {
260+ logger.Warn("Failed to create HTTPS listener with explicit IPv4 binding",
261+ "error", err, "port", p.config.Port)
262+
263+ // Try simple binding as fallback
264+ if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
265+ logger.Error("HTTPS server failed", "error", err, "port", p.config.Port)
266+ }
267+ return
268+ }
269+
270+ // Create TLS listener
271+ tlsListener := tls.NewListener(tcpListener, httpsServer.TLSConfig)
272+ logger.Info("HTTPS server is now accepting connections", "port", p.config.Port)
273+
274+ // Start server with explicit listener
275+ if err := httpsServer.Serve(tlsListener); err != nil && err != http.ErrServerClosed {
276+ logger.Error("HTTPS server failed", "error", err, "port", p.config.Port)
193 }277 }
278+}
194 279
195- // Create a custom HTTP server280+// startHTTPServer starts the HTTP server
281+func (p *Proxy) startHTTPServer() {
282+ // Create HTTP server
196 httpServer := &http.Server{283 httpServer := &http.Server{
197 Addr: fmt.Sprintf(":%s", p.config.HttpPort),284 Addr: fmt.Sprintf(":%s", p.config.HttpPort),
198 Handler: p.httpServer,285 Handler: p.httpServer,
199 ReadTimeout: 30 * time.Second,286 ReadTimeout: 30 * time.Second,
200 WriteTimeout: 30 * time.Second,287 WriteTimeout: 30 * time.Second,
201 IdleTimeout: 120 * time.Second,288 IdleTimeout: 120 * time.Second,
289+ ErrorLog: log.New(os.Stderr, "[HTTP] ", log.LstdFlags),
202 }290 }
203 291
204- p.serverStarted = true292+ // Try to start the server with different approaches depending on environment
293+ logger.Info("Starting HTTP server", "port", p.config.HttpPort)
205 294
206- // Start HTTPS server295+ // First try explicit IPv4 binding
207- go func() {296+ httpAddr := fmt.Sprintf("0.0.0.0:%s", p.config.HttpPort)
208- logger.Info("Starting HTTPS server", "address", httpsServer.Addr)297+ httpListener, err := net.Listen("tcp4", httpAddr)
209- // Using empty strings for cert and key files since we're using GetCertificate
210- if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
211- logger.Error("HTTPS server failed", "error", err)
212- }
213- }()
214 298
215- // Start HTTP server299+ if err != nil {
216- go func() {300+ logger.Warn("Failed to create HTTP listener with explicit IPv4 binding",
217- logger.Info("Starting HTTP server", "address", httpServer.Addr)301+ "error", err, "port", p.config.HttpPort)
302+
303+ // Try simple binding as fallback
218 if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {304 if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
219- logger.Error("HTTP server failed", "error", err)305+ logger.Error("HTTP server failed", "error", err, "port", p.config.HttpPort)
220 }306 }
221- }()307+ return
222-308+ }
223- // Test admin connection after server starts
224- go p.TestAdminConnectionLater()
225 309
226- return nil310+ // Start server with explicit listener
311+ logger.Info("HTTP server is now accepting connections", "port", p.config.HttpPort)
312+ if err := httpServer.Serve(httpListener); err != nil && err != http.ErrServerClosed {
313+ logger.Error("HTTP server failed", "error", err, "port", p.config.HttpPort)
314+ }
227 }315 }
228 316
229 // Stop gracefully shuts down the proxy server317 // Stop gracefully shuts down the proxy server
230 func (p *Proxy) Stop() error {318 func (p *Proxy) Stop() error {
231 logger.Info("Stopping reverse proxy")319 logger.Info("Stopping reverse proxy")
232 320
321+ // Check if proxy is disabled via configuration
322+ if !p.config.Enabled {
323+ logger.Info("Proxy server is disabled via configuration, nothing to stop")
324+ return nil
325+ }
326+
233 // Run any rate limiter cleanup functions327 // Run any rate limiter cleanup functions
234 for _, cleanup := range rateLimiterCleanup {328 for _, cleanup := range rateLimiterCleanup {
235 cleanup()329 cleanup()
@@ -325,21 +419,178 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
325 419
326 // TestAdminConnectionLater tests the admin connection after a short delay420 // TestAdminConnectionLater tests the admin connection after a short delay
327 func (p *Proxy) TestAdminConnectionLater() {421 func (p *Proxy) TestAdminConnectionLater() {
328- // Wait for servers to start422+ // Try to acquire the lock, return immediately if another test is in progress
329- time.Sleep(2 * time.Second)423+ if !adminTestMutex.TryLock() {
424+ logger.Debug("Admin connection test already in progress, skipping")
425+ return
426+ }
427+ defer adminTestMutex.Unlock()
428+
429+ // Wait for servers to start - give plenty of time for binding to complete
430+ time.Sleep(10 * time.Second)
431+
432+ // Log that we're beginning the connection test
433+ logger.Info("Beginning admin connection test after server startup")
434+
435+ // First verify if ports 80 and 443 are actually bound locally
436+ verifyLocalPortBinding := func(port string) bool {
437+ // Try a local connection to the port
438+ conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%s", port), 2*time.Second)
439+ if err != nil {
440+ logger.Error(fmt.Sprintf("Failed to connect to localhost:%s - port may not be bound", port),
441+ "error", err)
442+ return false
443+ }
444+ conn.Close()
445+ logger.Info(fmt.Sprintf("Successfully verified port %s is bound locally", port))
446+ return true
447+ }
448+
449+ // Check if we can connect to ports 80 and 443 locally
450+ httpBound := verifyLocalPortBinding(p.config.HttpPort)
451+ httpsBound := verifyLocalPortBinding(p.config.Port)
452+
453+ if !httpBound || !httpsBound {
454+ logger.Error("One or more ports are not bound locally - proxy may not be functioning",
455+ "http_port_ok", httpBound,
456+ "https_port_ok", httpsBound)
457+ }
330 458
331 logger.Debug("Testing admin connections after server startup")459 logger.Debug("Testing admin connections after server startup")
332 460
333 // Get domain and current route461 // Get domain and current route
334 adminDomain := p.app.GetConfig().Http.FullDomain()462 adminDomain := p.app.GetConfig().Http.FullDomain()
335 463
464+ // First, check if the route exists in memory
336 p.mu.RLock()465 p.mu.RLock()
337 route, exists := p.routes[adminDomain]466 route, exists := p.routes[adminDomain]
338 p.mu.RUnlock()467 p.mu.RUnlock()
339 468
469+ // Add debug logging for admin route configuration
470+ if exists {
471+ logger.Debug("Current admin route configuration",
472+ "domain", adminDomain,
473+ "container_ip", route.ContainerIP,
474+ "container_port", route.ContainerPort,
475+ "protocol", route.Protocol)
476+
477+ // Get the container's own IP by checking network interfaces
478+ containerIP := p.getOwnContainerIP()
479+ adminPath := p.app.GetConfig().Admin.Path
480+ url := fmt.Sprintf("http://%s:%s%s/ping", containerIP, route.ContainerPort, adminPath)
481+
482+ logger.Debug("Testing direct IP connection", "url", url)
483+ client := &http.Client{
484+ Timeout: 5 * time.Second,
485+ }
486+
487+ resp, err := client.Get(url)
488+ if err != nil {
489+ logger.Error("Failed to connect to admin with direct IP",
490+ "url", url,
491+ "error", err)
492+ } else {
493+ resp.Body.Close()
494+ logger.Info("Successfully connected to admin with direct IP",
495+ "url", url,
496+ "status", resp.Status)
497+
498+ // If localhost is being used, update the route to use the container IP
499+ if route.ContainerIP == "localhost" || route.ContainerIP == "127.0.0.1" {
500+ logger.Info("Updating admin route from localhost to container IP",
501+ "old_ip", route.ContainerIP,
502+ "new_ip", containerIP)
503+
504+ p.mu.Lock()
505+ if r, ok := p.routes[adminDomain]; ok {
506+ r.ContainerIP = containerIP
507+ }
508+ p.mu.Unlock()
509+
510+ // Reconfigure routes with the new IP
511+ p.configureRoutes()
512+
513+ // Also update in database if possible
514+ err := p.ForceUpdateRouteIP(adminDomain, containerIP)
515+ if err != nil {
516+ logger.Warn("Failed to update admin route IP in database",
517+ "error", err,
518+ "domain", adminDomain)
519+ }
520+ }
521+ }
522+ }
523+
340 if !exists {524 if !exists {
341- logger.Warn("Admin route not found")525+ logger.Warn("Admin route not found in memory, creating it now")
342- return526+ // Auto-detect the Gordon container name and ID
527+ containerName := p.detectGordonContainer()
528+
529+ // Get the network name from config
530+ networkName := p.app.GetConfig().ContainerEngine.Network
531+
532+ // Try to get the actual container IP from the network
533+ if p.gordonContainerID == "" {
534+ logger.Error("Failed to detect Gordon container ID, cannot add admin route")
535+ return
536+ }
537+
538+ // Get the IP address from the container's network
539+ ip, err := docker.GetContainerIPFromNetwork(p.gordonContainerID, networkName)
540+ if err != nil || ip == "" {
541+ logger.Error("Failed to get Gordon container IP from network",
542+ "container_id", p.gordonContainerID,
543+ "network", networkName,
544+ "error", err)
545+ ip = "localhost" // Fallback to localhost
546+ }
547+
548+ // Create the route in the database
549+ err = p.AddRoute(
550+ adminDomain,
551+ containerName,
552+ ip,
553+ p.app.GetConfig().Http.Port,
554+ "http", // Gordon server uses HTTP internally
555+ "/",
556+ )
557+
558+ if err != nil {
559+ logger.Error("Failed to add admin route to database during recovery",
560+ "error", err,
561+ "domain", adminDomain)
562+ } else {
563+ logger.Info("Successfully recreated admin domain route in database",
564+ "domain", adminDomain,
565+ "container", containerName,
566+ "target", fmt.Sprintf("http://%s:%s", ip, p.app.GetConfig().Http.Port))
567+ }
568+
569+ // Add route to memory directly to ensure it's available immediately
570+ p.mu.Lock()
571+ p.routes[adminDomain] = &ProxyRouteInfo{
572+ Domain: adminDomain,
573+ ContainerIP: ip,
574+ ContainerPort: p.app.GetConfig().Http.Port,
575+ ContainerID: containerName,
576+ Protocol: "http", // Gordon server uses HTTP internally
577+ Path: "/",
578+ Active: true,
579+ }
580+ p.mu.Unlock()
581+
582+ // Reconfigure routes to apply the changes
583+ p.configureRoutes()
584+
585+ // Update route variable for the next steps
586+ p.mu.RLock()
587+ route, exists = p.routes[adminDomain]
588+ p.mu.RUnlock()
589+
590+ if !exists {
591+ logger.Error("Failed to create admin route, route still doesn't exist after recreation attempt")
592+ return
593+ }
343 }594 }
344 595
345 // Test connection and update if needed596 // Test connection and update if needed
@@ -351,14 +602,43 @@ func (p *Proxy) TestAdminConnectionLater() {
351 logger.Info("Updated admin route with working connection",602 logger.Info("Updated admin route with working connection",
352 "domain", adminDomain,603 "domain", adminDomain,
353 "ip", testedIP)604 "ip", testedIP)
605+
606+ // Update the route in database as well
607+ err := p.ForceUpdateRouteIP(adminDomain, testedIP)
608+ if err != nil {
609+ logger.Warn("Failed to update admin route IP in database",
610+ "error", err,
611+ "domain", adminDomain)
612+ }
354 }613 }
355 p.mu.Unlock()614 p.mu.Unlock()
615+
616+ // Reconfigure routes to apply the IP change
617+ p.configureRoutes()
356 }618 }
357 619
358 // Check external port access620 // Check external port access
359 p.checkExternalPortAccess()621 p.checkExternalPortAccess()
360 }622 }
361 623
624+// checkPorts is a helper function to diagnose what might be using a port
625+func checkPorts(protocol, port string) {
626+ // This is just a debugging helper - errors are ignored
627+ // Try using netstat to check port usage
628+ output, err := exec.Command("netstat", "-tuln").CombinedOutput()
629+ if err == nil {
630+ logger.Debug("Network port status", "output", string(output))
631+ }
632+
633+ // Try using lsof to check port usage
634+ output, err = exec.Command("lsof", "-i", fmt.Sprintf("%s:%s", protocol, port)).CombinedOutput()
635+ if err == nil && len(output) > 0 {
636+ logger.Debug("Processes using port", "port", port, "output", string(output))
637+ } else {
638+ logger.Debug("No process found using port with lsof", "port", port)
639+ }
640+}
641+
362 // checkExternalPortAccess verifies if ports 80 and 443 are accessible642 // checkExternalPortAccess verifies if ports 80 and 443 are accessible
363 func (p *Proxy) checkExternalPortAccess() {643 func (p *Proxy) checkExternalPortAccess() {
364 adminDomain := p.app.GetConfig().Http.FullDomain()644 adminDomain := p.app.GetConfig().Http.FullDomain()
@@ -406,82 +686,129 @@ func (p *Proxy) checkExternalPortAccess() {
406 }686 }
407 }687 }
408 688
409-// // detectGordonContainer attempts to find the Gordon container automatically689+// getOwnContainerIP attempts to determine the container's own IP address
410-// func (p *Proxy) detectGordonContainer() string {690+// from network interfaces, preferring non-loopback interfaces
411-// // Check if we're running inside a container691+func (p *Proxy) getOwnContainerIP() string {
412-// if docker.IsRunningInContainer() {692+ // Default fallback IP based on common container network range
413-// logger.Debug("Detected we're running in a container")693+ defaultIP := "10.89.0.118" // Updated to match the observed IP in logs
414-694+
415-// // Get our hostname which should be the container ID in Docker/Podman695+ // Use environment variable if available (can be set in container config)
416-// hostname := os.Getenv("HOSTNAME")696+ if envIP := os.Getenv("GORDON_CONTAINER_IP"); envIP != "" {
417-// if hostname != "" {697+ logger.Debug("Using container IP from environment variable", "ip", envIP)
418-// // Check if there's a container with this hostname698+ return envIP
419-// containers, err := docker.ListRunningContainers()699+ }
420-// if err == nil {700+
421-// for _, container := range containers {701+ // Try to get the IP from the Gordon network - this is the most reliable method
422-// // Check if this container is us by comparing hostname with container ID702+ networkName := p.app.GetConfig().ContainerEngine.Network
423-// if strings.HasPrefix(hostname, container.ID) {703+ if p.gordonContainerID != "" {
424-// // This is our container704+ ip, err := docker.GetContainerIPFromNetwork(p.gordonContainerID, networkName)
425-// containerName := strings.TrimLeft(container.Names[0], "/")705+ if err == nil && ip != "" {
426-// logger.Debug("Auto-detected Gordon container name from hostname", "container_name", containerName)706+ logger.Info("Using container IP from Docker network", "ip", ip, "network", networkName)
427-// return containerName707+ return ip
428-// }708+ } else {
429-// }709+ logger.Error("Failed to get container IP from Docker network",
430-// } else {710+ "error", err,
431-// logger.Debug("Could not list containers when checking hostname", "error", err)711+ "container_id", p.gordonContainerID,
432-// }712+ "network", networkName)
433-// }713+ }
434-// }714+ } else {
435-715+ logger.Error("Gordon container ID not set, cannot get container IP from Docker network")
436-// // Try to find Gordon by examining container processes716+ }
437-// containers, err := docker.ListRunningContainers()717+
438-// if err == nil {718+ // Try to detect by listing network interfaces
439-// for _, container := range containers {719+ ifaces, err := net.Interfaces()
440-// containerID := container.ID720+ if err != nil {
441-721+ logger.Error("Failed to list network interfaces", "error", err)
442-// // Check container processes for Gordon722+ return defaultIP
443-// containerLogs, err := docker.GetContainerLogs(containerID)723+ }
444-// if err == nil && (strings.Contains(containerLogs, "/gordon") || strings.Contains(containerLogs, "serve")) {724+
445-// containerName := strings.TrimLeft(container.Names[0], "/")725+ // First, look for eth0 or similar standard interfaces
446-// logger.Debug("Auto-detected Gordon container by process", "container_name", containerName)726+ for _, iface := range ifaces {
447-// return containerName727+ // Skip loopback
448-// } else if err != nil {728+ if iface.Flags&net.FlagLoopback != 0 {
449-// logger.Debug("Could not get container logs", "container_id", containerID, "error", err)729+ continue
450-// }730+ }
451-731+
452-// // Check container command for Gordon732+ // Prefer eth0 or similar
453-// if len(container.Command) > 0 && strings.Contains(container.Command, "gordon") {733+ if strings.HasPrefix(iface.Name, "eth") || strings.HasPrefix(iface.Name, "en") {
454-// containerName := strings.TrimLeft(container.Names[0], "/")734+ addrs, err := iface.Addrs()
455-// logger.Debug("Auto-detected Gordon container by command", "container_name", containerName)735+ if err != nil {
456-// return containerName736+ continue
457-// }737+ }
458-// }738+
459-// } else {739+ for _, addr := range addrs {
460-// logger.Debug("Could not list containers when checking processes", "error", err)740+ ip, _, err := net.ParseCIDR(addr.String())
461-// }741+ if err != nil {
462-742+ continue
463-// // If we couldn't detect from processes, try to find a container with gordon in the name or image743+ }
464-// if err == nil {744+
465-// for _, container := range containers {745+ // Only use IPv4 addresses
466-// // Check container names for "gordon"746+ if ipv4 := ip.To4(); ipv4 != nil {
467-// for _, name := range container.Names {747+ logger.Debug("Found container IP from network interface",
468-// if strings.Contains(strings.ToLower(name), "gordon") {748+ "interface", iface.Name,
469-// containerName := strings.TrimLeft(name, "/")749+ "ip", ipv4.String())
470-// logger.Debug("Auto-detected Gordon container by name", "container_name", containerName)750+ return ipv4.String()
471-// return containerName751+ }
472-// }752+ }
473-// }753+ }
474-754+ }
475-// // Check image name for "gordon"755+
476-// if strings.Contains(strings.ToLower(container.Image), "gordon") {756+ // If no preferred interface found, use any non-loopback interface
477-// containerName := strings.TrimLeft(container.Names[0], "/")757+ for _, iface := range ifaces {
478-// logger.Debug("Auto-detected Gordon container by image", "container_name", containerName, "image", container.Image)758+ if iface.Flags&net.FlagLoopback != 0 {
479-// return containerName759+ continue
480-// }760+ }
481-// }761+
482-// }762+ addrs, err := iface.Addrs()
483-763+ if err != nil {
484-// // If all else fails, default to "gordon"764+ continue
485-// logger.Debug("Could not auto-detect Gordon container, using default name", "container_name", "gordon")765+ }
486-// return "gordon"766+
487-// }767+ for _, addr := range addrs {
768+ ip, _, err := net.ParseCIDR(addr.String())
769+ if err != nil {
770+ continue
771+ }
772+
773+ if ipv4 := ip.To4(); ipv4 != nil {
774+ logger.Debug("Found container IP from fallback interface",
775+ "interface", iface.Name,
776+ "ip", ipv4.String())
777+ return ipv4.String()
778+ }
779+ }
780+ }
781+
782+ logger.Warn("Could not determine container IP, using default", "ip", defaultIP)
783+ return defaultIP
784+}
785+
786+// StartAdminRouteVerification starts a periodic verification of admin routes
787+// to ensure they are correctly registered and functioning
788+func (p *Proxy) StartAdminRouteVerification() {
789+ ticker := time.NewTicker(30 * time.Second)
790+ defer ticker.Stop()
791+
792+ logger.Debug("Starting admin route verification service")
793+
794+ for {
795+ select {
796+ case <-ticker.C:
797+ adminDomain := p.app.GetConfig().Http.FullDomain()
798+
799+ // Check if the admin route exists in the routes map
800+ p.mu.RLock()
801+ _, exists := p.routes[adminDomain]
802+ p.mu.RUnlock()
803+
804+ if !exists {
805+ logger.Warn("Admin route missing during periodic check, recreating it")
806+ p.TestAdminConnectionLater()
807+ continue
808+ }
809+
810+ // Test the existing admin connection
811+ p.TestAdminConnectionLater()
812+ }
813+ }
814+}
D internal/proxy/utils.go +0 -26
@@ -1,26 +0,0 @@
1-package proxy
2-
3-import (
4- "crypto/rand"
5- "fmt"
6- "time"
7-
8- "github.com/google/uuid"
9-)
10-
11-// proxyGenerateUUID generates a random UUID for use in various identifiers
12-// This is a renamed version to avoid conflicts until we clean up proxy.go
13-func proxyGenerateUUID() string {
14- id, err := uuid.NewRandom()
15- if err != nil {
16- // If we can't generate a UUID using the standard library,
17- // fall back to our own implementation
18- b := make([]byte, 16)
19- _, err := rand.Read(b)
20- if err != nil {
21- return fmt.Sprintf("%d", time.Now().UnixNano())
22- }
23- return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
24- }
25- return id.String()
26-}
M internal/templating/models/html/admin/createcontainerfull.gohtml +3 -3
@@ -2,13 +2,13 @@
2 <html lang="en">2 <html lang="en">
3 {{template "header.gohtml" .}}3 {{template "header.gohtml" .}}
4 4
5- <body class="bg-zinc-200 flex flex-col lg:flex-row p-0">5+ <body class="bg-app flex flex-col lg:flex-row p-0">
6 {{template "menu.gohtml" .}}6 {{template "menu.gohtml" .}}
7 <!-- Main content here -->7 <!-- Main content here -->
8 <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">8 <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
9 <div class="flex flex-col md:flex-row w-full flex-grow">9 <div class="flex flex-col md:flex-row w-full flex-grow">
10- <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4">10+ <div class="card flex-grow">
11- <h1 class="text-2xl font-bold mb-4">Edit Container</h1>11+ <h1 class="card-header mb-4">Edit Container</h1>
12 <!-- Include the existing edit container fragment here -->12 <!-- Include the existing edit container fragment here -->
13 {{template "createcontainer.gohtml" .}}13 {{template "createcontainer.gohtml" .}}
14 </div>14 </div>
M internal/templating/models/html/admin/dashboard.gohtml +1 -1
@@ -1,6 +1,6 @@
1 <div id="dashboard">1 <div id="dashboard">
2 <h12 <h1
3- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"3+ class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s"
4 >4 >
5 Hello World from dashboard.html5 Hello World from dashboard.html
6 </h1>6 </h1>
M internal/templating/models/html/admin/index.gohtml +9 -9
@@ -2,23 +2,23 @@
2 <html lang="en">2 <html lang="en">
3 {{template "header.gohtml" .}}3 {{template "header.gohtml" .}}
4 4
5- <body class="bg-zinc-200 flex flex-col md:flex-row p-4 md:p-0">5+ <body class="bg-app flex flex-col md:flex-row p-4 md:p-0">
6 {{template "menu.gohtml" .}}6 {{template "menu.gohtml" .}}
7 <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4">7 <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4">
8 <div8 <div
9 class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">9 class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
10- <div class="flex-grow bg-white p-4 rounded-md shadow mb-4">10+ <div class="card flex-grow">
11- <h2>{{index .Lang "dashboard" "content" "title"}}</h2>11+ <h2 class="card-header">{{index .Lang "dashboard" "content" "title"}}</h2>
12 </div>12 </div>
13 <div class="w-full md:w-2/6 flex flex-col space-y-4">13 <div class="w-full md:w-2/6 flex flex-col space-y-4">
14- <div class="bg-white p-4 rounded-md shadow mb-4">14+ <div class="card">
15- <div class="w-full h-44 rounded-sm bg-gray-300"></div>15+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
16 </div>16 </div>
17- <div class="bg-white p-4 rounded-md shadow mb-4">17+ <div class="card">
18- <div class="w-full h-44 rounded-sm bg-gray-300"></div>18+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
19 </div>19 </div>
20- <div class="bg-white p-4 rounded-md shadow mb-4">20+ <div class="card">
21- <div class="w-full h-44 rounded-sm bg-gray-300"></div>21+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
22 </div>22 </div>
23 </div>23 </div>
24 </div>24 </div>
M internal/templating/models/html/admin/install.gohtml +7 -7
@@ -21,7 +21,7 @@
21 </head>21 </head>
22 22
23 <body23 <body
24- class="IBMPlexSans bg-zinc-200 md:p-8 flex flex-col items-center"24+ class="IBMPlexSans bg-app md:p-8 flex flex-col items-center"
25 hx-boost="true"25 hx-boost="true"
26 >26 >
27 <!-- Logo and Title -->27 <!-- Logo and Title -->
@@ -38,28 +38,28 @@
38 </div>38 </div>
39 <div class="flex justify-center mt-4">39 <div class="flex justify-center mt-4">
40 <h140 <h1
41- class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-gordonmascot-hblue tracking-tighter"41+ class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter"
42 >42 >
43 Gordon43 Gordon
44 </h1>44 </h1>
45- <span class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2"45+ <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2"
46 >v0.0.1</span46 >v0.0.1</span
47 >47 >
48 </div>48 </div>
49 </div>49 </div>
50 50
51 <!-- Main Content -->51 <!-- Main Content -->
52- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">52+ <div class="card w-2/3">
53 <!-- Built-in Proxy -->53 <!-- Built-in Proxy -->
54 <div id="built-in-proxy" class="mt-8">54 <div id="built-in-proxy" class="mt-8">
55 <div class="flex items-center mb-4">55 <div class="flex items-center mb-4">
56- <div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">56+ <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
57 <span class="text-white font-bold">✓</span>57 <span class="text-white font-bold">✓</span>
58 </div>58 </div>
59- <h2 class="text-lg ml-2">Reverse Proxy</h2>59+ <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
60 </div>60 </div>
61 61
62- <div class="bg-gray-100 p-4 rounded-md">62+ <div class="bg-indigo-50 p-4 rounded-md">
63 <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>63 <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
64 <p>The proxy automatically:</p>64 <p>The proxy automatically:</p>
65 <ul class="list-disc ml-6 mb-4">65 <ul class="list-disc ml-6 mb-4">
M internal/templating/models/html/admin/manager.gohtml +65 -23
@@ -1,34 +1,76 @@
1 <!-- manager.gohtml -->1 <!-- manager.gohtml -->
2 <!DOCTYPE html>2 <!DOCTYPE html>
3-<html lang="en">3+<html lang="en" data-theme="gordon">
4 {{template "header.gohtml" .}}4 {{template "header.gohtml" .}}
5 5
6- <body class="bg-zinc-200 flex flex-col lg:flex-row p-0">6+ <body class="min-h-screen bg-base-200">
7 {{template "menu.gohtml" .}}7 {{template "menu.gohtml" .}}
8- <!-- Main content here -->8+
9- <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">9+ <!-- Main content -->
10- <div class="flex flex-col md:flex-row w-full flex-grow">10+ <div class="container mx-auto py-6 px-4 mt-16">
11- <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4">11+ <div class="text-sm breadcrumbs mb-4">
12- <!-- List all the containers -->12+ <ul>
13- <div13+ <li><a href="{{ .AdminPath }}">Gordon</a></li>
14- id="container-manager"14+ <li>Manager</li>
15- hx-get="/htmx/container-manager"15+ </ul>
16- hx-trigger="load"
17- hx-swap="innerHTML"
18- hx-swap-oob="true"
19- ></div>
20- <!-- List all the images -->
21- <div
22- id="image-manager"
23- hx-get="/htmx/image-manager"
24- hx-trigger="load"
25- hx-swap="innerHTML"
26- hx-swap-oob="true"
27- ></div>
28 </div>16 </div>
17+
18+ <!-- Stats overview -->
19+ <div class="stats shadow mb-6 w-full">
20+ <div class="stat">
21+ <div class="stat-figure text-primary">
22+ <span class="iconf text-3xl">󰡨</span>
23+ </div>
24+ <div class="stat-title">Containers</div>
25+ <div class="stat-value" id="container-count">{{.ContainerCount}}</div>
26+ <div class="stat-desc">Docker containers managed by Gordon</div>
27+ </div>
28+
29+ <div class="stat">
30+ <div class="stat-figure text-secondary">
31+ <span class="iconf text-3xl">󰋊</span>
32+ </div>
33+ <div class="stat-title">Images</div>
34+ <div class="stat-value" id="image-count">{{.ImageCount}}</div>
35+ <div class="stat-desc">Docker images available</div>
36+ </div>
37+ </div>
38+
39+ <!-- Tabs for containers and images -->
40+ <div role="tablist" class="tabs tabs-boxed mb-4">
41+ <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />
42+ <div role="tabpanel" class="tab-content p-0">
43+ <!-- Container manager section -->
44+ <div id="container-manager"
45+ hx-get="/htmx/container-manager"
46+ hx-trigger="load"
47+ hx-swap="innerHTML">
48+ <!-- Container content loaded via HTMX -->
49+ <div class="text-center p-4">
50+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
51+ Loading containers...
52+ </div>
53+ </div>
54+ </div>
55+
56+ <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />
57+ <div role="tabpanel" class="tab-content p-0">
58+ <!-- Image manager section -->
59+ <div id="image-manager"
60+ hx-get="/htmx/image-manager"
61+ hx-trigger="load"
62+ hx-swap="innerHTML">
63+ <!-- Image content loaded via HTMX -->
64+ <div class="text-center p-4">
65+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
66+ Loading images...
67+ </div>
68+ </div>
69+ </div>
70+ </div>
71+
29 </div>72 </div>
30 73
31- <!-- footer -->
32 {{template "footer.gohtml" .}}74 {{template "footer.gohtml" .}}
33 </body>75 </body>
34 </html>76 </html>
M internal/templating/models/html/fragments/components.gohtml +55 -12
@@ -1,20 +1,24 @@
1 <!-- @format -->1 <!-- @format -->
2 2
3-<div id="header">3+<div id="header" class="hero bg-base-200">
4- <h14+ <div class="hero-content text-center">
5- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"5+ <div class="max-w-md">
6- >6+ <h1 class="text-5xl font-bold gordon-title">
7- {{ .body.header.helloworld }}7+ {{ .body.header.helloworld }}
8- </h1>8+ </h1>
9+ <p class="py-6 IBMPlexSans">Welcome to Gordon Container Management system.</p>
10+ <button class="btn btn-primary">Get Started</button>
11+ </div>
12+ </div>
9 </div>13 </div>
10 14
11-<div id="hello">15+<div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
12- <h116+ <div>
13- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"17+ <span class="iconfb">󰋽</span>
14- >18+ <span class="IBMPlexSans">{{ .body.div.hello }}</span>
15- {{ .body.div.hello }}19+ </div>
16- </h1>
17 </div>20 </div>
21+
18 <div22 <div
19 id="header"23 id="header"
20 hx-get="/htmx"24 hx-get="/htmx"
@@ -31,3 +35,42 @@
31 hx-trigger="load"35 hx-trigger="load"
32 hx-swap="outerHTML"36 hx-swap="outerHTML"
33 ></div>37 ></div>
38+
39+<!-- Stats Cards -->
40+<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
41+ <div class="stat bg-base-100 shadow">
42+ <div class="stat-figure text-primary">
43+ <span class="iconfb text-3xl">󰡨</span>
44+ </div>
45+ <div class="stat-title">Containers</div>
46+ <div class="stat-value">31</div>
47+ <div class="stat-desc">Running containers</div>
48+ </div>
49+
50+ <div class="stat bg-base-100 shadow">
51+ <div class="stat-figure text-secondary">
52+ <span class="iconfb text-3xl">󱧕</span>
53+ </div>
54+ <div class="stat-title">Images</div>
55+ <div class="stat-value">42</div>
56+ <div class="stat-desc">Available images</div>
57+ </div>
58+
59+ <div class="stat bg-base-100 shadow">
60+ <div class="stat-figure text-accent">
61+ <span class="iconfb text-3xl">󰙅</span>
62+ </div>
63+ <div class="stat-title">Networks</div>
64+ <div class="stat-value">12</div>
65+ <div class="stat-desc">Container networks</div>
66+ </div>
67+</div>
68+
69+<!-- Example of tabs -->
70+<div class="tabs tabs-boxed my-4">
71+ <a class="tab tab-active">Overview</a>
72+ <a class="tab">Containers</a>
73+ <a class="tab">Images</a>
74+ <a class="tab">Networks</a>
75+ <a class="tab">Volumes</a>
76+</div>
M internal/templating/models/html/fragments/containerlist.gohtml +120 -110
@@ -1,112 +1,122 @@
1-<div class="flex flex-col p-4" hx-ext="response-targets">1+<!-- Container list using daisyUI components -->
2- <div class="flex justify-between items-center mb-1">2+<div class="card w-full bg-base-300">
3- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">3+ <div class="card-body p-4">
4- {{index .Lang "manager" "content" "h1-containers"}}4+
5- </h1>5+ <!-- Action buttons -->
6- <button6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7- id="refresh-containers-button"7+ <h3 class="font-semibold text-base-content">Available Containers: {{ len .Containers }}</h3>
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"8+ <div class="btn-group">
9- title="Refresh container list"9+ <button
10- >10+ class="btn btn-sm btn-primary"
11- <span class="iconf text-xl align-middle">󰑐 </span>11+ hx-get="/htmx/container-manager"
12- Refresh12+ hx-target="#container-manager"
13- </button>13+ hx-swap="innerHTML">
14- </div>14+ <span class="iconf">󰑓</span> Refresh
15- <table class="p-4 border-t-2 table-auto">15+ </button>
16- <thead>16+ </div>
17- <tr class="text-center text-gray-400">17+ </div>
18- <th>{{index .Lang "manager" "content" "thName"}}</th>18+
19- <th>{{index .Lang "manager" "content" "thPorts"}}</th>19+ <!-- Container list -->
20- <th>{{index .Lang "manager" "content" "thEntryPoint"}}</th>20+ <table class="table">
21- <th>{{index .Lang "manager" "content" "thStatus"}}</th>21+ <!-- head -->
22- <th>{{index .Lang "manager" "content" "thState"}}</th>22+ <thead>
23- <th>{{index .Lang "manager" "content" "thActions"}}</th>23+ <tr>
24- </tr>24+ <th>Name</th>
25- </thead>25+ <th>Image</th>
26- <tbody>26+ <th>Status</th>
27- {{ $Lang := .Lang }} 27+ <th>Created</th>
28- {{28+ <th>Actions</th>
29- range.containers29+ </tr>
30- }}30+ </thead>
31- <tr id="container-{{.ID}}" class="text-center hover:bg-slate-100">31+ <tbody>
32- <td>32+ {{ if or .NoContainer (eq (len .Containers) 0) }}
33- <span33+ <tr>
34- title="{{.Container.ID}}"34+ <td colspan="5" class="text-center py-4">
35- class="font-bold"35+ <div class="alert alert-info">
36- >{{.Name}}</span36+ <span class="iconf">ℹ️</span>
37- >37+ <span>No containers found. Upload an image first, then create a container.</span>
38- </td>38+ </div>
39- <td class="port-container">{{ .Ports }}</td>39+ </td>
40- <td class="entrypoint-container">{{.ProxyPort}}</td>
41- <td class="status-container">{{.Status}} (Up since {{.UpSince}})</td>
42- <td style="color: {{.StateColor}};">{{.State}}</td>
43- <td>
44- <button
45- id="start-button-{{.ID}}"
46- hx-post="/htmx/container-manager/start/{{.ID}}"
47- hx-target="#actionResponse-{{.ID}}"
48- hx-target-*="#error-message-{{.ID}}"
49- hx-swap="outerHTML"
50- class="py-2 px-4"
51- >
52- <span title="Start the container" class="iconf text-xl text-blue-600 hover:text-gordonmascot-hblue"></span>
53- </button>
54- <!-- EDIT -->
55- <button
56- id="edit-button-{{.ID}}"
57- hx-get="/htmx/container-manager/edit/{{.ID}}"
58- hx-target="#actionResponse-{{.ID}}"
59- hx-target-*="#error-message-{{.ID}}"
60- hx-swap="innerHTML"
61- class="py-2 px-4"
62- data-active="false"
63- >
64- <span title="Edit the container" class="iconf text-2xl text-blue-600 hover:text-gordonmascot-hblue">󱩽</span>
65- </button>
66- <!-- stop -->
67- <button
68- id="stop-button-{{.ID}}"
69- hx-post="/htmx/container-manager/stop/{{.ID}}"
70- hx-target="#actionResponse-{{.ID}}"
71- hx-target-*="#error-message-{{.ID}}"
72- hx-swap="innerHTML"
73- hx-confirm="{{index $Lang "manager" "content" "confirm-container-stop"}}"
74- class="py-2 px-4"
75- >
76- <span title="Stop the container" class="iconf text-xl text-red-600 hover:text-red-900"></span>
77- </button>
78- <!-- REMOVE -->
79- <button
80- id="remove-button-img-{{.ID}}"
81- hx-delete="/htmx/container-manager/delete/{{.ID}}"
82- hx-target="#actionResponse-{{.ID}}"
83- hx-target-*="#error-message-{{.ID}}"
84- hx-swap="innerHTML"
85- hx-confirm="{{index $Lang "manager" "content" "confirm-delete"}}"
86- class="py-2 px-4"
87- >
88- <!-- iconoir trash -->
89- <span title="Remove the container" class="iconf text-xl text-red-600 hover:text-red-900"></span>
90- </button>
91- </td>
92 </tr>40 </tr>
93- <tr>41+ {{ else }}
94- <td colspan="7">42+ {{ range .Containers }}
95- <div id="actionResponse-{{.ID}}"></div>43+ <tr class="hover">
96- <div id="error-message-{{.ID}}" class="text-red-500"></div>44+ <td>
97- </td>45+ <div class="flex items-center gap-3">
98- </tr>46+ <div>
99- {{ else }}47+ <div class="font-bold">{{ .Names }}</div>
100- <tr>48+ <div class="text-sm opacity-50">{{ .ID }}</div>
101- <td colspan="7" class="mt-2">49+ </div>
102- <p class="text-center text-gray-400 text-xl">50+ </div>
103- {{index $Lang "manager" "content" "no-containers"}}51+ </td>
104- </p>52+ <td>{{ .Image }}</td>
105- </td>53+ <td>
106- </tr>54+ {{ if eq .State "running" }}
107- {{55+ <div class="badge badge-success badge-sm">Running</div>
108- end56+ {{ else if eq .State "exited" }}
109- }}57+ <div class="badge badge-warning badge-sm">Stopped</div>
110- </tbody>58+ {{ else }}
111- </table>59+ <div class="badge badge-ghost badge-sm">{{ .State }}</div>
60+ {{ end }}
61+ </td>
62+ <td>{{ .Created }}</td>
63+ <th>
64+ <div class="dropdown dropdown-end">
65+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
66+ <span class="iconf">󰅀</span>
67+ </div>
68+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40">
69+ {{ if eq .State "running" }}
70+ <li>
71+ <button
72+ class="text-warning flex items-center"
73+ hx-post="/htmx/container-manager/stop/{{ .ID }}"
74+ hx-target="#container-manager"
75+ hx-swap="innerHTML">
76+ <span class="iconf mr-2">󰓛</span> Stop
77+ </button>
78+ </li>
79+ {{ else if eq .State "exited" }}
80+ <li>
81+ <button
82+ class="text-success flex items-center"
83+ hx-post="/htmx/container-manager/start/{{ .ID }}"
84+ hx-target="#container-manager"
85+ hx-swap="innerHTML">
86+ <span class="iconf mr-2">󰐊</span> Start
87+ </button>
88+ </li>
89+ {{ end }}
90+ <li>
91+ <button
92+ class="text-info flex items-center"
93+ hx-get="/htmx/container-manager/edit/{{ .ID }}"
94+ hx-target="#container-manager"
95+ hx-swap="innerHTML">
96+ <span class="iconf mr-2">󰏫</span> Edit
97+ </button>
98+ </li>
99+ <li>
100+ <button
101+ class="text-error flex items-center"
102+ hx-delete="/htmx/container-manager/delete/{{ .ID }}"
103+ hx-target="#container-manager"
104+ hx-swap="innerHTML"
105+ hx-confirm="Are you sure you want to delete this container?">
106+ <span class="iconf mr-2">󰆴</span> Delete
107+ </button>
108+ </li>
109+ </ul>
110+ </div>
111+ </th>
112+ </tr>
113+ {{ end }}
114+ {{ end }}
115+ </tbody>
116+ </table>
117+ </div>
112 </div>118 </div>
119+
120+<script>
121+console.debug('Container list rendered');
122+</script>
M internal/templating/models/html/fragments/createcontainer.gohtml +79 -54
@@ -1,9 +1,12 @@
1 <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">1 <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">
2 <!-- Left Side: Docker Run Options -->2 <!-- Left Side: Docker Run Options -->
3- <div class="flex-grow bg-slate-100 p-4 rounded-md">3+ <div class="flex-grow card p-6">
4- <h2 class="font-bold pb-4">Deploy a container based on {{.ImageName}}</h2>4+ <h2 class="card-title pb-4">Deploy a container based on {{.ImageName}}</h2>
5 {{if .ErrorMessage}}5 {{if .ErrorMessage}}
6- <div class="text-red-500">{{.ErrorMessage}}</div>6+ <div class="alert alert-error">
7+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
8+ <span>{{.ErrorMessage}}</span>
9+ </div>
7 {{ end }}10 {{ end }}
8 <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post" 11 <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post"
9 hx-post="/htmx/create-container/{{.ShortID}}"12 hx-post="/htmx/create-container/{{.ShortID}}"
@@ -11,83 +14,104 @@
11 hx-target="#create-container-{{.ShortID}}"14 hx-target="#create-container-{{.ShortID}}"
12 hx-target-*="#error-message-create-container-{{.ShortID}}">15 hx-target-*="#error-message-create-container-{{.ShortID}}">
13 <!-- Image Name -->16 <!-- Image Name -->
14- <div class="flex space-x-4 pb-4">17+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
15- <div class="w-1/3">18+ <div class="md:col-span-1">
16- <label class="block">Image name</label>19+ <label class="label">
17- <input type="text" name="image_name" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"20+ <span class="label-text">Image name</span>
21+ </label>
22+ <input type="text" name="image_name" class="input input-bordered input-disabled w-full"
18 value="{{.ImageName}}" readonly />23 value="{{.ImageName}}" readonly />
19 </div>24 </div>
20 25
21- <div class="w-2/3">26+ <div class="md:col-span-2">
22- <label class="block">Image ID</label>27+ <label class="label">
23- <input type="text" name="image_id" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"28+ <span class="label-text">Image ID</span>
29+ </label>
30+ <input type="text" name="image_id" class="input input-bordered input-disabled w-full"
24 value="{{.ImageID}}" readonly />31 value="{{.ImageID}}" readonly />
25 </div>32 </div>
26 </div>33 </div>
27 34
28 <!-- Container Name -->35 <!-- Container Name -->
29- <div class="pb-4">36+ <div class="form-control pb-4">
30- <label class="block">Container name</label>37+ <label class="label">
31- <input type="text" name="container_name" class="w-full p-4 rounded-md" value="{{.ContainerName}}" />38+ <span class="label-text">Container name</span>
39+ </label>
40+ <input type="text" name="container_name" class="input input-bordered w-full" value="{{.ContainerName}}" />
32 </div>41 </div>
42+
33 <!-- Http(s) / Subdomain / Domain -->43 <!-- Http(s) / Subdomain / Domain -->
34- <div class="flex pb-4 space-x-4">44+ <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4">
35 <!-- Protocol -->45 <!-- Protocol -->
36- <div class="w-1/4">46+ <div class="md:col-span-3">
37- <label class="block">Protocol</label>47+ <label class="label">
38- <select name="container_protocol" class="w-full p-4 rounded-md border-2 bg-slate-200 border-gray-300">48+ <span class="label-text">Protocol</span>
49+ </label>
50+ <select name="container_protocol" class="select select-bordered w-full">
39 <option value="https">https</option>51 <option value="https">https</option>
40 <option value="http">http</option>52 <option value="http">http</option>
41 </select>53 </select>
42 </div>54 </div>
43 55
44 <!-- Subdomain -->56 <!-- Subdomain -->
45- <div class="w-1/3">57+ <div class="md:col-span-4">
46- <label class="block">Subdomain</label>58+ <label class="label">
47- <input type="text" name="container_subdomain" class="w-full p-4 rounded-md" value="{{.ContainerSubdomain}}" />59+ <span class="label-text">Subdomain</span>
60+ </label>
61+ <input type="text" name="container_subdomain" class="input input-bordered w-full" value="{{.ContainerSubdomain}}" />
48 </div>62 </div>
49 63
50 <!-- Domain -->64 <!-- Domain -->
51- <div class="w-5/12">65+ <div class="md:col-span-5">
52- <label class="block">Domain</label>66+ <label class="label">
53- <input type="text" name="container_domain" class="w-full p-4 rounded-md" value="{{.ContainerDomain}}" />67+ <span class="label-text">Domain</span>
68+ </label>
69+ <input type="text" name="container_domain" class="input input-bordered w-full" value="{{.ContainerDomain}}" />
54 </div>70 </div>
55 </div>71 </div>
72+
56 <!-- Exposed Ports -->73 <!-- Exposed Ports -->
57- <div class="pb-4">74+ <div class="form-control pb-4">
58- <label class="block">Exposed Ports (Optional)</label>75+ <label class="label">
59- <span class="text-gray-500">76+ <span class="label-text">Exposed Ports (Optional)</span>
60- eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)77+ <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
61- </span>78+ </label>
62- <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" />79+ <input type="text" name="ports" class="input input-bordered w-full" value="{{.Ports}}" />
63 </div>80 </div>
64 81
65 <!-- Proxy Port -->82 <!-- Proxy Port -->
66- <div class="mb-6">83+ <div class="form-control pb-4">
67- <label class="block">Container Proxy Port (Required)</label>84+ <label class="label">
68- <div class="text-xs text-gray-500 mb-2">85+ <span class="label-text">Container Proxy Port (Required)</span>
69- Specify the internal container port that the proxy will route traffic to86+ <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
70- </div>87+ </label>
71- <input type="text" name="container_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required />88+ <input type="text" name="container_port" class="input input-bordered w-full" value="{{.ProxyPort}}" required />
72 </div>89 </div>
73 90
74 <!-- Volumes -->91 <!-- Volumes -->
75- <div class="pb-4">92+ <div class="form-control pb-4">
76- <label class="block">Volumes</label>93+ <label class="label">
77- <span class="text-gray-500">e.g. ./data:/container/data </span>94+ <span class="label-text">Volumes</span>
78- <input type="text" name="volumes" class="w-full p-4 rounded-md" value="{{.Volumes}}" />95+ <span class="label-text-alt">e.g. ./data:/container/data</span>
96+ </label>
97+ <input type="text" name="volumes" class="input input-bordered w-full" value="{{.Volumes}}" />
79 </div>98 </div>
80 99
81- <div class="pb-4">100+ <div class="form-control pb-4">
82- <label class="block">Environment variables</label>101+ <label class="label">
83- <span class="text-gray-500">e.g. KEY=VALUE </span>102+ <span class="label-text">Environment variables</span>
84- <textarea name="environment_variables" class="w-full p-4 rounded-md"103+ <span class="label-text-alt">e.g. KEY=VALUE</span>
104+ </label>
105+ <textarea name="environment_variables" class="textarea textarea-bordered w-full"
85 rows="4">{{.EnvironmentVariables}}</textarea>106 rows="4">{{.EnvironmentVariables}}</textarea>
86 </div>107 </div>
108+
87 <!-- Restart: always/on-failure/only when stopped/never -->109 <!-- Restart: always/on-failure/only when stopped/never -->
88- <div class="pb-4">110+ <div class="form-control pb-4">
89- <label class="block">Restart</label>111+ <label class="label">
90- <select name="restart" class="w-full p-4 rounded-md border-2 bg-gray-100 border-gray-300">112+ <span class="label-text">Restart</span>
113+ </label>
114+ <select name="restart" class="select select-bordered w-full">
91 <option value="always">Always</option>115 <option value="always">Always</option>
92 <option value="on-failure">On failure</option>116 <option value="on-failure">On failure</option>
93 <option value="unless-stopped">Unless stopped</option>117 <option value="unless-stopped">Unless stopped</option>
@@ -96,20 +120,21 @@
96 </div>120 </div>
97 121
98 <!-- Skip Proxy Setup Option -->122 <!-- Skip Proxy Setup Option -->
99- <div class="pb-4">123+ <div class="form-control pb-4">
100- <label class="flex items-center space-x-2">124+ <label class="label cursor-pointer justify-start gap-4">
101- <input type="checkbox" name="skip_proxy_setup" value="true" class="form-checkbox h-5 w-5 text-blue-600" />125+ <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
102 <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>126 <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
103 </label>127 </label>
104- <p class="text-sm text-gray-500 ml-7">128+ <div class="text-sm opacity-70 ml-10">
105 Use this option if you only want to create the container without triggering proxy setup, 129 Use this option if you only want to create the container without triggering proxy setup,
106 certificate generation, and other related processes.130 certificate generation, and other related processes.
107- </p>131+ </div>
108 </div>132 </div>
109 133
110- <input type="submit" value="Create" id="create-container-submit"134+ <button type="submit" id="create-container-submit" class="btn btn-primary">
111- class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" />135+ Create Container
136+ </button>
112 </form>137 </form>
113- <div id="error-message-create-container-{{.ShortID}}" class="text-red-500"></div>138+ <div id="error-message-create-container-{{.ShortID}}" class="text-error mt-4"></div>
114 </div>139 </div>
115 </div>⏎̸140 </div>⏎̸
M internal/templating/models/html/fragments/editcontainer.gohtml +12 -10
@@ -1,9 +1,9 @@
1 <div1 <div
2 id="edit-container-{{.ID}}"2 id="edit-container-{{.ID}}"
3- class="h-fit-content"3+ class="card bg-base-100 shadow-xl"
4 hx-ext="response-targets"4 hx-ext="response-targets"
5 >5 >
6- <div id="editor">6+ <div id="editor" class="card-body">
7 <form7 <form
8 method="post"8 method="post"
9 action="/htmx/container-manager/edit/{{.ID}}"9 action="/htmx/container-manager/edit/{{.ID}}"
@@ -14,17 +14,19 @@
14 <textarea14 <textarea
15 name="container_config"15 name="container_config"
16 id="container_config"16 id="container_config"
17- class="w-full p-4 rounded-md bg-slate-100 border-gray-300 border-2"17+ class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF"
18 rows="20"18 rows="20"
19 >{{.ContainerInfo}}</textarea19 >{{.ContainerInfo}}</textarea
20 >20 >
21- <input21+ <div class="card-actions justify-end mt-4">
22- type="submit"22+ <input
23- value="Save"23+ type="submit"
24- id="save-button-{{.ID}}"24+ value="Save"
25- class="bg-gordonmascot-lblue text-white font-bold px-8 py-1 rounded-md hover:bg-gordonmascot-mblue cursor-pointer"25+ id="save-button-{{.ID}}"
26- />26+ class="btn btn-primary"
27+ />
28+ </div>
27 </form>29 </form>
28- <div id="error-message-edit-container-{{.ID}}" class="text-red-500"></div>30+ <div id="error-message-edit-container-{{.ID}}" class="text-error mt-2"></div>
29 </div>31 </div>
30 </div>32 </div>
M internal/templating/models/html/fragments/footer.gohtml +33 -0
@@ -0,0 +1,33 @@
1+<footer class="footer p-10 bg-base-200 text-base-content">
2+ <div>
3+ <span class="footer-title">Gordon</span>
4+ <a class="link link-hover">About us</a>
5+ <a class="link link-hover">Documentation</a>
6+ <a class="link link-hover">Github</a>
7+ </div>
8+ <div>
9+ <span class="footer-title">Container Management</span>
10+ <a class="link link-hover">Dashboard</a>
11+ <a class="link link-hover">Settings</a>
12+ <a class="link link-hover">Stats</a>
13+ </div>
14+ <div>
15+ <span class="footer-title">Legal</span>
16+ <a class="link link-hover">Terms of use</a>
17+ <a class="link link-hover">Privacy policy</a>
18+ <a class="link link-hover">License</a>
19+ </div>
20+</footer>
21+<footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300">
22+ <div class="items-center grid-flow-col">
23+ <span class="iconfb text-2xl">󰊢</span>
24+ <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p>
25+ </div>
26+ <div class="md:place-self-center md:justify-self-end">
27+ <div class="grid grid-flow-col gap-4">
28+ <a class="iconfb text-2xl">󰊤</a>
29+ <a class="iconfb text-2xl">󰗃</a>
30+ <a class="iconfb text-2xl">󰖟</a>
31+ </div>
32+ </div>
33+</footer>
M internal/templating/models/html/fragments/header.gohtml +10 -0
@@ -11,4 +11,14 @@
11 <script src="/libs/htmx-sse.js"></script>11 <script src="/libs/htmx-sse.js"></script>
12 <script defer src="/libs/hyperscript.min.js"></script>12 <script defer src="/libs/hyperscript.min.js"></script>
13 <script defer src="/assets/js/custom.js"></script>13 <script defer src="/assets/js/custom.js"></script>
14+
15+ <!-- daisyUI theme configuration -->
16+ <script>
17+ // Check for saved theme preference or use preferred color scheme
18+ const savedTheme = localStorage.getItem('theme') || 'gordon';
19+ document.documentElement.setAttribute('data-theme', savedTheme);
20+
21+ // Log theme loading for debugging
22+ console.debug('Theme loaded:', savedTheme);
23+ </script>
14 </head>24 </head>
M internal/templating/models/html/fragments/imagelist.gohtml +95 -79
@@ -1,82 +1,98 @@
1-<div class="flex flex-col p-4">1+<!-- Image list using daisyUI components -->
2- <!-- Upload button aligned right of Images -->2+<div class="card w-full bg-base-300">
3- <div class="flex justify-between items-center mb-1">3+ <div class="card-body p-4">
4- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">Images</h1>4+
5- <div class="flex space-x-2">5+ <!-- Action buttons -->
6- <button6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7- id="refresh-images-button"7+ <h3 class="font-semibold text-base-content">Available Images: {{ len .Images }}</h3>
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"8+ <div class="flex gap-2">
9- title="Refresh image list"9+ <button
10- >10+ class="btn btn-sm btn-secondary"
11- <span class="iconf text-xl align-middle">󰑐 </span>11+ hx-get="/htmx/upload-image"
12- Refresh12+ hx-target="#image-manager"
13- </button>13+ hx-swap="innerHTML">
14- <button14+ <span class="iconf">󰦒</span> Upload
15- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"15+ </button>
16- id="upload-button"16+ <button
17- hx-get="/htmx/upload-image"17+ class="btn btn-sm btn-primary"
18- hx-target="#upload-image"18+ hx-get="/htmx/image-manager"
19- hx-swap="innerHTML"19+ hx-target="#image-manager"
20- title="Manually upload a .tar container image"20+ hx-swap="innerHTML">
21- >21+ <span class="iconf">󰑓</span> Refresh
22- <span class="iconf text-xl align-middle">󰭾 </span>22+ </button>
23- Upload23+ </div>
24- </button>
25 </div>24 </div>
26- </div>
27- <div id="upload-image"></div>
28- <table class="p-4 border-t-2">
29- <tbody>
30- {{
31- range.Images
32- }}
33- <tr id="img-{{.ShortID}}" hx-ext="response-targets" class=" hover:bg-slate-100">
34- <td>
35- <span title="{{.ShortID}}">{{.Name}}</span>
36- </td>
37- <td>
38- <span title="Image creation date">{{.CreatedStr}}</span>
39- </td>
40- <td>{{.SizeStr}}</td>
41- <td align="right">
42-
43- <button
44- id="add-button-img-{{.ShortID}}"
45- hx-get="/htmx/create-container/{{.ShortID}}"
46- hx-target="#create-container-{{.ShortID}}"
47- hx-target-*="#error-message-div-{{.ShortID}}"
48- hx-swap="innerHTML"
49- class="py-2 px-4"
50- >
51- <span title="Deploy a container using this image" class="iconf text-xl text-blue-600 hover:text-gordonmascot-hblue">󰆍</span>
52- </button>
53 25
54- <button26+ <!-- Image list -->
55- id="remove-button-img-{{.ShortID}}"27+ <table class="table">
56- hx-delete="/htmx/image-manager/delete/{{.ShortID}}"28+ <!-- head -->
57- hx-target="#img-{{.ShortID}}"29+ <thead>
58- hx-target-*="#error-message-div-{{.ShortID}}"30+ <tr>
59- hx-swap="innerHTML"31+ <th>Repository</th>
60- hx-confirm="Are you sure you want to delete this container image?"32+ <th>Tag</th>
61- class="py-2 px-4"33+ <th>Size</th>
62- >34+ <th>Created</th>
63- <span title="Remove this image" class="iconf text-xl text-red-600 hover:text-red-900"></span>35+ <th>Actions</th>
64- </button>36+ </tr>
65- </td>37+ </thead>
66- </tr>38+ <tbody>
67- <t>39+ {{ if .NoImage }}
68- <td colspan="4">40+ <tr>
69- <div id="error-message-div-{{.ShortID}}" class="text-red-500"></div>41+ <td colspan="5" class="text-center py-4">
70- <div class="create-container" id="create-container-{{.ShortID}}"></div>42+ <div class="alert alert-info">
71- </td>43+ <span class="iconf">ℹ️</span>
72- </tr>44+ <span>No images found. Upload an image first.</span>
73- {{ else }}45+ </div>
74- <tr>46+ </td>
75- <td colspan="4" class="mt-2">No images found.</td>47+ </tr>
76- </tr>48+ {{ else }}
77- {{49+ {{ range .Images }}
78- end50+ <tr class="hover">
79- }}51+ <td>
80- </tbody>52+ <div class="flex items-center gap-3">
81- </table>53+ <div>
54+ <div class="font-bold">{{ .Repository }}</div>
55+ <div class="text-sm opacity-50">{{ .ID }}</div>
56+ </div>
57+ </div>
58+ </td>
59+ <td>{{ .Tag }}</td>
60+ <td>{{ .Size }}</td>
61+ <td>{{ .Created }}</td>
62+ <th>
63+ <div class="dropdown dropdown-end">
64+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
65+ <span class="iconf">󰅀</span>
66+ </div>
67+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
68+ <li>
69+ <a
70+ href="/htmx/create-container/{{ if gt (len .ID) 12 }}{{ slice .ID 0 12 }}{{ else }}{{ .ID }}{{ end }}"
71+ class="text-success flex items-center">
72+ <span class="iconf mr-2">󰐕</span> Create Container
73+ </a>
74+ </li>
75+ <li>
76+ <button
77+ class="text-error flex items-center"
78+ hx-delete="/htmx/image-manager/delete/{{ .ID }}"
79+ hx-target="#image-manager"
80+ hx-swap="innerHTML"
81+ hx-confirm="Are you sure you want to delete this image?">
82+ <span class="iconf mr-2">󰆴</span> Delete
83+ </button>
84+ </li>
85+ </ul>
86+ </div>
87+ </th>
88+ </tr>
89+ {{ end }}
90+ {{ end }}
91+ </tbody>
92+ </table>
93+ </div>
82 </div>94 </div>
95+
96+<script>
97+console.debug('Image list rendered');
98+</script>
M internal/templating/models/html/fragments/menu.gohtml +61 -39
@@ -1,45 +1,67 @@
1-<div1+<!-- DaisyUI Navbar -->
2- id="menubar"2+<div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
3- class="w-full h-12 md:h-16 fixed top-0 left-0 bg-gordonmascot-lblue flex flex-row justify-evenly items-center px-4 IBMPlexSans">3+ <div class="navbar-start">
4- <div class="w-full flex justify-start">4+ <div class="flex items-center">
5- <h15+ <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
6- class="gordon-title text-3xl md:text-4xl text-gordonmascot-hblue tracking-tighter">6+ Gordon
7- Gordon7+ </h1>
8- </h1>8+ <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
9- <span9+ {{ .BuildVersion }}
10- id="actual-version"10+ </span>
11- class="text-gordonmascot-mblue text-xs md:text-sm pr-1 JetBrainsMonoNF">11+ <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
12- {{ .BuildVersion }}12+ <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
13- </span>13+ </span>
14- <span14+ </div>
15- hidden
16- id="update-available"
17- class="text-green-300 text-xs md:text-sm JetBrainsMonoNF hidden"
18- title=""
19- ><a href="https://github.com/bnema/gordon/releases">󰨦</a>
20- </span>
21 </div>15 </div>
22- <div class="w-full">16+ <div class="navbar-center hidden lg:flex">
23- <ul17+ <ul class="menu menu-horizontal px-1">
24- class="flex flex-row space-y-6 font-semibold text-gray-100 text-lg md:text-xl justify-center">18+ <li>
25- <li class="">19+ <a href="{{ .AdminPath }}/manager" class="font-semibold">
26- <span class="JetBrainsMonoBoldNF pr-1"> </span>20+ <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
27- <a href="{{ .AdminPath }}/manager">Manager</a>21+ </a>
28 </li>22 </li>
29 </ul>23 </ul>
30 </div>24 </div>
31- <div class="w-full flex space-x-4 justify-end">25+ <div class="navbar-end">
32- <a26+ <!-- Theme toggle -->
33- href="{{ .UserSettingsURL }}"27+ <div class="dropdown dropdown-end">
34- title="User settings"28+ <label tabindex="0" class="btn btn-ghost btn-circle">
35- class="iconf text-gordonmascot-mblue text-lg md:text-xl"29+ <span class="iconf text-lg md:text-xl">󰔎</span>
36- ></a30+ </label>
37- >31+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
38- <a32+ <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
39- href="{{ .LogoutURL }}"33+ <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
40- title="Logout"34+ <li><button onclick="setTheme('light')">Light</button></li>
41- class="iconf text-gordonmascot-mblue text-lg md:text-xl"35+ <li><button onclick="setTheme('dark')">Dark</button></li>
42- >󰍃</a36+ </ul>
43- >37+ </div>
38+
39+ <!-- User menu -->
40+ <div class="dropdown dropdown-end">
41+ <label tabindex="0" class="btn btn-ghost btn-circle">
42+ <span class="iconf text-lg md:text-xl">󰀉</span>
43+ </label>
44+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
45+ <li>
46+ <a href="{{ .UserSettingsURL }}" title="User settings">
47+ <span class="iconf">󰒓</span> Settings
48+ </a>
49+ </li>
50+ <li>
51+ <a href="{{ .LogoutURL }}" title="Logout">
52+ <span class="iconf">󰍃</span> Logout
53+ </a>
54+ </li>
55+ </ul>
56+ </div>
44 </div>57 </div>
45 </div>58 </div>
59+
60+<!-- Theme toggle script -->
61+<script>
62+ function setTheme(theme) {
63+ document.documentElement.setAttribute('data-theme', theme);
64+ localStorage.setItem('theme', theme);
65+ console.debug('Theme changed to:', theme);
66+ }
67+</script>
M internal/templating/models/html/fragments/success.gohtml +1 -1
@@ -1,5 +1,5 @@
1 <div1 <div
2 id="success"2 id="success"
3- class="flex text-blue-800 content-center justify-center items-center w-full h-full">3+ class="flex text-indigo-600 content-center justify-center items-center w-full h-full">
4 <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p>4 <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p>
5 </div>5 </div>
M internal/templating/models/html/fragments/uploadimage.gohtml +53 -27
@@ -1,35 +1,61 @@
1-<!-- Form to upload a container image (.tar format) -->1+<!-- Upload image form using DaisyUI components -->
2-<div2+<div class="card bg-base-100 shadow">
3- class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"3+ <div class="card-body">
4- hx-ext="response-targets"4+ <div class="flex justify-between items-center">
5- hx-boost="true">5+ <h2 class="card-title text-primary">Upload Image</h2>
6- <div class="flex-grow bg-slate-100 p-4 rounded-md">6+ <button
7- <h2>Upload Image Containers</h2>7+ class="btn btn-sm btn-circle btn-ghost"
8- <!-- Upload Form -->8+ hx-get="/htmx/image-manager"
9+ hx-target="#image-manager"
10+ hx-swap="innerHTML">
11+ <span class="iconf text-lg">✕</span>
12+ </button>
13+ </div>
14+ <div class="divider"></div>
15+
9 <form16 <form
10- action="/upload"17+ id="upload-form"
11- method="post"18+ hx-encoding="multipart/form-data"
12- enctype="multipart/form-data"
13 hx-post="/htmx/upload-image"19 hx-post="/htmx/upload-image"
14- hx-swap="innerHTML"
15 hx-target="#image-manager"20 hx-target="#image-manager"
16- hx-target-*="#error-message-div">21+ hx-swap="innerHTML"
17- <div22+ class="space-y-4">
18- class="upload-zone w-full h-44 bg-slate-200 rounded-md p-4"23+
19- hx-drop="true">24+ <div class="alert alert-info shadow-lg mb-4">
20- Drag & Drop your file here or25+ <span class="iconf">ℹ️</span>
21- <input26+ <div>
22- type="file"27+ <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
23- name="file"28+ <p>After uploading, you can create and manage containers from this image.</p>
29+ </div>
30+ </div>
31+
32+ <div class="form-control w-full">
33+ <label class="label">
34+ <span class="label-text">Select a container image file (.tar)</span>
35+ </label>
36+ <input
37+ type="file"
38+ name="imageFile"
39+ class="file-input file-input-bordered w-full"
24 accept=".tar"40 accept=".tar"
25- class="bg-transparent outline-none" />41+ required />
42+ </div>
43+
44+ <div class="card-actions justify-end mt-4">
45+ <button
46+ class="btn btn-ghost"
47+ hx-get="/htmx/image-manager"
48+ hx-target="#image-manager"
49+ hx-swap="innerHTML"
50+ type="button">
51+ Cancel
52+ </button>
53+ <button
54+ class="btn btn-primary"
55+ type="submit">
56+ <span class="iconf mr-1">󰦒</span> Upload
57+ </button>
26 </div>58 </div>
27- <input
28- type="submit"
29- value="Upload"
30- id="upload-submit-button"
31- class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" />
32 </form>59 </form>
33- <div id="error-message-div" class="text-red-500"></div>
34 </div>60 </div>
35 </div>61 </div>
M internal/templating/models/html/login/index.gohtml +79 -63
@@ -1,5 +1,5 @@
1 <!doctype html>1 <!doctype html>
2-<html lang="en">2+<html lang="en" data-theme="corporate">
3 <head>3 <head>
4 <meta charset="UTF-8" />4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -12,95 +12,111 @@
12 <script src="/libs/htmx.min.js"></script>12 <script src="/libs/htmx.min.js"></script>
13 <!-- HTMX SSE -->13 <!-- HTMX SSE -->
14 <script src="/libs/htmx-sse.js"></script>14 <script src="/libs/htmx-sse.js"></script>
15+ <!-- DaisyUI -->
16+ <script src="/libs/daisyui.js"></script>
15 <!-- Custom JS from assets/js -->17 <!-- Custom JS from assets/js -->
16 <script src="/assets/js/custom.js"></script>18 <script src="/assets/js/custom.js"></script>
19+
20+ <!-- daisyUI theme configuration -->
21+ <script>
22+ // Check for saved theme preference or use preferred color scheme
23+ const savedTheme = localStorage.getItem('theme') || 'corporate';
24+ document.documentElement.setAttribute('data-theme', savedTheme);
25+ console.debug('Theme loaded:', savedTheme);
26+ </script>
17 </head>27 </head>
18 28
19- <body class="IBMPlexSans bg-zinc-200 md:p-8 flex flex-col items-center">29+ <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4">
20 <!-- Logo and Title -->30 <!-- Logo and Title -->
21 <div31 <div
22 id="logo-mascot"32 id="logo-mascot"
23 class="flex flex-col justify-center items-center p-4 mb-4">33 class="flex flex-col justify-center items-center p-4 mb-4">
24- <div class="circle-shadow flex justify-center w-100">34+ <div class="flex justify-center w-100">
25 <img35 <img
26 src="/assets/imgs/gordon-mascot-mq-trsp.png"36 src="/assets/imgs/gordon-mascot-mq-trsp.png"
27 alt="logo gordon"37 alt="logo gordon"
28- class="sm:h-24 h-32 xl:h-48" />38+ class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" />
29 </div>39 </div>
30 <div class="flex justify-center mt-4">40 <div class="flex justify-center mt-4">
31 <h141 <h1
32- class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-gordonmascot-hblue tracking-tighter">42+ class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter">
33 Gordon43 Gordon
34 </h1>44 </h1>
35 <span45 <span
36- class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2"46+ class="gordon-v-subtitle text-secondary text-sm ml-2"
37 >{{ .BuildVersion }}</span47 >{{ .BuildVersion }}</span
38 >48 >
39 </div>49 </div>
40 </div>50 </div>
41 51
42 <!-- Main Content -->52 <!-- Main Content -->
43- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">53+ <div class="card bg-base-100 shadow-xl w-full max-w-md">
44- <!-- Login Section -->54+ <div class="card-body">
45- <div id="login-section">55+ <!-- Login Section -->
46- <div class="flex flex-col items-center">56+ <div id="login-section">
47- <h2 class="text-2xl font-semibold text-gray-800">Login</h2>57+ <div class="flex flex-col items-center">
48- 58+ <h2 class="card-title text-center mb-4">Login</h2>
49- <!-- Error Message Display -->59+
50- {{ if .Error }}60+ <!-- Error Message Display -->
51- <div class="mt-4 w-full max-w-md bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">61+ {{ if .Error }}
52- <span class="block sm:inline">62+ <div class="alert alert-error mb-4">
53- {{ if eq .Error "empty_token" }}63+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
54- Please enter a token.64+ <span>
55- {{ else if eq .Error "invalid_token" }}65+ {{ if eq .Error "empty_token" }}
56- Invalid token. Please try again.66+ Please enter a token.
57- {{ else if eq .Error "server_error" }}67+ {{ else if eq .Error "invalid_token" }}
58- A server error occurred. Please try again later.68+ Invalid token. Please try again.
59- {{ else }}69+ {{ else if eq .Error "server_error" }}
60- An error occurred. Please try again.70+ A server error occurred. Please try again later.
61- {{ end }}71+ {{ else }}
62- </span>72+ An error occurred. Please try again.
63- </div>73+ {{ end }}
64- {{ end }}74+ </span>
65- 75+ </div>
66- <!-- Token Input Form -->76+ {{ end }}
67- <div class="mt-4 w-full max-w-md">77+
68- <form action="login/submit-token" method="POST" class="mb-4">78+ <!-- Token Input Form -->
69- <div class="mb-2">79+ <div class="w-full">
70- <label for="token" class="block text-sm font-medium text-gray-700">Admin Token</label>80+ <form action="login/submit-token" method="POST" class="mb-4">
71- <input 81+ <div class="form-control mb-4">
72- type="text" 82+ <label for="token" class="label">
73- id="token" 83+ <span class="label-text">Admin Token</span>
74- name="token" 84+ </label>
75- placeholder="Enter your admin token" 85+ <input
76- class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-gordonmascot-hblue focus:border-gordonmascot-hblue"86+ type="text"
77- required87+ id="token"
78- />88+ name="token"
79- </div>89+ placeholder="Enter your admin token"
80- <button 90+ class="input input-bordered w-full"
81- type="submit" 91+ required
82- class="w-full bg-gordonmascot-mblue hover:bg-gordonmascot-hblue text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-150 ease-in-out"92+ />
93+ </div>
94+ <button
95+ type="submit"
96+ class="btn btn-primary w-full"
97+ >
98+ Continue with Token
99+ </button>
100+ </form>
101+ <div class="divider">OR</div>
102+ </div>
103+
104+ <!-- GitHub OAuth -->
105+ <div class="w-full">
106+ <a
107+ href="login/oauth/github"
108+ class="btn btn-secondary w-full"
83 >109 >
84- Continue with Token110+ <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
85- </button>111+ <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
86- </form>112+ </svg>
87- <div class="text-center text-sm text-gray-500 mb-4">- OR -</div>113+ Sign in with GitHub
88- </div>114+ </a>
89- 115+ </div>
90- <!-- GitHub OAuth -->
91- <div
92- class="mt-2 p-2 bg-gordonmascot-hblue text-white rounded-md flex justify-left">
93- <a href="login/oauth/github" class="flex items-center">
94- <img
95- src="/assets/imgs/github/github-mark-white.png"
96- alt="logo github"
97- class="h-8 xl:h-12" />
98- <span class="w-full p-2 text-white">Login with GitHub</span>
99- </a>
100 </div>116 </div>
101 </div>117 </div>
118+ <!-- End Login Section -->
102 </div>119 </div>
103- <!-- End Login Section -->
104 </div>120 </div>
105 </body>121 </body>
106 </html>122 </html>
A internal/templating/models/templ/README.md +48 -0
@@ -0,0 +1,48 @@
1+# Templ Templates for Gordon
2+
3+This directory contains [templ](https://github.com/a-h/templ) templates for the Gordon application. We're migrating from Go's html/template to templ for improved type safety and maintainability.
4+
5+## Directory Structure
6+
7+- `components/` - Reusable UI components
8+- `layouts/` - Page layouts and base templates
9+- `pages/` - Full page templates
10+ - `admin/` - Admin pages
11+ - `login/` - Login pages
12+
13+## Working with Templates
14+
15+To create or modify templates:
16+
17+1. Edit the `.templ` files in the appropriate directory
18+2. Run `templ generate` or `make build-templ` to generate Go code
19+3. Use the templates in your handlers with the `TemplRenderer`
20+
21+## Converting from gohtml
22+
23+When converting from gohtml:
24+
25+1. Create a new `.templ` file
26+2. Copy HTML structure from gohtml
27+3. Replace `{{ .varName }}` with `{ varName }`
28+4. Use templ's control flow syntax like `if`, `for`, etc.
29+5. Use `@` to invoke other components
30+
31+## Example:
32+
33+```templ
34+package components
35+
36+templ Button(label string, classes string) {
37+ <button class={classes}>{ label }</button>
38+}
39+```
40+
41+## Usage in Handlers
42+
43+```go
44+func RenderMyPage(c echo.Context, a *server.App) error {
45+ renderer := render.NewTemplRenderer(a)
46+ return renderer.RenderTempl(c, mypage.PageTemplate("Page Title", someData))
47+}
48+```⏎̸
A internal/templating/models/templ/components/containerlist.templ +132 -0
@@ -0,0 +1,132 @@
1+package components
2+
3+import (
4+ "github.com/docker/docker/api/types"
5+ "strconv"
6+)
7+
8+// ContainerList displays a list of containers
9+templ ContainerList(containers []types.Container, noContainer bool) {
10+ <!-- Container list using daisyUI components -->
11+ <div class="card w-full bg-base-300">
12+ <div class="card-body p-4">
13+
14+ <!-- Action buttons -->
15+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
16+ <h3 class="font-semibold text-base-content">Available Containers: { strconv.Itoa(len(containers)) }</h3>
17+ <div class="btn-group">
18+ <button
19+ class="btn btn-sm btn-primary"
20+ hx-get="/htmx/container-manager"
21+ hx-target="#container-manager"
22+ hx-swap="innerHTML">
23+ <span class="iconf">󰑓</span> Refresh
24+ </button>
25+ </div>
26+ </div>
27+
28+ <!-- Container list -->
29+ <table class="table">
30+ <!-- head -->
31+ <thead>
32+ <tr>
33+ <th>Name</th>
34+ <th>Image</th>
35+ <th>Status</th>
36+ <th>Created</th>
37+ <th>Actions</th>
38+ </tr>
39+ </thead>
40+ <tbody>
41+ if noContainer || len(containers) == 0 {
42+ <tr>
43+ <td colspan="5" class="text-center py-4">
44+ <div class="alert alert-info">
45+ <span class="iconf">ℹ️</span>
46+ <span>No containers found. Upload an image first, then create a container.</span>
47+ </div>
48+ </td>
49+ </tr>
50+ } else {
51+ for _, container := range containers {
52+ <tr class="hover">
53+ <td>
54+ <div class="flex items-center gap-3">
55+ <div>
56+ <div class="font-bold">{ container.Names[0] }</div>
57+ <div class="text-sm opacity-50">{ container.ID }</div>
58+ </div>
59+ </div>
60+ </td>
61+ <td>{ container.Image }</td>
62+ <td>
63+ if container.State == "running" {
64+ <div class="badge badge-success badge-sm">Running</div>
65+ } else if container.State == "exited" {
66+ <div class="badge badge-warning badge-sm">Stopped</div>
67+ } else {
68+ <div class="badge badge-ghost badge-sm">{ container.State }</div>
69+ }
70+ </td>
71+ <td>{ strconv.FormatInt(container.Created, 10) }</td>
72+ <th>
73+ <div class="dropdown dropdown-end">
74+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
75+ <span class="iconf">󰅀</span>
76+ </div>
77+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40">
78+ if container.State == "running" {
79+ <li>
80+ <button
81+ class="text-warning flex items-center"
82+ hx-post={ "/htmx/container-manager/stop/" + container.ID }
83+ hx-target="#container-manager"
84+ hx-swap="innerHTML">
85+ <span class="iconf mr-2">󰓛</span> Stop
86+ </button>
87+ </li>
88+ } else if container.State == "exited" {
89+ <li>
90+ <button
91+ class="text-success flex items-center"
92+ hx-post={ "/htmx/container-manager/start/" + container.ID }
93+ hx-target="#container-manager"
94+ hx-swap="innerHTML">
95+ <span class="iconf mr-2">󰐊</span> Start
96+ </button>
97+ </li>
98+ }
99+ <li>
100+ <button
101+ class="text-info flex items-center"
102+ hx-get={ "/htmx/container-manager/edit/" + container.ID }
103+ hx-target="#container-manager"
104+ hx-swap="innerHTML">
105+ <span class="iconf mr-2">󰏫</span> Edit
106+ </button>
107+ </li>
108+ <li>
109+ <button
110+ class="text-error flex items-center"
111+ hx-delete={ "/htmx/container-manager/delete/" + container.ID }
112+ hx-target="#container-manager"
113+ hx-swap="innerHTML"
114+ hx-confirm="Are you sure you want to delete this container?">
115+ <span class="iconf mr-2">󰆴</span> Delete
116+ </button>
117+ </li>
118+ </ul>
119+ </div>
120+ </th>
121+ </tr>
122+ }
123+ }
124+ </tbody>
125+ </table>
126+ </div>
127+ </div>
128+
129+ <script>
130+ console.debug('Container list rendered');
131+ </script>
132+}⏎̸
A internal/templating/models/templ/components/containerlist_templ.go +228 -0 generated file: collapsed
A internal/templating/models/templ/components/createcontainer.templ +160 -0
@@ -0,0 +1,160 @@
1+package components
2+
3+// ContainerFormData holds the data needed for the container creation form
4+type ContainerFormData struct {
5+ ImageName string
6+ ImageID string
7+ ShortID string
8+ ContainerName string
9+ ContainerSubdomain string
10+ ContainerDomain string
11+ Ports string
12+ ProxyPort string
13+ Volumes string
14+ EnvironmentVariables string
15+ ErrorMessage string
16+}
17+
18+// CreateContainerForm displays a form for creating a new container
19+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">
21+ <!-- Left Side: Docker Run Options -->
22+ <div class="flex-grow card p-6">
23+ <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2>
24+ if data.ErrorMessage != "" {
25+ <div class="alert alert-error">
26+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
27+ <span>{ data.ErrorMessage }</span>
28+ </div>
29+ }
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+ hx-swap="innerHTML"
33+ hx-target={ "#create-container-" + data.ShortID }
34+ hx-target-*={ "#error-message-create-container-" + data.ShortID }>
35+ <!-- Image Name -->
36+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
37+ <div class="md:col-span-1">
38+ <label class="label">
39+ <span class="label-text">Image name</span>
40+ </label>
41+ <input type="text" name="image_name" class="input input-bordered input-disabled w-full"
42+ value={ data.ImageName } readonly />
43+ </div>
44+
45+ <div class="md:col-span-2">
46+ <label class="label">
47+ <span class="label-text">Image ID</span>
48+ </label>
49+ <input type="text" name="image_id" class="input input-bordered input-disabled w-full"
50+ value={ data.ImageID } readonly />
51+ </div>
52+ </div>
53+
54+ <!-- Container Name -->
55+ <div class="form-control pb-4">
56+ <label class="label">
57+ <span class="label-text">Container name</span>
58+ </label>
59+ <input type="text" name="container_name" class="input input-bordered w-full" value={ data.ContainerName } />
60+ </div>
61+
62+ <!-- Http(s) / Subdomain / Domain -->
63+ <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4">
64+ <!-- Protocol -->
65+ <div class="md:col-span-3">
66+ <label class="label">
67+ <span class="label-text">Protocol</span>
68+ </label>
69+ <select name="container_protocol" class="select select-bordered w-full">
70+ <option value="https">https</option>
71+ <option value="http">http</option>
72+ </select>
73+ </div>
74+
75+ <!-- Subdomain -->
76+ <div class="md:col-span-4">
77+ <label class="label">
78+ <span class="label-text">Subdomain</span>
79+ </label>
80+ <input type="text" name="container_subdomain" class="input input-bordered w-full" value={ data.ContainerSubdomain } />
81+ </div>
82+
83+ <!-- Domain -->
84+ <div class="md:col-span-5">
85+ <label class="label">
86+ <span class="label-text">Domain</span>
87+ </label>
88+ <input type="text" name="container_domain" class="input input-bordered w-full" value={ data.ContainerDomain } />
89+ </div>
90+ </div>
91+
92+ <!-- Exposed Ports -->
93+ <div class="form-control pb-4">
94+ <label class="label">
95+ <span class="label-text">Exposed Ports (Optional)</span>
96+ <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
97+ </label>
98+ <input type="text" name="ports" class="input input-bordered w-full" value={ data.Ports } />
99+ </div>
100+
101+ <!-- Proxy Port -->
102+ <div class="form-control pb-4">
103+ <label class="label">
104+ <span class="label-text">Container Proxy Port (Required)</span>
105+ <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
106+ </label>
107+ <input type="text" name="container_port" class="input input-bordered w-full" value={ data.ProxyPort } required />
108+ </div>
109+
110+ <!-- Volumes -->
111+ <div class="form-control pb-4">
112+ <label class="label">
113+ <span class="label-text">Volumes</span>
114+ <span class="label-text-alt">e.g. ./data:/container/data</span>
115+ </label>
116+ <input type="text" name="volumes" class="input input-bordered w-full" value={ data.Volumes } />
117+ </div>
118+
119+ <div class="form-control pb-4">
120+ <label class="label">
121+ <span class="label-text">Environment variables</span>
122+ <span class="label-text-alt">e.g. KEY=VALUE</span>
123+ </label>
124+ <textarea name="environment_variables" class="textarea textarea-bordered w-full"
125+ rows="4">{ data.EnvironmentVariables }</textarea>
126+ </div>
127+
128+ <!-- Restart: always/on-failure/only when stopped/never -->
129+ <div class="form-control pb-4">
130+ <label class="label">
131+ <span class="label-text">Restart</span>
132+ </label>
133+ <select name="restart" class="select select-bordered w-full">
134+ <option value="always">Always</option>
135+ <option value="on-failure">On failure</option>
136+ <option value="unless-stopped">Unless stopped</option>
137+ <option value="never">Never</option>
138+ </select>
139+ </div>
140+
141+ <!-- Skip Proxy Setup Option -->
142+ <div class="form-control pb-4">
143+ <label class="label cursor-pointer justify-start gap-4">
144+ <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
145+ <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
146+ </label>
147+ <div class="text-sm opacity-70 ml-10">
148+ Use this option if you only want to create the container without triggering proxy setup,
149+ certificate generation, and other related processes.
150+ </div>
151+ </div>
152+
153+ <button type="submit" id="create-container-submit" class="btn btn-primary">
154+ Create Container
155+ </button>
156+ </form>
157+ <div id={ "error-message-create-container-" + data.ShortID } class="text-error mt-4"></div>
158+ </div>
159+ </div>
160+} ⏎̸
A internal/templating/models/templ/components/createcontainer_templ.go +283 -0 generated file: collapsed
A internal/templating/models/templ/components/editcontainer.templ +39 -0
@@ -0,0 +1,39 @@
1+package components
2+
3+import t "github.com/a-h/templ"
4+
5+// EditContainer replaces editcontainer.gohtml
6+// It renders a form to edit a container's configuration
7+templ EditContainer(id string, containerInfo string) {
8+ <div
9+ id={ "edit-container-" + id }
10+ class="card bg-base-100 shadow-xl"
11+ hx-ext="response-targets"
12+ >
13+ <div id="editor" class="card-body">
14+ <form
15+ method="post"
16+ action={ t.URL("/htmx/container-manager/edit/" + id) }
17+ hx-post={ string(t.URL("/htmx/container-manager/edit/" + id)) }
18+ hx-swap="innerHTML"
19+ hx-target-*={ "#error-message-edit-container-" + id }
20+ >
21+ <textarea
22+ name="container_config"
23+ id="container_config"
24+ class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF"
25+ rows="20"
26+ >{ containerInfo }</textarea>
27+ <div class="card-actions justify-end mt-4">
28+ <input
29+ type="submit"
30+ value="Save"
31+ id={ "save-button-" + id }
32+ class="btn btn-primary"
33+ />
34+ </div>
35+ </form>
36+ <div id={ "error-message-edit-container-" + id } class="text-error mt-2"></div>
37+ </div>
38+ </div>
39+} ⏎̸
A internal/templating/models/templ/components/editcontainer_templ.go +131 -0 generated file: collapsed
A internal/templating/models/templ/components/footer.templ +39 -0
@@ -0,0 +1,39 @@
1+package components
2+
3+// Footer is a templ component for the page footer
4+// It replaces footer.gohtml
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">
27+ <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>
30+ </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>
37+ </div>
38+ </footer>
39+}⏎̸
A internal/templating/models/templ/components/footer_templ.go +42 -0 generated file: collapsed
A internal/templating/models/templ/components/header.templ +30 -0
@@ -0,0 +1,30 @@
1+package components
2+
3+// Header is a templ component for the page header
4+// It replaces header.gohtml
5+templ Header(title string) {
6+ <head>
7+ <meta charset="UTF-8" />
8+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9+ <title>{ title }</title>
10+ <link rel="stylesheet" href="/assets/css/tailwind.css" />
11+
12+ <script src="/libs/jquery.min.js"></script>
13+ <script src="/libs/htmx.min.js"></script>
14+ <script src="/libs/htmx-response-targets.js"></script>
15+ <script src="/libs/htmx-debug.js"></script>
16+ <script src="/libs/htmx-sse.js"></script>
17+ <script defer src="/libs/hyperscript.min.js"></script>
18+ <script defer src="/assets/js/custom.js"></script>
19+
20+ <!-- daisyUI theme configuration -->
21+ <script>
22+ // Check for saved theme preference or use preferred color scheme
23+ const savedTheme = localStorage.getItem('theme') || 'gordon-dark';
24+ document.documentElement.setAttribute('data-theme', savedTheme);
25+
26+ // Log theme loading for debugging
27+ console.debug('Theme loaded:', savedTheme);
28+ </script>
29+ </head>
30+}⏎̸
A internal/templating/models/templ/components/header_templ.go +55 -0 generated file: collapsed
A internal/templating/models/templ/components/htmx_components.templ +80 -0
@@ -0,0 +1,80 @@
1+package components
2+
3+// HTMXHeader creates the hero header with HTMX attributes
4+templ HTMXHeader(title string, subtitle string) {
5+ <div id="header" class="hero bg-base-200">
6+ <div class="hero-content text-center">
7+ <div class="max-w-md">
8+ <h1 class="text-5xl font-bold gordon-title">
9+ { title }
10+ </h1>
11+ <p class="py-6 IBMPlexSans">{ subtitle }</p>
12+ <button class="btn btn-primary">Get Started</button>
13+ </div>
14+ </div>
15+ </div>
16+}
17+
18+// HTMXAlert creates an alert box that can be loaded with HTMX
19+templ HTMXAlert(message string) {
20+ <div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
21+ <div>
22+ <span class="iconfb">󰋽</span>
23+ <span class="IBMPlexSans">{ message }</span>
24+ </div>
25+ </div>
26+}
27+
28+// HTMXHeaderContainer creates a container to load the HTMX header
29+templ HTMXHeaderContainer() {
30+ <div
31+ id="header"
32+ hx-get="/htmx"
33+ hx-headers='{"HX-Fragment": "header"}'
34+ hx-trigger="load"
35+ hx-swap="outerHTML"
36+ ></div>
37+}
38+
39+// HTMXAppContainer creates a container to load the HTMX app
40+templ HTMXAppContainer() {
41+ <div
42+ id="app"
43+ hx-get="/htmx"
44+ hx-headers='{"HX-Fragment": "hello"}'
45+ hx-trigger="load"
46+ hx-swap="outerHTML"
47+ ></div>
48+}
49+
50+// StatsCard creates individual stat cards for the dashboard
51+templ StatsCard(title string, value string, description string, icon string) {
52+ <div class="stat bg-base-100 shadow">
53+ <div class="stat-figure text-primary">
54+ <span class="iconfb text-3xl">{ icon }</span>
55+ </div>
56+ <div class="stat-title">{ title }</div>
57+ <div class="stat-value">{ value }</div>
58+ <div class="stat-desc">{ description }</div>
59+ </div>
60+}
61+
62+// StatsCards creates a set of stats cards
63+templ StatsCards() {
64+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
65+ @StatsCard("Containers", "31", "Running containers", "󰡨")
66+ @StatsCard("Images", "42", "Available images", "󱧕")
67+ @StatsCard("Networks", "12", "Container networks", "󰙅")
68+ </div>
69+}
70+
71+// TabsNav creates a navigation tabs component
72+templ TabsNav(activeTab string) {
73+ <div class="tabs tabs-boxed my-4">
74+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Overview")}>Overview</a>
75+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Containers")}>Containers</a>
76+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Images")}>Images</a>
77+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Networks")}>Networks</a>
78+ <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Volumes")}>Volumes</a>
79+ </div>
80+}⏎̸
A internal/templating/models/templ/components/htmx_components_templ.go +438 -0 generated file: collapsed
A internal/templating/models/templ/components/imagelist.templ +122 -0
@@ -0,0 +1,122 @@
1+package components
2+
3+import (
4+ "strconv"
5+)
6+
7+// HumanReadableImage represents an image with human-readable attributes
8+type HumanReadableImage struct {
9+ ID string
10+ ShortID string
11+ Name string
12+ Repository string
13+ Tag string
14+ Size string
15+ Created string
16+ RepoDigests []string
17+ RepoTags []string
18+}
19+
20+// ImageList displays a list of images
21+templ ImageList(images []HumanReadableImage) {
22+ <!-- Image list using daisyUI components -->
23+ <div class="card w-full bg-base-300">
24+ <div class="card-body p-4">
25+
26+ <!-- Action buttons -->
27+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
28+ <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3>
29+ <div class="flex gap-2">
30+ <button
31+ class="btn btn-sm btn-secondary"
32+ hx-get="/htmx/upload-image"
33+ hx-target="#image-manager"
34+ hx-swap="innerHTML">
35+ <span class="iconf">󰦒</span> Upload
36+ </button>
37+ <button
38+ class="btn btn-sm btn-primary"
39+ hx-get="/htmx/image-manager"
40+ hx-target="#image-manager"
41+ hx-swap="innerHTML">
42+ <span class="iconf">󰑓</span> Refresh
43+ </button>
44+ </div>
45+ </div>
46+
47+ <!-- Image list -->
48+ <table class="table">
49+ <!-- head -->
50+ <thead>
51+ <tr>
52+ <th>Repository</th>
53+ <th>Tag</th>
54+ <th>Size</th>
55+ <th>Created</th>
56+ <th>Actions</th>
57+ </tr>
58+ </thead>
59+ <tbody>
60+ /* Check if there are no images */
61+ if len(images) == 0 {
62+ <tr>
63+ <td colspan="5" class="text-center py-4">
64+ <div class="alert alert-info">
65+ <span class="iconf">ℹ️</span>
66+ <span>No images found. Upload an image first.</span>
67+ </div>
68+ </td>
69+ </tr>
70+ } else {
71+ /* List all available images */
72+ for _, img := range images {
73+ <tr class="hover">
74+ <td>
75+ <div class="flex items-center gap-3">
76+ <div>
77+ <div class="font-bold">{ img.Repository }</div>
78+ <div class="text-sm opacity-50">{ img.ID }</div>
79+ </div>
80+ </div>
81+ </td>
82+ <td>{ img.Tag }</td>
83+ <td>{ img.Size }</td>
84+ <td>{ img.Created }</td>
85+ <th>
86+ <div class="dropdown dropdown-end">
87+ <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
88+ <span class="iconf">󰅀</span>
89+ </div>
90+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
91+ <li>
92+ <a
93+ href={ templ.SafeURL("/htmx/create-container/" + img.ShortID) }
94+ class="text-success flex items-center">
95+ <span class="iconf mr-2">󰐕</span> Create Container
96+ </a>
97+ </li>
98+ <li>
99+ <button
100+ class="text-error flex items-center"
101+ hx-delete={ "/htmx/image-manager/delete/" + img.ShortID }
102+ hx-target="#image-manager"
103+ hx-swap="innerHTML"
104+ hx-confirm="Are you sure you want to delete this image?">
105+ <span class="iconf mr-2">󰆴</span> Delete
106+ </button>
107+ </li>
108+ </ul>
109+ </div>
110+ </th>
111+ </tr>
112+ }
113+ }
114+ </tbody>
115+ </table>
116+ </div>
117+ </div>
118+
119+ <script>
120+ console.debug('Image list rendered');
121+ </script>
122+} ⏎̸
A internal/templating/models/templ/components/imagelist_templ.go +175 -0 generated file: collapsed
A internal/templating/models/templ/components/navbar.templ +72 -0
@@ -0,0 +1,72 @@
1+package components
2+
3+// Navbar is a menu component for navigation
4+templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
5+ <!-- DaisyUI Navbar -->
6+ <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
7+ <div class="navbar-start">
8+ <div class="flex items-center">
9+ <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
10+ Gordon
11+ </h1>
12+ <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
13+ { buildVersion }
14+ </span>
15+ <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
16+ <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
17+ </span>
18+ </div>
19+ </div>
20+ <div class="navbar-center hidden lg:flex">
21+ <ul class="menu menu-horizontal px-1">
22+ <li>
23+ <a href={ templ.URL(adminPath + "/manager") } class="font-semibold">
24+ <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
25+ </a>
26+ </li>
27+ </ul>
28+ </div>
29+ <div class="navbar-end">
30+ <!-- Theme toggle -->
31+ <div class="dropdown dropdown-end">
32+ <label tabindex="0" class="btn btn-ghost btn-circle">
33+ <span class="iconf text-lg md:text-xl">󰔎</span>
34+ </label>
35+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
36+ <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
37+ <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
38+ <li><button onclick="setTheme('light')">Light</button></li>
39+ <li><button onclick="setTheme('dark')">Dark</button></li>
40+ </ul>
41+ </div>
42+
43+ <!-- User menu -->
44+ <div class="dropdown dropdown-end">
45+ <label tabindex="0" class="btn btn-ghost btn-circle">
46+ <span class="iconf text-lg md:text-xl">󰀉</span>
47+ </label>
48+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
49+ <li>
50+ <a href={ templ.URL(userSettingsURL) } title="User settings">
51+ <span class="iconf">󰒓</span> Settings
52+ </a>
53+ </li>
54+ <li>
55+ <a href={ templ.URL(logoutURL) } title="Logout">
56+ <span class="iconf">󰍃</span> Logout
57+ </a>
58+ </li>
59+ </ul>
60+ </div>
61+ </div>
62+ </div>
63+
64+ <!-- Theme toggle script -->
65+ <script>
66+ function setTheme(theme) {
67+ document.documentElement.setAttribute('data-theme', theme);
68+ localStorage.setItem('theme', theme);
69+ console.debug('Theme changed to:', theme);
70+ }
71+ </script>
72+}⏎̸
A internal/templating/models/templ/components/navbar_templ.go +81 -0 generated file: collapsed
A internal/templating/models/templ/components/success.templ +16 -0
@@ -0,0 +1,16 @@
1+package components
2+
3+// Success displays a success message
4+templ Success(message string) {
5+ <div
6+ id="success"
7+ class="flex text-indigo-600 content-center justify-center items-center w-full h-full">
8+ <p class="text-2xl text-center">
9+ if message != "" {
10+ { message }
11+ } else {
12+ Success!
13+ }
14+ </p>
15+ </div>
16+}⏎̸
A internal/templating/models/templ/components/success_templ.go +61 -0 generated file: collapsed
A internal/templating/models/templ/components/uploadimage.templ +66 -0
@@ -0,0 +1,66 @@
1+package components
2+
3+// UploadImageForm displays a form for uploading Docker images
4+templ UploadImageForm() {
5+ <!-- Upload image form using DaisyUI components -->
6+ <div class="card bg-base-100 shadow">
7+ <div class="card-body">
8+ <div class="flex justify-between items-center">
9+ <h2 class="card-title text-primary">Upload Image</h2>
10+ <button
11+ class="btn btn-sm btn-circle btn-ghost"
12+ hx-get="/htmx/image-manager"
13+ hx-target="#image-manager"
14+ hx-swap="innerHTML">
15+ <span class="iconf text-lg">✕</span>
16+ </button>
17+ </div>
18+ <div class="divider"></div>
19+
20+ <form
21+ id="upload-form"
22+ hx-encoding="multipart/form-data"
23+ hx-post="/htmx/upload-image"
24+ hx-target="#image-manager"
25+ hx-swap="innerHTML"
26+ class="space-y-4">
27+
28+ <div class="alert alert-info shadow-lg mb-4">
29+ <span class="iconf">ℹ️</span>
30+ <div>
31+ <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
32+ <p>After uploading, you can create and manage containers from this image.</p>
33+ </div>
34+ </div>
35+
36+ <div class="form-control w-full">
37+ <label class="label">
38+ <span class="label-text">Select a container image file (.tar)</span>
39+ </label>
40+ <input
41+ type="file"
42+ name="imageFile"
43+ class="file-input file-input-bordered w-full"
44+ accept=".tar"
45+ required />
46+ </div>
47+
48+ <div class="card-actions justify-end mt-4">
49+ <button
50+ class="btn btn-ghost"
51+ hx-get="/htmx/image-manager"
52+ hx-target="#image-manager"
53+ hx-swap="innerHTML"
54+ type="button">
55+ Cancel
56+ </button>
57+ <button
58+ class="btn btn-primary"
59+ type="submit">
60+ <span class="iconf mr-1">󰦒</span> Upload
61+ </button>
62+ </div>
63+ </form>
64+ </div>
65+ </div>
66+} ⏎̸
A internal/templating/models/templ/components/uploadimage_templ.go +41 -0 generated file: collapsed
A internal/templating/models/templ/layouts/admin_layout.templ +21 -0
@@ -0,0 +1,21 @@
1+package layouts
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/components"
4+
5+// AdminLayout provides the base layout for admin pages
6+templ AdminLayout(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
7+ <!DOCTYPE html>
8+ <html lang="en" data-theme="gordon-dark">
9+ @components.Header(title)
10+ <body class="min-h-screen bg-base-100">
11+ @components.Navbar(buildVersion, adminPath, userSettingsURL, logoutURL)
12+
13+ <!-- Main content starts after navbar height -->
14+ <div class="container mx-auto pt-20 px-4">
15+ { children... }
16+ </div>
17+
18+ @components.Footer()
19+ </body>
20+ </html>
21+}⏎̸
A internal/templating/models/templ/layouts/admin_layout_templ.go +75 -0 generated file: collapsed
A internal/templating/models/templ/layouts/base.templ +16 -0
@@ -0,0 +1,16 @@
1+package layouts
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/components"
4+
5+// Base layout template that provides the HTML structure
6+// All pages will use this layout template
7+templ Base(title string) {
8+ <!DOCTYPE html>
9+ <html lang="en">
10+ @components.Header(title)
11+ <body class="min-h-screen bg-base-100">
12+ { children... }
13+ @components.Footer()
14+ </body>
15+ </html>
16+}⏎̸
A internal/templating/models/templ/layouts/base_templ.go +64 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/createcontainerfull.templ +30 -0
@@ -0,0 +1,30 @@
1+package admin
2+
3+import (
4+ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
5+ "github.com/bnema/gordon/internal/templating/models/templ/components"
6+)
7+
8+// CreateContainerFullData represents the data needed for the create container full page
9+type CreateContainerFullData struct {
10+ Title string
11+ BuildVersion string
12+ AdminPath string
13+ UserSettings string
14+ LogoutURL string
15+ ContainerData components.ContainerFormData
16+}
17+
18+// CreateContainerFullPage replaces createcontainerfull.gohtml
19+// It renders the full page for creating a container
20+templ CreateContainerFullPage(data CreateContainerFullData) {
21+ @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
22+ <div class="flex flex-col md:flex-row w-full flex-grow">
23+ <div class="card flex-grow">
24+ <h1 class="card-header mb-4">Create Container</h1>
25+ <!-- Include the container creation form component -->
26+ @components.CreateContainerForm(data.ContainerData)
27+ </div>
28+ </div>
29+ }
30+} ⏎̸
A internal/templating/models/templ/pages/admin/createcontainerfull_templ.go +83 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/dashboard.templ +18 -0
@@ -0,0 +1,18 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// DashboardPage implements the admin dashboard
6+templ DashboardPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
7+ @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) {
8+ <div id="dashboard">
9+ <h1
10+ class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s"
11+ >
12+ Hello World from dashboard.html
13+ </h1>
14+ <!-- We load Chart.js here to avoid loading it in every page end when all the rest is loaded -->
15+ <script src="libs/chart.js"></script>
16+ </div>
17+ }
18+}⏎̸
A internal/templating/models/templ/pages/admin/dashboard_templ.go +61 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/index.templ +36 -0
@@ -0,0 +1,36 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// IndexPageData represents data needed for the index page
6+type IndexPageData struct {
7+ Title string
8+ BuildVersion string
9+ AdminPath string
10+ UserSettings string
11+ LogoutURL string
12+ LangContent map[string]string // For storing language translations
13+}
14+
15+// IndexPage replaces admin/index.gohtml
16+// It renders the admin dashboard index page
17+templ IndexPage(data IndexPageData) {
18+ @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
19+ <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
20+ <div class="card flex-grow">
21+ <h2 class="card-header">{ data.LangContent["title"] }</h2>
22+ </div>
23+ <div class="w-full md:w-2/6 flex flex-col space-y-4">
24+ <div class="card">
25+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
26+ </div>
27+ <div class="card">
28+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
29+ </div>
30+ <div class="card">
31+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
32+ </div>
33+ </div>
34+ </div>
35+ }
36+} ⏎̸
A internal/templating/models/templ/pages/admin/index_templ.go +85 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/install.templ +59 -0
@@ -0,0 +1,59 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// InstallPage replaces install.gohtml
6+// It renders the installation page
7+templ InstallPage(title string, version string) {
8+ @layouts.Base(title) {
9+ <!-- Logo and Title -->
10+ <div
11+ id="logo-mascot"
12+ class="flex flex-col justify-center items-center p-4 mb-4"
13+ >
14+ <div class="circle-shadow flex justify-center w-100">
15+ <img
16+ src="/assets/imgs/gordon-mascot-hq-trsp.png"
17+ alt="logo"
18+ class="sm:h-24 h-32 xl:h-48"
19+ />
20+ </div>
21+ <div class="flex justify-center mt-4">
22+ <h1
23+ class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter"
24+ >
25+ Gordon
26+ </h1>
27+ <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2">
28+ v{ version }
29+ </span>
30+ </div>
31+ </div>
32+
33+ <!-- Main Content -->
34+ <div class="card w-2/3">
35+ <!-- Built-in Proxy -->
36+ <div id="built-in-proxy" class="mt-8">
37+ <div class="flex items-center mb-4">
38+ <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
39+ <span class="text-white font-bold">✓</span>
40+ </div>
41+ <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
42+ </div>
43+
44+ <div class="bg-indigo-50 p-4 rounded-md">
45+ <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
46+ <p>The proxy automatically:</p>
47+ <ul class="list-disc ml-6 mb-4">
48+ <li>Manages Let's Encrypt certificates</li>
49+ <li>Routes domains to the appropriate containers</li>
50+ <li>Handles HTTP to HTTPS redirects</li>
51+ <li>Provides proper headers for forwarded requests</li>
52+ </ul>
53+ <p>No additional configuration is required.</p>
54+ </div>
55+ </div>
56+ <!-- End Built-in Proxy -->
57+ </div>
58+ }
59+} ⏎̸
A internal/templating/models/templ/pages/admin/install_templ.go +75 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/manager.templ +73 -0
@@ -0,0 +1,73 @@
1+package admin
2+
3+import (
4+ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
5+ "strconv"
6+)
7+
8+// ManagerPage implements the container and image manager page
9+templ ManagerPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string, containerCount int, imageCount int) {
10+ @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) {
11+ <!-- Main content -->
12+ <div class="text-sm breadcrumbs mb-4">
13+ <ul>
14+ <li><a href={ templ.URL(adminPath) }>Gordon</a></li>
15+ <li>Manager</li>
16+ </ul>
17+ </div>
18+
19+ <!-- Stats overview -->
20+ <div class="stats shadow mb-6 w-full">
21+ <div class="stat">
22+ <div class="stat-figure text-primary">
23+ <span class="iconf text-3xl">󰡨</span>
24+ </div>
25+ <div class="stat-title">Containers</div>
26+ <div class="stat-value" id="container-count">{ strconv.Itoa(containerCount) }</div>
27+ <div class="stat-desc">Docker containers managed by Gordon</div>
28+ </div>
29+
30+ <div class="stat">
31+ <div class="stat-figure text-secondary">
32+ <span class="iconf text-3xl">󰋊</span>
33+ </div>
34+ <div class="stat-title">Images</div>
35+ <div class="stat-value" id="image-count">{ strconv.Itoa(imageCount) }</div>
36+ <div class="stat-desc">Docker images available</div>
37+ </div>
38+ </div>
39+
40+ <!-- Tabs for containers and images -->
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 />
43+ <div role="tabpanel" class="tab-content p-0">
44+ <!-- Container manager section -->
45+ <div id="container-manager"
46+ hx-get="/htmx/container-manager"
47+ hx-trigger="load"
48+ hx-swap="innerHTML">
49+ <!-- Container content loaded via HTMX -->
50+ <div class="text-center p-4">
51+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
52+ Loading containers...
53+ </div>
54+ </div>
55+ </div>
56+
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">
59+ <!-- Image manager section -->
60+ <div id="image-manager"
61+ hx-get="/htmx/image-manager"
62+ hx-trigger="load"
63+ hx-swap="innerHTML">
64+ <!-- Image content loaded via HTMX -->
65+ <div class="text-center p-4">
66+ <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
67+ Loading images...
68+ </div>
69+ </div>
70+ </div>
71+ </div>
72+ }
73+}⏎̸
A internal/templating/models/templ/pages/admin/manager_templ.go +99 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/user.templ +58 -0
@@ -0,0 +1,58 @@
1+package admin
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// UserPageData represents data needed for the user page
6+type UserPageData struct {
7+ Title string
8+ BuildVersion string
9+ AdminPath string
10+ UserSettings string
11+ LogoutURL string
12+ Username string
13+ Email string
14+ Role string
15+}
16+
17+// UserPage replaces admin/user.gohtml
18+// It renders the user profile page
19+templ UserPage(data UserPageData) {
20+ @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
21+ <div class="text-sm breadcrumbs mb-4">
22+ <ul>
23+ <li><a href={ templ.URL(data.AdminPath) }>Gordon</a></li>
24+ <li>User Profile</li>
25+ </ul>
26+ </div>
27+
28+ <div class="card bg-base-100 shadow-xl">
29+ <div class="card-body">
30+ <h2 class="card-title">User Profile</h2>
31+ <div class="divider"></div>
32+
33+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
34+ <div class="form-control">
35+ <label class="label">
36+ <span class="label-text">Username</span>
37+ </label>
38+ <input type="text" class="input input-bordered" value={ data.Username } readonly />
39+ </div>
40+
41+ <div class="form-control">
42+ <label class="label">
43+ <span class="label-text">Email</span>
44+ </label>
45+ <input type="email" class="input input-bordered" value={ data.Email } readonly />
46+ </div>
47+
48+ <div class="form-control">
49+ <label class="label">
50+ <span class="label-text">Role</span>
51+ </label>
52+ <input type="text" class="input input-bordered" value={ data.Role } readonly />
53+ </div>
54+ </div>
55+ </div>
56+ </div>
57+ }
58+} ⏎̸
A internal/templating/models/templ/pages/admin/user_templ.go +122 -0 generated file: collapsed
A internal/templating/models/templ/pages/errors/errors.templ +46 -0
@@ -0,0 +1,46 @@
1+package errors
2+
3+import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4+
5+// ErrorPageData represents data needed for error pages
6+type ErrorPageData struct {
7+ Title string
8+ BuildVersion string
9+ AdminPath string
10+ StatusCode int
11+ Message string
12+}
13+
14+// NotFoundPage renders a 404 error page
15+templ NotFoundPage(data ErrorPageData) {
16+ @layouts.Base(data.Title) {
17+ <div class="flex flex-col items-center justify-center min-h-screen p-4">
18+ <div class="flex flex-col items-center mb-8">
19+ <img src="/assets/imgs/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" />
20+ <h1 class="text-4xl font-bold text-error">404</h1>
21+ <h2 class="text-2xl mb-2">Page Not Found</h2>
22+ <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p>
23+ <a href={ templ.URL(data.AdminPath) } class="btn btn-primary">
24+ <span class="iconf mr-2">🏠</span> Go Home
25+ </a>
26+ </div>
27+ </div>
28+ }
29+}
30+
31+// ForbiddenPage renders a 403 error page
32+templ ForbiddenPage(data ErrorPageData) {
33+ @layouts.Base(data.Title) {
34+ <div class="flex flex-col items-center justify-center min-h-screen p-4">
35+ <div class="flex flex-col items-center mb-8">
36+ <img src="/assets/imgs/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" />
37+ <h1 class="text-4xl font-bold text-error">403</h1>
38+ <h2 class="text-2xl mb-2">Access Forbidden</h2>
39+ <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p>
40+ <a href={ templ.URL(data.AdminPath) } class="btn btn-primary">
41+ <span class="iconf mr-2">🏠</span> Go Home
42+ </a>
43+ </div>
44+ </div>
45+ }
46+} ⏎̸
A internal/templating/models/templ/pages/errors/errors_templ.go +162 -0 generated file: collapsed
A internal/templating/models/templ/pages/login/index.templ +126 -0
@@ -0,0 +1,126 @@
1+package login
2+
3+// LoginPage implements the login page with token and GitHub authentication
4+templ LoginPage(title string, buildVersion string, errorMsg string) {
5+ <!doctype html>
6+ <html lang="en" data-theme="gordon-dark">
7+ <head>
8+ <meta charset="UTF-8" />
9+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10+ <title>{ title }</title>
11+ <meta name="description" content="Gordon Container Management System" />
12+ <meta name="keywords" content="container, management, docker, podman" />
13+ <!-- Custom CSS + Tailwind -->
14+ <link rel="stylesheet" href="/assets/css/tailwind.css" />
15+ <!-- HTMX min js -->
16+ <script src="/libs/htmx.min.js"></script>
17+ <!-- HTMX SSE -->
18+ <script src="/libs/htmx-sse.js"></script>
19+ <!-- DaisyUI -->
20+ <script src="/libs/daisyui.js"></script>
21+ <!-- Custom JS from assets/js -->
22+ <script src="/assets/js/custom.js"></script>
23+
24+ <!-- daisyUI theme configuration -->
25+ <script>
26+ // Check for saved theme preference or use preferred color scheme
27+ const savedTheme = localStorage.getItem('theme') || 'gordon-dark';
28+ document.documentElement.setAttribute('data-theme', savedTheme);
29+ console.debug('Theme loaded:', savedTheme);
30+ </script>
31+ </head>
32+
33+ <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4">
34+ <!-- Logo and Title -->
35+ <div
36+ id="logo-mascot"
37+ class="flex flex-col justify-center items-center p-4 mb-4">
38+ <div class="flex justify-center w-100">
39+ <img
40+ src="/assets/imgs/gordon-mascot-mq-trsp.png"
41+ alt="logo gordon"
42+ class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" />
43+ </div>
44+ <div class="flex justify-center mt-4">
45+ <h1
46+ class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter">
47+ Gordon
48+ </h1>
49+ <span
50+ class="gordon-v-subtitle text-secondary text-sm ml-2"
51+ >{ buildVersion }</span>
52+ </div>
53+ </div>
54+
55+ <!-- Main Content -->
56+ <div class="card bg-base-100 shadow-xl w-full max-w-md">
57+ <div class="card-body">
58+ <!-- Login Section -->
59+ <div id="login-section">
60+ <div class="flex flex-col items-center">
61+ <h2 class="card-title text-center mb-4">Login</h2>
62+
63+ <!-- Error Message Display -->
64+ if errorMsg != "" {
65+ <div class="alert alert-error mb-4">
66+ <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
67+ <span>
68+ if errorMsg == "empty_token" {
69+ Please enter a token.
70+ } else if errorMsg == "invalid_token" {
71+ Invalid token. Please try again.
72+ } else if errorMsg == "server_error" {
73+ A server error occurred. Please try again later.
74+ } else {
75+ An error occurred. Please try again.
76+ }
77+ </span>
78+ </div>
79+ }
80+
81+ <!-- Token Input Form -->
82+ <div class="w-full">
83+ <form action="login/submit-token" method="POST" class="mb-4">
84+ <div class="form-control mb-4">
85+ <label for="token" class="label">
86+ <span class="label-text">Admin Token</span>
87+ </label>
88+ <input
89+ type="text"
90+ id="token"
91+ name="token"
92+ placeholder="Enter your admin token"
93+ class="input input-bordered w-full"
94+ required
95+ />
96+ </div>
97+ <button
98+ type="submit"
99+ class="btn btn-primary w-full"
100+ >
101+ Continue with Token
102+ </button>
103+ </form>
104+ <div class="divider">OR</div>
105+ </div>
106+
107+ <!-- GitHub OAuth -->
108+ <div class="w-full">
109+ <a
110+ href="login/oauth/github"
111+ class="btn btn-secondary w-full"
112+ >
113+ <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
114+ <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
115+ </svg>
116+ Sign in with GitHub
117+ </a>
118+ </div>
119+ </div>
120+ </div>
121+ <!-- End Login Section -->
122+ </div>
123+ </div>
124+ </body>
125+ </html>
126+}⏎̸
A internal/templating/models/templ/pages/login/index_templ.go +102 -0 generated file: collapsed
A internal/templating/render/templ.go +42 -0
@@ -0,0 +1,42 @@
1+package render
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+
8+ "github.com/a-h/templ"
9+ "github.com/bnema/gordon/internal/server"
10+ "github.com/labstack/echo/v4"
11+)
12+
13+// Renderer for templ templates
14+type TemplRenderer struct {
15+ BuildVersion string
16+ LatestVersion string
17+}
18+
19+// RenderTempl renders a templ component
20+func (r *TemplRenderer) RenderTempl(ctx echo.Context, component templ.Component) error {
21+ // Set the content type and status code
22+ ctx.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML)
23+ ctx.Response().WriteHeader(http.StatusOK)
24+
25+ // Create a render context
26+ renderCtx := context.WithValue(ctx.Request().Context(), "BuildVersion", r.BuildVersion)
27+
28+ // Render the component
29+ err := component.Render(renderCtx, ctx.Response().Writer)
30+ if err != nil {
31+ return fmt.Errorf("failed to render templ component: %w", err)
32+ }
33+
34+ return nil
35+}
36+
37+// NewTemplRenderer creates a new templ renderer
38+func NewTemplRenderer(a *server.App) *TemplRenderer {
39+ return &TemplRenderer{
40+ BuildVersion: a.Config.Build.BuildVersion,
41+ }
42+}⏎̸
A internal/templating/templ_embed.go +13 -0
@@ -0,0 +1,13 @@
1+package templating
2+
3+// This file contains templ components embeddings
4+// As we migrate from traditional Go templates to templ,
5+// we'll add the appropriate code here to handle templ templates.
6+
7+import (
8+ // Import templ generated templates, which will make them available to the application
9+ _ "github.com/bnema/gordon/internal/templating/models/templ/components"
10+ _ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
11+ _ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
12+ _ "github.com/bnema/gordon/internal/templating/models/templ/pages/login"
13+)⏎̸
M internal/webui/public/assets/css/custom.css +112 -3
@@ -6,9 +6,118 @@
6 6
7 /* Embeded path : assets/css/cutsom.css */7 /* Embeded path : assets/css/cutsom.css */
8 8
9-@tailwind base;9+@import "tailwindcss";
10-@tailwind components;10+@source "../../../templating/models/html/**/*.{gohtml,go}";
11-@tailwind utilities;11+@plugin "daisyui";
12+
13+/* Custom DaisyUI theme */
14+@plugin "daisyui/theme" {
15+ name: "gordon-dark";
16+ default: true;
17+ prefersdark: true;
18+ color-scheme: "dark";
19+ --color-base-100: oklch(28% 0.091 267.935);
20+ --color-base-200: oklch(13% 0.028 261.692);
21+ --color-base-300: oklch(20% 0.042 265.755);
22+ --color-base-content: oklch(93% 0.034 272.788);
23+ --color-primary: oklch(87% 0 0);
24+ --color-primary-content: oklch(14% 0 0);
25+ --color-secondary: oklch(83% 0.145 321.434);
26+ --color-secondary-content: oklch(29% 0.136 325.661);
27+ --color-accent: oklch(80% 0.105 251.813);
28+ --color-accent-content: oklch(28% 0.091 267.935);
29+ --color-neutral: oklch(37% 0.034 259.733);
30+ --color-neutral-content: oklch(98% 0.002 247.839);
31+ --color-info: oklch(68% 0.169 237.323);
32+ --color-info-content: oklch(97% 0.013 236.62);
33+ --color-success: oklch(70% 0.14 182.503);
34+ --color-success-content: oklch(98% 0.014 180.72);
35+ --color-warning: oklch(76% 0.188 70.08);
36+ --color-warning-content: oklch(98% 0.022 95.277);
37+ --color-error: oklch(64% 0.246 16.439);
38+ --color-error-content: oklch(96% 0.015 12.422);
39+ --radius-selector: 1rem;
40+ --radius-field: 0rem;
41+ --radius-box: 0.25rem;
42+ --size-selector: 0.25rem;
43+ --size-field: 0.25rem;
44+ --border: 1px;
45+ --depth: 0;
46+ --noise: 0;
47+}
48+
49+
50+/* Custom component classes */
51+@layer components {
52+ /* Button styles */
53+ .btn-primary {
54+ @apply flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
55+ }
56+
57+ .btn-secondary {
58+ @apply flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2;
59+ }
60+
61+ .btn-danger {
62+ @apply flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
63+ }
64+
65+ /* Alert styles */
66+ .alert-error {
67+ @apply rounded-md bg-red-50 p-4 dark:bg-red-900/20;
68+ }
69+
70+ .alert-error-icon {
71+ @apply h-5 w-5 text-red-400;
72+ }
73+
74+ .alert-error-text {
75+ @apply text-sm font-medium text-red-800 dark:text-red-300;
76+ }
77+
78+ .alert-success {
79+ @apply rounded-md bg-green-50 p-4 dark:bg-green-900/20;
80+ }
81+
82+ .alert-success-icon {
83+ @apply h-5 w-5 text-green-400;
84+ }
85+
86+ .alert-success-text {
87+ @apply text-sm font-medium text-green-800 dark:text-green-300;
88+ }
89+
90+ /* Card styles */
91+ .card {
92+ @apply bg-white p-4 rounded-md shadow mb-4;
93+ }
94+
95+ .card-header {
96+ @apply text-lg font-semibold text-indigo-600 mb-2;
97+ }
98+
99+ /* Background styles */
100+ .bg-app {
101+ @apply bg-indigo-50;
102+ }
103+
104+ .bg-sidebar {
105+ @apply bg-indigo-900;
106+ }
107+
108+ .bg-card {
109+ @apply bg-white;
110+ }
111+
112+ /* Text styles */
113+ .text-title {
114+ @apply text-indigo-600 font-semibold;
115+ }
116+
117+ .text-subtitle {
118+ @apply text-indigo-500;
119+ }
120+}
12 121
13 /* Import IBM Plex Fonts */122 /* Import IBM Plex Fonts */
14 @font-face {123 @font-face {
M internal/webui/public/assets/css/tailwind.css +8334 -1315 large diff (9649 lines changed): collapsed
M internal/webui/public/assets/js/custom.js +33 -1
@@ -187,18 +187,36 @@ function refreshContainerList() {
187 187
188 // Show loading indicator188 // Show loading indicator
189 const containerManager = $('#container-manager');189 const containerManager = $('#container-manager');
190+ if (containerManager.length === 0) {
191+ console.error("Container manager element not found in DOM");
192+ return;
193+ }
194+
195+ console.debug("Container manager element found:", containerManager[0]);
196+ console.debug("Container manager classes:", containerManager.attr('class'));
197+
190 const originalContent = containerManager.html();198 const originalContent = containerManager.html();
191 containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading containers...</div>');199 containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading containers...</div>');
192 200
201+ // Ensure the container is visible
202+ containerManager.removeClass('hidden');
203+
193 $.ajax({204 $.ajax({
194 url: '/htmx/container-manager',205 url: '/htmx/container-manager',
195 type: 'GET',206 type: 'GET',
196 success: function(data) {207 success: function(data) {
208+ console.debug("Container data received, length:", data.length);
197 $('#container-manager').html(data);209 $('#container-manager').html(data);
210+
211+ // Force visibility
212+ $('#container-manager').removeClass('hidden');
213+
198 console.debug("Container list refreshed successfully");214 console.debug("Container list refreshed successfully");
199 },215 },
200 error: function(xhr, status, error) {216 error: function(xhr, status, error) {
201 console.error("Error refreshing container list:", error);217 console.error("Error refreshing container list:", error);
218+ console.error("Status:", status);
219+ console.error("Response:", xhr.responseText);
202 // Restore original content on error220 // Restore original content on error
203 containerManager.html(originalContent);221 containerManager.html(originalContent);
204 }222 }
@@ -211,6 +229,14 @@ function refreshImageList() {
211 229
212 // Show loading indicator230 // Show loading indicator
213 const imageManager = $('#image-manager');231 const imageManager = $('#image-manager');
232+ if (imageManager.length === 0) {
233+ console.error("Image manager element not found in DOM");
234+ return;
235+ }
236+
237+ console.debug("Image manager element found:", imageManager[0]);
238+ console.debug("Image manager classes:", imageManager.attr('class'));
239+
214 const originalContent = imageManager.html();240 const originalContent = imageManager.html();
215 imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading images...</div>');241 imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading images...</div>');
216 242
@@ -218,11 +244,14 @@ function refreshImageList() {
218 url: '/htmx/image-manager',244 url: '/htmx/image-manager',
219 type: 'GET',245 type: 'GET',
220 success: function(data) {246 success: function(data) {
247+ console.debug("Image data received, length:", data.length);
221 $('#image-manager').html(data);248 $('#image-manager').html(data);
222 console.debug("Image list refreshed successfully");249 console.debug("Image list refreshed successfully");
223 },250 },
224 error: function(xhr, status, error) {251 error: function(xhr, status, error) {
225 console.error("Error refreshing image list:", error);252 console.error("Error refreshing image list:", error);
253+ console.error("Status:", status);
254+ console.error("Response:", xhr.responseText);
226 // Restore original content on error255 // Restore original content on error
227 imageManager.html(originalContent);256 imageManager.html(originalContent);
228 }257 }
@@ -236,6 +265,9 @@ function setupHtmxEventListeners() {
236 const targetId = event.detail.target.id;265 const targetId = event.detail.target.id;
237 console.debug(`HTMX afterOnLoad event for target: ${targetId}`);266 console.debug(`HTMX afterOnLoad event for target: ${targetId}`);
238 267
268+ // Remove the tab visibility fix which was interfering with DaisyUI
269+ // DaisyUI handles tab visibility automatically with the input[type="radio"] approach
270+
239 // Check if the target is an action response271 // Check if the target is an action response
240 if (targetId && targetId.startsWith('actionResponse-')) {272 if (targetId && targetId.startsWith('actionResponse-')) {
241 console.debug(`Action completed for ${targetId}`);273 console.debug(`Action completed for ${targetId}`);
@@ -257,7 +289,7 @@ function setupHtmxEventListeners() {
257 console.debug('Image manager updated');289 console.debug('Image manager updated');
258 290
259 // Show notification for successful upload291 // Show notification for successful upload
260- if (event.detail.xhr.responseText.includes('Success')) {292+ if (event.detail.xhr && event.detail.xhr.responseText && event.detail.xhr.responseText.includes('Success')) {
261 showNotification('Image uploaded successfully', 'success');293 showNotification('Image uploaded successfully', 'success');
262 }294 }
263 295
A internal/webui/public/libs/chart.js +20 -0 too large to show in the browser

too large to show in the browser view raw

A internal/webui/public/libs/daisyui.js +8 -0 too large to show in the browser

too large to show in the browser view raw

M package.json +3 -1
@@ -1,5 +1,7 @@
1 {1 {
2+ "type": "module",
2 "devDependencies": {3 "devDependencies": {
4+ "daisyui": "^5.0.6",
3 "prettier": "^3.5.3",5 "prettier": "^3.5.3",
4 "prettier-plugin-tailwindcss": "^0.5.14",6 "prettier-plugin-tailwindcss": "^0.5.14",
5 "tailwindcss": "^4.0.14"7 "tailwindcss": "^4.0.14"
@@ -9,7 +11,7 @@
9 "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css"11 "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css"
10 },12 },
11 "dependencies": {13 "dependencies": {
12- "caniuse-lite": "^1.0.30001705",14+ "@tailwindcss/cli": "^4.0.14",
13 "dotenv": "^16.4.7"15 "dotenv": "^16.4.7"
14 }16 }
15 }17 }
M pkg/docker/client.go +19 -3
@@ -28,14 +28,15 @@ func InitializeClient(config *Config) error {
28 return fmt.Errorf("sock field in Config is empty")28 return fmt.Errorf("sock field in Config is empty")
29 }29 }
30 30
31+ logger.Debug("Checking socket file existence", "socket_path", config.Sock)
31 if _, err := os.Stat(config.Sock); os.IsNotExist(err) {32 if _, err := os.Stat(config.Sock); os.IsNotExist(err) {
32- logger.Error("Failed to initialize Docker client", "error", err)33+ logger.Error("Failed to initialize Docker client", "error", err, "socket_path", config.Sock)
33 return err34 return err
34 }35 }
35 36
36 // For Podman compatibility, we need to ensure we're using the right connection approach37 // For Podman compatibility, we need to ensure we're using the right connection approach
37 host := "unix://" + config.Sock38 host := "unix://" + config.Sock
38- logger.Debug("Setting up Docker client", "host", host)39+ logger.Debug("Setting up Docker client", "host", host, "podman_enabled", config.PodmanEnable)
39 40
40 // Create the Docker client with appropriate options41 // Create the Docker client with appropriate options
41 cli, err := client.NewClientWithOpts(42 cli, err := client.NewClientWithOpts(
@@ -47,7 +48,18 @@ func InitializeClient(config *Config) error {
47 return fmt.Errorf("error initializing Docker client: %s", err)48 return fmt.Errorf("error initializing Docker client: %s", err)
48 }49 }
49 50
50- logger.Debug("Docker client initialized", "socket", config.Sock, "podman_enabled", config.PodmanEnable)51+ // Test the connection to make sure it works
52+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
53+ defer cancel()
54+
55+ logger.Debug("Testing Docker client connection", "socket", config.Sock)
56+ _, err = cli.Ping(ctx)
57+ if err != nil {
58+ logger.Error("Docker client ping failed", "error", err, "socket", config.Sock)
59+ return fmt.Errorf("docker daemon not responding: %s", err)
60+ }
61+
62+ logger.Debug("Docker client initialized successfully", "socket", config.Sock, "podman_enabled", config.PodmanEnable)
51 dockerCli = cli63 dockerCli = cli
52 currentConfig = config64 currentConfig = config
53 return nil65 return nil
@@ -56,17 +68,20 @@ func InitializeClient(config *Config) error {
56 func CheckIfInitialized() error {68 func CheckIfInitialized() error {
57 if currentConfig == nil || currentConfig.Sock == "" {69 if currentConfig == nil || currentConfig.Sock == "" {
58 logger.Error("Docker client check failed", "error", "client not initialized or configuration missing")70 logger.Error("Docker client check failed", "error", "client not initialized or configuration missing")
71+ logger.Debug("Docker client initialization failed: configuration is missing or empty")
59 return fmt.Errorf("docker client is not initialized or configuration is missing")72 return fmt.Errorf("docker client is not initialized or configuration is missing")
60 }73 }
61 74
62 socketPath := currentConfig.Sock75 socketPath := currentConfig.Sock
63 if _, err := os.Stat(socketPath); os.IsNotExist(err) {76 if _, err := os.Stat(socketPath); os.IsNotExist(err) {
64 logger.Error("Docker client check failed", "error", "socket file not found", "path", socketPath)77 logger.Error("Docker client check failed", "error", "socket file not found", "path", socketPath)
78+ logger.Debug("Docker client check failed: socket file not found", "path", socketPath)
65 return fmt.Errorf("sock file does not exist: %s", socketPath)79 return fmt.Errorf("sock file does not exist: %s", socketPath)
66 }80 }
67 81
68 if dockerCli == nil {82 if dockerCli == nil {
69 logger.Error("Docker client check failed", "error", "client is nil")83 logger.Error("Docker client check failed", "error", "client is nil")
84+ logger.Debug("Docker client check failed: client is nil")
70 return fmt.Errorf("docker client is not initialized")85 return fmt.Errorf("docker client is not initialized")
71 }86 }
72 87
@@ -76,6 +91,7 @@ func CheckIfInitialized() error {
76 _, err := dockerCli.ContainerList(ctx, container.ListOptions{})91 _, err := dockerCli.ContainerList(ctx, container.ListOptions{})
77 if err != nil {92 if err != nil {
78 logger.Error("Docker client check failed", "error", err)93 logger.Error("Docker client check failed", "error", err)
94+ logger.Debug("Docker client check failed: cannot connect to Docker daemon", "error", err)
79 return fmt.Errorf("cannot connect to Docker daemon: %s", err)95 return fmt.Errorf("cannot connect to Docker daemon: %s", err)
80 }96 }
81 97
M pkg/docker/containers.go +7 -5
@@ -100,10 +100,7 @@ func ListRunningContainers() ([]types.Container, error) {
100 if err != nil {100 if err != nil {
101 return nil, err101 return nil, err
102 }102 }
103- // Check if the list is empty103+ // Just return the containers, even if it's an empty slice
104- if len(containers) == 0 {
105- return nil, fmt.Errorf("no containers found")
106- }
107 return containers, nil104 return containers, nil
108 }105 }
109 106
@@ -420,6 +417,11 @@ func CheckIfNetworkExists(networkName string) (bool, error) {
420 417
421 // GetNetworkInfo returns information about a network418 // GetNetworkInfo returns information about a network
422 func GetNetworkInfo(networkName string) (*network.Inspect, error) {419 func GetNetworkInfo(networkName string) (*network.Inspect, error) {
420+ // Check if Docker client is initialized
421+ if err := CheckIfInitialized(); err != nil {
422+ return nil, fmt.Errorf("cannot get network info: %w", err)
423+ }
424+
423 networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{})425 networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{})
424 if err != nil {426 if err != nil {
425 return nil, err427 return nil, err
@@ -947,7 +949,7 @@ func ListenForContainerEvents(networkFilter string, startCallback func(string, s
947 logger.Debug("Container stopped",949 logger.Debug("Container stopped",
948 "container_id", event.id,950 "container_id", event.id,
949 "container_name", event.name)951 "container_name", event.name)
950- 952+
951 // Call the stop callback if provided953 // Call the stop callback if provided
952 if stopCallback != nil {954 if stopCallback != nil {
953 logger.Debug("Calling container stop callback",955 logger.Debug("Calling container stop callback",
A pkg/scripts/container_diagnostic/diagnostic.sh +171 -0
@@ -0,0 +1,171 @@
1+#!/bin/bash
2+
3+# Gordon Container Diagnostic Script
4+# Usage: podman exec -it gordon /bin/sh /diagnostic.sh
5+
6+echo "----- Gordon System Information -----"
7+echo "Date: $(date)"
8+echo "Hostname: $(hostname)"
9+echo "Container ID: $(hostname)"
10+
11+echo "----- Network Interfaces -----"
12+ip addr show
13+
14+echo "----- Network Routes -----"
15+ip route show
16+
17+echo "----- Listening Ports -----"
18+netstat -tuln
19+
20+echo "----- Process Listening On Ports -----"
21+netstat -tulnp
22+
23+echo "----- HTTP Server Test -----"
24+curl -v http://localhost:8080/admin/ping 2>&1 | grep -v '^{.*}'
25+
26+echo "----- HTTPS/HTTP Binding Test -----"
27+# Try to create listeners on ports 80, 443, and 8080 to test if they're available
28+# More reliable port check using timeout
29+check_port() {
30+ local port=$1
31+ local service=$2
32+ # Try to bind to the port briefly
33+ timeout 1 bash -c "nc -l -p $port >/dev/null 2>&1" >/dev/null 2>&1
34+ if [ $? -eq 124 ] || [ $? -eq 1 ]; then
35+ # Timeout or error means port is likely in use
36+ echo "✅ Port $port is in use - $service server is likely running"
37+ echo "Process using port $port:"
38+ # Multiple ways to check what's using the port
39+ lsof -i :$port 2>/dev/null || \
40+ ss -tulpn | grep ":$port" 2>/dev/null || \
41+ netstat -tulpn | grep ":$port" 2>/dev/null || \
42+ ls -l /proc/*/fd/* 2>/dev/null | grep -i "0.0.0.0:$port" | head -5
43+ else
44+ echo "❌ WARNING: Port $port is NOT in use - $service server is not running!"
45+ # Get the Gordon PID
46+ GORDON_PID=$(pgrep -f "/gordon serve" || pgrep -f "gordon$" || echo "")
47+ if [ -n "$GORDON_PID" ]; then
48+ echo "Gordon process found (PID: $GORDON_PID)"
49+
50+ # Check if anything is preventing binding to the port
51+ echo "Checking process capabilities:"
52+ grep Cap /proc/$GORDON_PID/status || echo "Could not check process capabilities"
53+
54+ # Check if the process tried to bind but failed (search logs)
55+ echo "Checking for binding errors in Gordon logs for port $port:"
56+ grep -i "error\|fail\|warn" /proc/1/fd/1 | grep -i "server\|listen\|bind\|port $port" | tail -10 || echo "No specific binding errors found in logs"
57+
58+ # Try to get socket information
59+ echo "Socket status for port $port:"
60+ ss -tulpen | grep ":$port" || echo "No socket listening on port $port"
61+
62+ # Check port availability externally
63+ echo "Testing if port can be bound explicitly:"
64+ timeout 2 nc -l -p $port -v 2>&1 || echo "Could not bind to port $port"
65+ else
66+ echo "Gordon process not found"
67+ fi
68+ fi
69+ echo ""
70+}
71+
72+# Check main ports
73+check_port 80 "HTTP"
74+check_port 443 "HTTPS"
75+check_port 8080 "Main HTTP"
76+
77+# Additional detailed diagnostics for HTTPS/TLS
78+echo "----- Detailed TLS Server Diagnostics -----"
79+# Check for TLS configuration issues
80+echo "TLS configuration in logs:"
81+grep -i "TLS\|cert\|getCertificate\|fallback" /proc/1/fd/1 | tail -15 || echo "No TLS configuration logs found"
82+
83+# Check for listener creation logs
84+echo "Listener creation logs:"
85+grep -i "listener\|starting.*server\|bound to" /proc/1/fd/1 | tail -15 || echo "No listener creation logs found"
86+
87+# Check for server startup sequence issues
88+echo "Server startup sequence:"
89+grep -i "starting\|init\|ready\|signal" /proc/1/fd/1 | grep -v "DEBUG" | tail -20 || echo "No server startup sequence logs found"
90+
91+# Check port binding status in container
92+echo "Port mapping configuration:"
93+cat /proc/1/environ | tr '\0' '\n' | grep -i "PORT\|GORDON_PROXY" || echo "No port mapping environment variables found"
94+
95+echo "----- Privileges and Capabilities Check -----"
96+# Check process privileges
97+echo "Process privileges:"
98+id || echo "Could not get process ID"
99+
100+# Check capabilities
101+echo "Process capabilities:"
102+if command -v capsh >/dev/null 2>&1; then
103+ capsh --print || echo "Could not run capsh"
104+else
105+ grep Cap /proc/self/status || echo "Could not check capabilities via proc"
106+fi
107+
108+# Check container security context
109+echo "Container security context:"
110+if [ -f "/.dockerenv" ] || [ -f "/run/.containerenv" ]; then
111+ echo "Running in a container"
112+ ls -la /proc/1/ns/ || echo "Could not check namespaces"
113+else
114+ echo "Not running in a container"
115+fi
116+
117+echo "----- Running Gordon Processes -----"
118+ps aux | grep -i gordon | grep -v grep
119+
120+echo "----- DNS Resolution Test -----"
121+# Try to resolve our own domain
122+DOMAIN=$(grep GORDON_HTTP_DOMAIN /proc/1/environ | cut -d= -f2 | tr -d '\0')
123+SUBDOMAIN=$(grep GORDON_HTTP_SUBDOMAIN /proc/1/environ | cut -d= -f2 | tr -d '\0')
124+if [ -n "$DOMAIN" ] && [ -n "$SUBDOMAIN" ]; then
125+ FQDN="${SUBDOMAIN}.${DOMAIN}"
126+ echo "Testing DNS resolution for $FQDN"
127+ dig $FQDN +short
128+ host $FQDN
129+else
130+ echo "Domain information not found in environment"
131+fi
132+
133+echo "----- TLS Certificate Status -----"
134+CERT_DIR=/certs
135+if [ -d "$CERT_DIR" ]; then
136+ echo "Certificate directory contents:"
137+ ls -la $CERT_DIR
138+
139+ # Check for certificates for our domain
140+ if [ -n "$FQDN" ]; then
141+ echo "Looking for certificates for $FQDN:"
142+ find $CERT_DIR -name "*$FQDN*" -o -name "cert-$FQDN"
143+ fi
144+else
145+ echo "Certificate directory $CERT_DIR not found"
146+fi
147+
148+echo "----- Gordon HTTPS Server Logs -----"
149+echo "Last 20 log entries related to HTTPS server initialization:"
150+grep -i "server\|tls\|https\|Certificate\|net\." /proc/1/fd/1 | tail -20
151+
152+echo "----- Goroutines Status -----"
153+# Check if GOMAXPROCS is set too high
154+echo "GOMAXPROCS setting:"
155+grep -i "GOMAXPROCS" /proc/1/environ | tr '\0' '\n' || echo "GOMAXPROCS not explicitly set"
156+
157+# Check the number of threads used by gordon
158+echo "Number of threads used by Gordon process:"
159+pid=$(pgrep -f "^/gordon")
160+if [ -n "$pid" ]; then
161+ ls -l /proc/$pid/task | wc -l
162+else
163+ echo "Gordon process not found"
164+fi
165+
166+echo "----- TCP Connection Status -----"
167+# Check existing TCP connections and states
168+echo "TCP connection states:"
169+netstat -nat | awk '{print $6}' | sort | uniq -c
170+
171+echo "----- End of Diagnostic -----" ⏎̸
M tailwind.config.js +39 -0
@@ -14,7 +14,46 @@ module.exports = {
14 lbeige: "#FFDCB3",14 lbeige: "#FFDCB3",
15 mbeige: "#D7B49F",15 mbeige: "#D7B49F",
16 },16 },
17+ // Custom color scheme
18+ indigo: {
19+ 50: "#eef2ff",
20+ 100: "#e0e7ff",
21+ 200: "#c7d2fe",
22+ 300: "#a5b4fc",
23+ 400: "#818cf8",
24+ 500: "#6366f1",
25+ 600: "#4f46e5",
26+ 700: "#4338ca",
27+ 800: "#3730a3",
28+ 900: "#312e81",
29+ 950: "#1e1b4b",
30+ },
31+ red: {
32+ 50: "#fef2f2",
33+ 100: "#fee2e2",
34+ 200: "#fecaca",
35+ 300: "#fca5a5",
36+ 400: "#f87171",
37+ 500: "#ef4444",
38+ 600: "#dc2626",
39+ 700: "#b91c1c",
40+ 800: "#991b1b",
41+ 900: {
42+ DEFAULT: "#7f1d1d",
43+ 20: "rgba(127, 29, 29, 0.2)",
44+ },
45+ 950: "#450a0a",
46+ },
17 },47 },
18 },48 },
19 },49 },
50+ plugins: [require("daisyui")],
51+ daisyui: {
52+ themes: ["light", "dark", "gordon", "gordon-dark"],
53+ darkTheme: "gordon-dark",
54+ base: true,
55+ styled: true,
56+ utils: true,
57+ logs: true,
58+ },
20 };59 };
A tools.go +7 -0
@@ -0,0 +1,7 @@
1+// +build tools
2+
3+package tools
4+
5+import (
6+ _ "github.com/a-h/templ/cmd/templ"
7+)⏎̸

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close