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
33 before:
44 hooks:
55 - go mod tidy
6+ - templ generate
7+ - bun run build:css
68
79 release:
810 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 @@
11 FROM --platform=$TARGETPLATFORM alpine:latest
22
3+# Install diagnostic utilities
4+RUN apk add --no-cache \
5+ curl \
6+ wget \
7+ net-tools \
8+ bind-tools \
9+ busybox-extras
10+
311 COPY gordon /gordon
12+COPY pkg/scripts/container_diagnostic/diagnostic.sh /diagnostic.sh
13+RUN chmod +x /diagnostic.sh
414
515 RUN touch /.iscontainer
616
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+
723 ENTRYPOINT ["/gordon"]
8-CMD ["serve"]
24+CMD ["serve", "--use-fallback-binding", "--proxy-debug"]
M Makefile +8 -2
@@ -20,13 +20,13 @@ LDFLAGS := -s -w \
2020 ARCHS := amd64 arm64
2121
2222 # Phony targets
23-.PHONY: all build build-push clean build-css
23+.PHONY: all build build-push clean build-css build-templ
2424
2525 # Default target
2626 all: build
2727
2828 # Build binaries
29-build: build-css
29+build: build-css build-templ
3030 @echo "Building Go binaries..."
3131 @mkdir -p $(DIST_DIR)
3232 @rm -f $(DIST_DIR)/*
@@ -41,6 +41,12 @@ build-css:
4141 @bun run build:css
4242 @echo "CSS built successfully"
4343
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+
4450 # Build and push Docker images
4551 build-push: build-css build
4652 @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:
4444 # Reverse Proxy Configuration
4545 - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
4646 - 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)
4748 - GORDON_PROXY_CERT_DIR=/certs
4849 - 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)
5052
5153 # Session Secret for security
5254 - SESSION_SECRET=replace_with_secure_random_string
@@ -97,8 +99,11 @@ networks:
9799 # # Reverse Proxy Configuration
98100 # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
99101 # - 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)
100103 # - GORDON_PROXY_CERT_DIR=/certs
101104 # - 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)
102107 #
103108 # # Session Secret for security
104109 # - SESSION_SECRET=replace_with_secure_random_string
M go.mod +9 -0
@@ -6,6 +6,7 @@ require (
66 github.com/AlecAivazis/survey/v2 v2.3.7
77 github.com/Masterminds/semver/v3 v3.3.1
88 github.com/PuerkitoBio/goquery v1.10.2
9+ github.com/a-h/templ v0.3.833
910 github.com/charmbracelet/bubbles v0.20.0
1011 github.com/charmbracelet/lipgloss v1.0.0
1112 github.com/charmbracelet/log v0.4.0
@@ -24,12 +25,20 @@ require (
2425 )
2526
2627 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
2731 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
2834 github.com/klauspost/compress v1.18.0 // indirect
2935 github.com/klauspost/cpuid/v2 v2.2.10 // indirect
36+ github.com/natefinch/atomic v1.0.1 // indirect
3037 github.com/zeebo/xxh3 v1.0.2 // indirect
3138 go.mongodb.org/mongo-driver v1.17.3 // indirect
3239 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
3342 google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
3443 )
3544
M go.sum +14 -2 generated file: collapsed
M internal/cli/cmd/serve.go +68 -3
@@ -4,6 +4,7 @@ package cmd
44
55 import (
66 "fmt"
7+ "os"
78
89 "github.com/bnema/gordon/internal/cli/handler"
910 "github.com/bnema/gordon/internal/common"
@@ -17,6 +18,13 @@ import (
1718 func NewServeCommand(a *server.App) *cobra.Command {
1819 defaultport := "1323"
1920 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
2028
2129 // if the server is running in a docker container, the default port is 8080
2230 // (changed from 80 to avoid conflict with the reverse proxy)
@@ -36,9 +44,56 @@ func NewServeCommand(a *server.App) *cobra.Command {
3644 fmt.Println("Continuing with Docker/Podman functionality disabled")
3745 }
3846
39- // handler.StartServer will use the value of port, which will be the default
40- // value if no flag is specified
41- a.Config.Http.Port = port
47+ // Only use the port from flag if it was explicitly set by the user
48+ // otherwise keep the one from configuration (which has env vars applied)
49+ 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+
4297 err := handler.StartServer(a, port)
4398 if err != nil {
4499 logger.Error("Server error", "error", err)
@@ -53,5 +108,15 @@ func NewServeCommand(a *server.App) *cobra.Command {
53108
54109 // Attach the flag to serveCmd and store its value in the variable port
55110 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+
56121 return serveCmd
57122 }
M internal/cli/handler/start_server.go +55 -15
@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 "github.com/bnema/gordon/internal/httpserve"
13+ "github.com/bnema/gordon/internal/proxy"
1314 "github.com/bnema/gordon/internal/server"
1415 "github.com/bnema/gordon/pkg/logger"
1516 "github.com/labstack/echo/v4"
@@ -19,6 +20,8 @@ import (
1920 var (
2021 globalServer *server.App
2122 globalServerLock sync.Mutex
23+ // Add a lock for server initialization
24+ serverInitLock sync.Mutex
2225 )
2326
2427 // SetGlobalServer sets the global server instance for shutdown handling
@@ -38,6 +41,10 @@ func GetGlobalServer() *server.App {
3841 // execute cmd/srv/main.go main function
3942
4043 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+
4148 // Set the global server instance for shutdown handling
4249 SetGlobalServer(a)
4350
@@ -54,27 +61,38 @@ func StartServer(a *server.App, port string) error {
5461 logger.Error("Token initialization error", "error", err)
5562 }
5663
57- // Initialize and start the reverse proxy using the already loaded configuration
58- p, err := httpserve.InitializeProxy(a)
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- }
64+ // Create a channel to signal when the main server is ready
65+ serverReady := make(chan struct{})
6366
67+ logger.Info("Initializing main HTTP server...")
6468 e := echo.New()
6569 e.Server.ReadTimeout = 10 * time.Minute
6670 e.Server.WriteTimeout = 10 * time.Minute
6771 e.HideBanner = true
6872 e.HidePort = true
73+
74+ logger.Debug("About to register routes to main HTTP server")
6975 e = httpserve.RegisterRoutes(e, a)
76+ logger.Info("Routes registered to main HTTP server")
7077
7178 // Setup a channel to capture termination signals
7279 sigs := make(chan os.Signal, 1)
7380 signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
7481
75- // Start server in a goroutine
82+ // Start the server in a goroutine
7683 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
7896 if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil {
7997 if err.Error() != "http: Server closed" {
8098 logger.Error("Server error", "error", err)
@@ -82,13 +100,35 @@ func StartServer(a *server.App, port string) error {
82100 }
83101 }()
84102
85- // Test admin connections after the server has started (in a separate goroutine)
86- if p != nil {
87- go func() {
88- // Add a delay to ensure the server has fully started
89- time.Sleep(2 * time.Second)
90- p.TestAdminConnectionLater()
91- }()
103+ // Log information about accessing the admin WebUI
104+ adminPath := a.Config.Admin.Path
105+ if adminPath == "" {
106+ adminPath = "/admin" // Default if not set
107+ }
108+
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")
92132 }
93133
94134 // Wait for interrupt signal
M internal/common/init_config.go +170 -61
@@ -47,14 +47,15 @@ type BuildConfig struct {
4747 }
4848
4949 type AdminConfig struct {
50- Path string `yaml:"path"`
50+ Path string `yaml:"path"`
51+ AdminWebUI bool `yaml:"-"`
5152 }
5253
5354 type GeneralConfig struct {
5455 StorageDir string `yaml:"storageDir"`
5556 Token string `yaml:"token"`
5657 LogLevel string `yaml:"logLevel"`
57- GordonContainerID string `yaml:"gordonContainerID"`
58+ GordonContainerID string `yaml:"-"`
5859 }
5960
6061 type HttpConfig struct {
@@ -73,33 +74,39 @@ type ContainerEngineConfig struct {
7374 }
7475
7576 type ReverseProxyConfig struct {
76- Port string `yaml:"port"` // Port for the reverse proxy to listen on
77- HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
78- CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
79- AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
80- RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
81- LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
82- Email string `yaml:"email"` // Email for Let's Encrypt
83- CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
84- GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
85- EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true)
86- DisableRateLimit bool `yaml:"disableRateLimit"` // Whether to disable rate limiting middleware (default: false)
77+ Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled
78+ Port string `yaml:"port"` // Port for the reverse proxy to listen on
79+ HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
80+ CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
81+ AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
82+ RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
83+ LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
84+ Email string `yaml:"email"` // Email for Let's Encrypt
85+ CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
86+ GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
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)
8791 }
8892
8993 // Default values
9094 var (
91- sock = "/var/run/docker.sock"
92- podmansock = "/run/podman/podman.sock"
93- reverseProxyPort = "443" // Changed to 443 for standard HTTPS
94- httpPort = "80" // Standard HTTP port
95- certDir = "/certs"
96- letsEncryptMode = "staging"
97- autoRenew = true
98- renewBefore = 30 // days
99- cacheSize = 1000 // entries
100- gracePeriod = 30 // seconds
101- defaultLogLevel = "info" // Default log level
102- disableRateLimit = false
95+ sock = "/var/run/docker.sock"
96+ podmansock = "/run/podman/podman.sock"
97+ reverseProxyPort = "443" // Changed to 443 for standard HTTPS
98+ httpPort = "80" // Standard HTTP port
99+ certDir = "/certs"
100+ letsEncryptMode = "staging"
101+ autoRenew = true
102+ proxyEnabled = true // Default to enabled for backward compatibility
103+ renewBefore = 30 // days
104+ cacheSize = 1000 // entries
105+ gracePeriod = 30 // seconds
106+ defaultLogLevel = "info" // Default log level
107+ disableRateLimit = false
108+ detectUpstreamProxy = false // Default to disabled
109+ skipCertificates = false // Default to disabled
103110 )
104111
105112 // applyDefaultsToConfig applies default values to any fields that have zero values
@@ -115,6 +122,12 @@ func applyDefaultsToConfig(config *Config) bool {
115122 }
116123
117124 // 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+ }
118131 if config.ReverseProxy.Port == "" {
119132 config.ReverseProxy.Port = reverseProxyPort
120133 logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort)
@@ -150,16 +163,30 @@ func applyDefaultsToConfig(config *Config) bool {
150163 logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod)
151164 defaultsApplied = true
152165 }
153- // Set EnableLogs to true by default if not specified
154- if !config.ReverseProxy.EnableLogs {
155- config.ReverseProxy.EnableLogs = true
156- logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true)
166+ // Set EnableHttpLogs to true by default if not specified
167+ if !config.ReverseProxy.EnableHttpLogs {
168+ config.ReverseProxy.EnableHttpLogs = 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)
157183 defaultsApplied = true
158184 }
159- // Set DisableRateLimit to false by default if not specified
160- if !config.ReverseProxy.DisableRateLimit {
161- config.ReverseProxy.DisableRateLimit = false
162- logger.Debug("Applied default value for ReverseProxy.DisableRateLimit", "value", false)
185+
186+ // Set SkipCertificates to false by default if not specified
187+ if !config.ReverseProxy.SkipCertificates {
188+ config.ReverseProxy.SkipCertificates = skipCertificates
189+ logger.Debug("Applied default value for ReverseProxy.SkipCertificates", "value", skipCertificates)
163190 defaultsApplied = true
164191 }
165192
@@ -476,6 +503,25 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
476503 logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val)
477504 }
478505 }
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+ }
479525 if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" {
480526 config.ReverseProxy.CertDir = val
481527 if printLogs {
@@ -524,22 +570,59 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
524570 logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i)
525571 }
526572
527- // Handle GORDON_PROXY_ENABLE_LOGS environment variable
528- if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" {
573+ // Handle GORDON_PROXY_ENABLE_HTTP_LOGS environment variable
574+ if val := os.Getenv("GORDON_PROXY_ENABLE_HTTP_LOGS"); val != "" {
529575 enableLogs, err := strconv.ParseBool(val)
530576 if err == nil {
531- config.ReverseProxy.EnableLogs = enableLogs
577+ config.ReverseProxy.EnableHttpLogs = enableLogs
532578 }
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)
534587 }
535588
536- // Handle GORDON_PROXY_DISABLE_RATE_LIMIT environment variable
537- if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" {
589+ // Handle GORDON_PROXY_ENABLE_RATE_LIMIT environment variable
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
538598 disableRateLimit, err := strconv.ParseBool(val)
539599 if err == nil {
540- config.ReverseProxy.DisableRateLimit = disableRateLimit
600+ // 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)
541625 }
542- logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT", "value", config.ReverseProxy.DisableRateLimit)
543626 }
544627 }
545628
@@ -587,16 +670,16 @@ func (config *Config) LoadConfig() (*Config, error) {
587670 Network: "gordon",
588671 },
589672 ReverseProxy: ReverseProxyConfig{
590- Port: reverseProxyPort,
591- HttpPort: httpPort,
592- CertDir: certDir,
593- AutoRenew: autoRenew,
594- RenewBefore: renewBefore,
595- LetsEncryptMode: letsEncryptMode,
596- CacheSize: cacheSize,
597- GracePeriod: gracePeriod,
598- EnableLogs: true,
599- DisableRateLimit: false,
673+ Port: reverseProxyPort,
674+ HttpPort: httpPort,
675+ CertDir: certDir,
676+ AutoRenew: autoRenew,
677+ RenewBefore: renewBefore,
678+ LetsEncryptMode: letsEncryptMode,
679+ CacheSize: cacheSize,
680+ GracePeriod: gracePeriod,
681+ EnableHttpLogs: true,
682+ EnableRateLimit: false,
600683 },
601684 }
602685
@@ -726,16 +809,16 @@ func (config *Config) LoadConfig() (*Config, error) {
726809 Network: "gordon",
727810 },
728811 ReverseProxy: ReverseProxyConfig{
729- Port: reverseProxyPort,
730- HttpPort: httpPort,
731- CertDir: certDir,
732- AutoRenew: autoRenew,
733- RenewBefore: renewBefore,
734- LetsEncryptMode: letsEncryptMode,
735- CacheSize: cacheSize,
736- GracePeriod: gracePeriod,
737- EnableLogs: true,
738- DisableRateLimit: false,
812+ Port: reverseProxyPort,
813+ HttpPort: httpPort,
814+ CertDir: certDir,
815+ AutoRenew: autoRenew,
816+ RenewBefore: renewBefore,
817+ LetsEncryptMode: letsEncryptMode,
818+ CacheSize: cacheSize,
819+ GracePeriod: gracePeriod,
820+ EnableHttpLogs: true,
821+ EnableRateLimit: false,
739822 },
740823 }
741824
@@ -808,6 +891,27 @@ func (config *Config) LoadConfig() (*Config, error) {
808891 logger.Info("Loaded configuration from", "path", configFilePath)
809892 }
810893
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+
811915 // Apply the log level from configuration
812916 if config.General.LogLevel != "" {
813917 // Set the log level using our centralized logger
@@ -930,3 +1034,8 @@ func (c *HttpConfig) FullDomain() string {
9301034 func (c *Config) GetVersion() string {
9311035 return c.Build.BuildVersion
9321036 }
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 {
1919 cc.PodmanSock = cc.Sock
2020 }
2121
22- config := NewDockerConfig(cc)
23- err := docker.InitializeClient(config)
24- if err != nil {
25- // Check if it's a "socket doesn't exist" error
26- if strings.Contains(err.Error(), "no such file or directory") {
27- // Log the error
28- log.Printf("Error initializing Docker client: %s", err)
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- }
22+ // If socket is empty, try to detect it automatically
23+ if cc.Sock == "" {
24+ // Try standard paths first
25+ standardPaths := []string{
26+ "/var/run/docker.sock", // Standard Docker socket in containers
27+ "/run/podman/podman.sock", // Standard Podman path
28+ "/var/run/podman/podman.sock", // Alternative standard path
29+ }
3730
38- // Add Docker socket path as a fallback
39- if !isPodmanSocket {
40- socketPaths = append(socketPaths, "/var/run/docker.sock")
41- }
31+ // Add home directory based path
32+ homePath := os.Getenv("HOME")
33+ if homePath != "" {
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)
4241
43- for _, path := range socketPaths {
44- // Skip if it's the path we already tried
45- if path == cc.Sock {
46- continue
42+ // Detect if this is a Podman socket
43+ isPodmanSocket = detectPodmanSocket(path)
44+ if isPodmanSocket {
45+ cc.Podman = true
46+ cc.PodmanSock = path
4747 }
4848
49- // Check if this socket exists
50- if _, err := os.Stat(path); err == nil {
51- log.Printf("Found alternative socket at %s", path)
49+ cc.Sock = path
50+ break
51+ } else {
52+ log.Printf("Socket not found at: %s", path)
53+ }
54+ }
5255
53- // Detect if this is a Podman socket
54- isPodmanSocket = detectPodmanSocket(path)
55- if isPodmanSocket {
56+ // If not found in standard paths, try common user IDs
57+ if cc.Sock == "" {
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)
5672 cc.Podman = true
57- cc.PodmanSock = path
73+ 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)
5878 }
79+ }
5980
60- cc.Sock = path
61-
62- // Try again with the new path
63- newConfig := NewDockerConfig(cc)
64- if err := docker.InitializeClient(newConfig); err == nil {
65- return nil
81+ // Check Docker socket for this UID
82+ if _, err := os.Stat(dockerSocketPath); err == nil {
83+ if file, err := os.OpenFile(dockerSocketPath, os.O_RDWR, 0); err == nil {
84+ file.Close()
85+ log.Printf("Found accessible Docker socket for user ID %s at: %s", uidStr, dockerSocketPath)
86+ cc.Sock = dockerSocketPath
87+ break
6688 }
6789 }
6890 }
91+ }
92+ }
6993
70- // Return without an error, as we want to continue even if Docker is not available
71- log.Printf("Docker/Podman functionality will be limited or unavailable")
72- return fmt.Errorf("could not connect to container engine: %w", err)
94+ // If we found a socket, try to initialize Docker client
95+ if cc.Sock != "" {
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
73101 }
74102
75- return err
103+ // 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+ }
76130 }
77- return nil
131+
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")
78192 }
79193
80194 // 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 (
77
88 "github.com/bnema/gordon/internal/server"
99 "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"
1012 "github.com/bnema/gordon/internal/templating/render"
1113 "github.com/bnema/gordon/pkg/docker"
1214 "github.com/bnema/gordon/pkg/sanitize"
@@ -34,7 +36,7 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
3436 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
3537 if !exists {
3638 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)
3840 }
3941
4042 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -49,30 +51,23 @@ func CreateContainerGET(c echo.Context, a *server.App) error {
4951 var imageName string
5052 if len(imageInfo.RepoTags) > 0 {
5153 imageName = imageInfo.RepoTags[0]
54+ } else {
55+ imageName = imageInfo.ID
5256 }
5357
54- data := map[string]interface{}{
55- "Title": "Create a new container",
56- "ShortID": shortID,
57- "ImageID": fullID,
58- "ImageName": imageName,
58+ // Setup form data
59+ formData := templcomponents.ContainerFormData{
60+ ImageName: imageName,
61+ ImageID: fullID,
62+ ShortID: shortID,
63+ ErrorMessage: "",
5964 }
6065
61- // Render the create container page
62- rendererData, err := render.GetHTMLRenderer("html/fragments", "createcontainer.gohtml", a.TemplateFS, 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)
66+ // Get the renderer
67+ renderer := render.NewTemplRenderer(a)
7568
69+ // Render the component
70+ return renderer.RenderTempl(c, templcomponents.CreateContainerForm(formData))
7671 }
7772
7873 // CreateContainerFullGET handles the full HTML page for creating a new container
@@ -84,7 +79,7 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
8479 fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
8580 if !exists {
8681 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)
8883 }
8984
9085 log.Debug("Found mapping: %s -> %s", shortID, fullID)
@@ -99,29 +94,32 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error {
9994 var imageName string
10095 if len(imageInfo.RepoTags) > 0 {
10196 imageName = imageInfo.RepoTags[0]
97+ } else {
98+ imageName = imageInfo.ID
10299 }
103100
104- data := map[string]interface{}{
105- "Title": "Create a new container",
106- "ShortID": shortID,
107- "ImageID": fullID,
108- "ImageName": imageName,
109- "AdminPath": a.Config.Admin.Path,
101+ // Setup form data
102+ formData := templcomponents.ContainerFormData{
103+ ImageName: imageName,
104+ ImageID: fullID,
105+ ShortID: shortID,
106+ ErrorMessage: "",
110107 }
111108
112- // Specify both admin and fragments directories
113- rendererData, err := render.GetHTMLRenderer("html/admin", "createcontainerfull.gohtml", a.TemplateFS, a, "html/fragments")
114- if err != nil {
115- return sendError(c, err)
116- }
109+ // Get the renderer
110+ renderer := render.NewTemplRenderer(a)
117111
118- renderedHTML, err := rendererData.Render(data, a)
119- if err != nil {
120- log.Debug("Error rendering template: %v", err)
121- return sendError(c, err)
112+ // Render the createcontainerfull template
113+ pageData := templadmin.CreateContainerFullData{
114+ Title: "Create Container | Gordon",
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,
122120 }
123121
124- return c.HTML(200, renderedHTML)
122+ return renderer.RenderTempl(c, templadmin.CreateContainerFullPage(pageData))
125123 }
126124
127125 // CreateContainerPOST handles the create container form submission
M internal/httpserve/handlers/container_management.go +58 -2
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/gordon/internal/templating/render"
1313 "github.com/bnema/gordon/pkg/docker"
1414 "github.com/bnema/gordon/pkg/humanize"
15+ "github.com/bnema/gordon/pkg/logger"
1516 "github.com/docker/docker/api/types"
1617 "github.com/labstack/echo/v4"
1718 )
@@ -58,6 +59,10 @@ type HumanReadableContainerImage struct {
5859 SizeStr string
5960 RepoDigests []string
6061 RepoTags []string
62+ Repository string
63+ Tag string
64+ Size string
65+ Created string
6166 }
6267
6368 type HumanReadableContainer struct {
@@ -85,17 +90,33 @@ type ContainerDisplay struct {
8590
8691 // renderHTML is a generalized function to render HTML
8792 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+
8895 rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a)
8996 if err != nil {
97+ logger.Error("Error getting HTML renderer", "error", err)
9098 return err
9199 }
100+
92101 renderedHTML, err := rendererData.Render(data, a)
93102 if err != nil {
103+ logger.Error("Error rendering HTML", "error", err)
94104 return err
95105 }
106+
107+ logger.Debug("HTML rendered successfully", "content_length", len(renderedHTML))
96108 return c.HTML(http.StatusOK, renderedHTML)
97109 }
98110
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+
99120 // ActionSuccess returns the success HTML fragment
100121 func ActionSuccess(a *server.App) string {
101122 // Create a data structure to pass to the template
@@ -114,11 +135,19 @@ func ActionSuccess(a *server.App) string {
114135
115136 // ImageManagerComponent handles the /image-manager route (HTMX route)
116137 func ImageManagerComponent(c echo.Context, a *server.App) error {
138+ logger.Debug("ImageManagerComponent called", "url", c.Request().URL.String())
139+
117140 images, err := docker.ListContainerImages()
118141 if err != nil {
142+ logger.Error("Error listing container images:", "error", err)
119143 return sendError(c, err)
120144 }
121145
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+
122151 var humanReadableImages []HumanReadableContainerImage
123152
124153 for _, img := range images {
@@ -129,6 +158,14 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
129158 sizeStr := humanize.BytesToReadableSize(img.Size)
130159
131160 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+
132169 humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{
133170 ID: img.ID,
134171 ShortID: shortID,
@@ -137,6 +174,11 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
137174 Name: repoTag,
138175 RepoDigests: img.RepoDigests,
139176 RepoTags: img.RepoTags,
177+ // Add fields that match template expectations
178+ Repository: repository,
179+ Tag: tag,
180+ Size: sizeStr,
181+ Created: createdStr,
140182 })
141183 }
142184 }
@@ -145,6 +187,7 @@ func ImageManagerComponent(c echo.Context, a *server.App) error {
145187 "Images": humanReadableImages,
146188 }
147189
190+ logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images")
148191 return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data)
149192 }
150193
@@ -170,11 +213,19 @@ func ImageManagerDelete(c echo.Context, a *server.App) error {
170213
171214 // ContainerManagerComponent handles the /container-manager route
172215 func ContainerManagerComponent(c echo.Context, a *server.App) error {
216+ logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String())
217+
173218 containers, err := docker.ListRunningContainers()
174219 if err != nil {
220+ logger.Error("Error listing containers", "error", err)
175221 return sendError(c, err)
176222 }
177223
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+
178229 var humanReadableContainers []HumanReadableContainer
179230
180231 for _, container := range containers {
@@ -212,10 +263,15 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error {
212263 return err
213264 }
214265 data := map[string]interface{}{
215- "Lang": yamlData["Lang"],
216- "containers": humanReadableContainers,
266+ "Lang": yamlData["Lang"],
267+ "Containers": humanReadableContainers,
268+ "NoContainer": len(humanReadableContainers) == 0,
217269 }
218270
271+ logger.Debug("Rendering containerlist template",
272+ "humanReadableContainers", len(humanReadableContainers),
273+ "noContainer", len(humanReadableContainers) == 0)
274+
219275 return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)
220276 }
221277
M internal/httpserve/handlers/container_transactions.go +3 -3
@@ -2,13 +2,13 @@ package handlers
22
33 import (
44 "fmt"
5- "log"
65 "strings"
76
87 "github.com/bnema/gordon/internal/server"
98 "github.com/bnema/gordon/internal/templating/cmdparams"
109 "github.com/bnema/gordon/internal/templating/render"
1110 "github.com/bnema/gordon/pkg/docker"
11+ "github.com/bnema/gordon/pkg/logger"
1212 "github.com/labstack/echo/v4"
1313 "gopkg.in/yaml.v3"
1414 )
@@ -132,13 +132,13 @@ func (tq *TransactionQueue) Execute() error {
132132 for i, step := range tq.steps {
133133 err := step()
134134 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))
136136 transactionErrors = append(transactionErrors, err)
137137 for j := i - 1; j >= 0; j-- {
138138 rollback := tq.rollbacks[j]
139139 if rollback != nil {
140140 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))
142142 rollbackErrors = append(rollbackErrors, rollbackErr)
143143 }
144144 }
M internal/httpserve/handlers/endpoint_deploy.go +34 -34
@@ -17,7 +17,7 @@ import (
1717 "github.com/bnema/gordon/internal/proxy"
1818 "github.com/bnema/gordon/internal/server"
1919 "github.com/bnema/gordon/pkg/docker"
20- "github.com/charmbracelet/log"
20+ "github.com/bnema/gordon/pkg/logger"
2121 "github.com/labstack/echo/v4"
2222 )
2323
@@ -26,7 +26,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
2626 payload, err := validateAndPrepareDeployPayload(c)
2727 if err != nil {
2828 // Debug
29- log.Error("Failed to validate and prepare payload", "error", err)
29+ logger.Error("Failed to validate and prepare payload", "error", err)
3030 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
3131 Success: false,
3232 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -37,7 +37,7 @@ func PostDeploy(c echo.Context, a *server.App) error {
3737 _, err = saveAndImportImage(c, a, payload)
3838 if err != nil {
3939 // Debug
40- log.Error("Failed to save and import image", "error", err)
40+ logger.Error("Failed to save and import image", "error", err)
4141 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
4242 Success: false,
4343 Message: fmt.Sprintf("Failed to save or import image: %v", err),
@@ -48,14 +48,14 @@ func PostDeploy(c echo.Context, a *server.App) error {
4848 containerID, containerName, err := createAndStartContainer(a, payload)
4949 if err != nil {
5050 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)
5252 existingContainerID := extractContainerID(err.Error())
5353 existingContainerName, _ := docker.GetContainerName(existingContainerID)
5454 // Remove the / from the container name
5555 existingContainerName = strings.TrimLeft(existingContainerName, "/")
5656
5757 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")
5959 return sendJSONResponse(c, http.StatusConflict, DeployResponse{
6060 Success: false,
6161 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 {
7171 StatusCode: http.StatusConflict,
7272 })
7373 }
74- log.Error("Failed to create or start container", "error", err)
74+ logger.Error("Failed to create or start container", "error", err)
7575 return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
7676 Success: false,
7777 Message: fmt.Sprintf("Failed to create or start container: %v", err),
@@ -90,11 +90,11 @@ func PostDeploy(c echo.Context, a *server.App) error {
9090 containerIP := GetContainerIP(a, containerID, containerName)
9191 err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)
9292 if err != nil {
93- log.Error("Failed to add proxy route", "error", err)
93+ logger.Error("Failed to add proxy route", "error", err)
9494 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
9595 }
9696 } 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)
9898 }
9999
100100 // Arrived here means deployment was successful
@@ -124,7 +124,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
124124
125125 payload, err := validateAndPrepareDeployPayload(c)
126126 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)
128128 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
129129 Success: false,
130130 Message: fmt.Sprintf("Invalid payload: %v", err),
@@ -196,7 +196,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
196196 // Chunks validation steps
197197 //
198198 if len(chunkData) == 0 {
199- log.Error("Received empty chunk")
199+ logger.Error("Received empty chunk")
200200 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
201201 Success: false,
202202 Message: "Received empty chunk",
@@ -206,7 +206,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
206206
207207 // Verify chunk size matches metadata
208208 if int64(len(chunkData)) != metadata.ChunkSize {
209- log.Error("Chunk size mismatch",
209+ logger.Error("Chunk size mismatch",
210210 "expected", metadata.ChunkSize,
211211 "received", len(chunkData))
212212 return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
@@ -236,7 +236,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro
236236 }
237237
238238 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",
240240 "transferID", transferID)
241241
242242 // Get transfer data
@@ -296,21 +296,21 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
296296 // Check for existing container
297297 existingContainerID := docker.GetContainerIDByName(containerName)
298298 if existingContainerID != "" {
299- log.Warn("Container already exists",
299+ logger.Warn("Container already exists",
300300 "name", containerName,
301301 "id", existingContainerID)
302302 return sendDeployErrorResponse(c, "Container already exists", nil)
303303 }
304304
305305 // Import the image
306- log.Info("Importing image from file",
306+ logger.Info("Importing image from file",
307307 "path", tmpFile.Name(),
308308 "imageName", imageName)
309309
310310 imageID, err := docker.ImportImageToEngine(tmpFile.Name())
311311 if err != nil {
312312 if strings.Contains(err.Error(), "platform compatibility check failed") {
313- log.Error("Platform compatibility check failed",
313+ logger.Error("Platform compatibility check failed",
314314 "error", err,
315315 "imageName", imageName)
316316 return sendDeployErrorResponse(c, "Platform compatibility check failed", err)
@@ -343,7 +343,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
343343 // Cleanup transfer data
344344 cleanupTransfer(transferID)
345345
346- log.Info("Deployment completed successfully",
346+ logger.Info("Deployment completed successfully",
347347 "containerID", containerID,
348348 "containerName", containerName,
349349 "domain", targetDomain)
@@ -352,7 +352,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
352352 containerIP := GetContainerIP(a, containerID, containerName)
353353 err = AddProxyRoute(a, containerID, containerIP, port, targetDomain)
354354 if err != nil {
355- log.Error("Failed to add proxy route", "error", err)
355+ logger.Error("Failed to add proxy route", "error", err)
356356 return sendDeployErrorResponse(c, "Failed to add proxy route", err)
357357 }
358358
@@ -422,7 +422,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
422422
423423 // Double check that we have a valid container IP - critical for recreated containers
424424 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",
426426 "containerID", containerID,
427427 "invalid_ip", containerIP)
428428
@@ -437,7 +437,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
437437 // Get the correct container IP with retry logic
438438 newContainerIP := GetContainerIP(a, containerID, containerName)
439439 if newContainerIP != containerIP && newContainerIP != containerName {
440- log.Info("Updated container IP for proxy route",
440+ logger.Info("Updated container IP for proxy route",
441441 "containerID", containerID,
442442 "old_ip", containerIP,
443443 "new_ip", newContainerIP)
@@ -448,12 +448,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
448448 // Get container info to check labels
449449 containerInfo, err := docker.GetContainerInfo(containerID)
450450 if err != nil {
451- log.Warn("Failed to get container info", "error", err)
451+ logger.Warn("Failed to get container info", "error", err)
452452 } else {
453453 // Check if the container has a gordon.proxy.port label that might override containerPort
454454 if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
455455 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",
457457 "label_port", portLabel,
458458 "provided_port", containerPort)
459459 containerPort = portLabel
@@ -467,7 +467,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
467467 // Check if the target domain specifies HTTPS
468468 if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {
469469 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",
471471 "domain", targetDomain,
472472 "protocol", protocol)
473473 } else {
@@ -477,7 +477,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
477477 if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
478478 (sslValue == "true" || sslValue == "1" || sslValue == "yes") {
479479 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",
481481 "domain", targetDomain,
482482 "containerID", containerID,
483483 "label_value", sslValue,
@@ -496,16 +496,16 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
496496 cleanDomain = strings.TrimPrefix(cleanDomain, ".")
497497
498498 // 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",
500500 "domain", cleanDomain)
501501
502502 // Get container name for better logging
503503 containerName, err := docker.GetContainerName(containerID)
504504 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)
506506 } else {
507507 containerName = strings.TrimPrefix(containerName, "/")
508- log.Debug("Container name for proxy route", "name", containerName)
508+ logger.Debug("Container name for proxy route", "name", containerName)
509509 }
510510
511511 // 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
526526
527527 if err == nil {
528528 // 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",
530530 "domain", cleanDomain,
531531 "old_container_id", existingContainerID,
532532 "new_container_id", containerID)
@@ -538,7 +538,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
538538 networkName := a.Config.ContainerEngine.Network
539539 if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists &&
540540 networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP {
541- log.Info("Updating container IP before database update",
541+ logger.Info("Updating container IP before database update",
542542 "domain", cleanDomain,
543543 "old_ip", containerIP,
544544 "new_ip", networkSettings.IPAddress)
@@ -559,7 +559,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
559559 return fmt.Errorf("failed to update proxy route: %w", err)
560560 }
561561
562- log.Info("Updated proxy route for recreated container",
562+ logger.Info("Updated proxy route for recreated container",
563563 "domain", cleanDomain,
564564 "old_container_id", existingContainerID,
565565 "new_container_id", containerID,
@@ -579,11 +579,11 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
579579 WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP)
580580
581581 if err == nil && updatedContainerIP != containerIP {
582- log.Warn("Database verification failed: container IP mismatch",
582+ logger.Warn("Database verification failed: container IP mismatch",
583583 "expected", containerIP,
584584 "actual", updatedContainerIP)
585585 } else {
586- log.Debug("Database verification passed: container IP updated correctly",
586+ logger.Debug("Database verification passed: container IP updated correctly",
587587 "container_ip", containerIP)
588588 }
589589
@@ -596,12 +596,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
596596 routes := p.GetRoutes()
597597 if route, exists := routes[cleanDomain]; exists {
598598 if route.ContainerIP != containerIP {
599- log.Warn("Route reload verification failed: container IP mismatch",
599+ logger.Warn("Route reload verification failed: container IP mismatch",
600600 "domain", cleanDomain,
601601 "expected", containerIP,
602602 "actual", route.ContainerIP)
603603 } else {
604- log.Debug("Route reload verification passed: container IP loaded correctly",
604+ logger.Debug("Route reload verification passed: container IP loaded correctly",
605605 "domain", cleanDomain,
606606 "container_ip", containerIP)
607607 }
@@ -623,7 +623,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
623623 // and avoid database locking conflicts
624624 p.RegisterNewlyCreatedContainer(containerID)
625625
626- log.Debug("Added proxy route",
626+ logger.Debug("Added proxy route",
627627 "domain", targetDomain,
628628 "containerIP", containerIP,
629629 "containerPort", containerPort,
M internal/httpserve/handlers/endpoint_helpers.go +24 -24
@@ -18,7 +18,7 @@ import (
1818 "github.com/bnema/gordon/internal/templating/cmdparams"
1919 "github.com/bnema/gordon/pkg/docker"
2020 "github.com/bnema/gordon/pkg/filestore"
21- "github.com/charmbracelet/log"
21+ "github.com/bnema/gordon/pkg/logger"
2222 "github.com/labstack/echo/v4"
2323 )
2424
@@ -71,7 +71,7 @@ func (info *InfoResponse) Populate(a *server.App) {
7171 func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
7272 err := c.JSON(statusCode, response)
7373 if err != nil {
74- log.Error("Failed to send JSON response",
74+ logger.Error("Failed to send JSON response",
7575 "error", err,
7676 "statusCode", statusCode)
7777 }
@@ -152,7 +152,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
152152 imageID, err = docker.ImportImageToEngine(imagePath)
153153 if err != nil {
154154 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",
156156 "error", removeErr,
157157 "path", imagePath)
158158 }
@@ -161,7 +161,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
161161
162162 if imageID == "" {
163163 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",
165165 "error", removeErr,
166166 "path", imagePath)
167167 }
@@ -170,7 +170,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str
170170
171171 err = filestore.RemoveFromStorage(imagePath)
172172 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",
174174 "error", err,
175175 "path", imagePath)
176176 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
181181
182182 // createAndStartContainer creates a container from the image, starts it and returns the container ID
183183 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",
185185 "imageName", payload.ImageName,
186186 "imageID", payload.ImageID,
187187 "port", payload.Port)
@@ -191,21 +191,21 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
191191 return "", "", fmt.Errorf("failed to create command parameters: %v", err)
192192 }
193193
194- log.Debug("Container parameters prepared", "params", params)
194+ logger.Debug("Container parameters prepared", "params", params)
195195
196196 containerID, err := docker.CreateContainer(params)
197197 if err != nil {
198198 return "", "", fmt.Errorf("failed to create container: %v", err)
199199 }
200200
201- log.Info("Container created successfully", "containerID", containerID)
201+ logger.Info("Container created successfully", "containerID", containerID)
202202
203203 containerName, err := docker.GetContainerName(containerID)
204204 if err != nil {
205205 return "", "", fmt.Errorf("failed to get container name: %v", err)
206206 }
207207
208- log.Info("Starting container",
208+ logger.Info("Starting container",
209209 "containerID", containerID,
210210 "name", containerName)
211211
@@ -215,7 +215,7 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
215215 // Get container logs if start failed
216216 logs, logErr := docker.GetContainerLogs(containerID)
217217 if logErr == nil {
218- log.Error("Container start failed - container logs",
218+ logger.Error("Container start failed - container logs",
219219 "logs", logs,
220220 "containerID", containerID)
221221 }
@@ -223,14 +223,14 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri
223223 // Cleanup the failed container
224224 cleanupErr := docker.RemoveContainer(containerID)
225225 if cleanupErr != nil {
226- log.Error("Failed to cleanup container after start failure",
226+ logger.Error("Failed to cleanup container after start failure",
227227 "containerID", containerID,
228228 "error", cleanupErr)
229229 }
230230 return "", "", fmt.Errorf("failed to start container: %v", err)
231231 }
232232
233- log.Info("Container started successfully",
233+ logger.Info("Container started successfully",
234234 "containerID", containerID,
235235 "name", containerName)
236236
@@ -292,14 +292,14 @@ func isTransferComplete(transferID string) bool {
292292
293293 metadata := chunkStore.metadata[transferID]
294294 if metadata == nil {
295- log.Warn("No metadata found for transfer", "transferID", transferID)
295+ logger.Warn("No metadata found for transfer", "transferID", transferID)
296296 return false
297297 }
298298
299299 chunksReceived := len(chunkStore.chunks[transferID])
300300 isComplete := chunksReceived == metadata.TotalChunks
301301
302- log.Debug("Transfer completion check",
302+ logger.Debug("Transfer completion check",
303303 "transferID", transferID,
304304 "chunksReceived", chunksReceived,
305305 "totalExpected", metadata.TotalChunks,
@@ -328,7 +328,7 @@ func cleanupOldTransfers() {
328328 chunkStore.mu.Lock()
329329 for id, startTime := range chunkStore.started {
330330 if now.Sub(startTime) > transferTimeout {
331- log.Debug("Cleaning up stale transfer",
331+ logger.Debug("Cleaning up stale transfer",
332332 "transferID", id,
333333 "age", now.Sub(startTime))
334334 cleanupTransfer(id)
@@ -376,7 +376,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
376376 totalSize += int64(len(chunk))
377377 }
378378
379- log.Debug("Starting chunk assembly",
379+ logger.Debug("Starting chunk assembly",
380380 "totalChunks", totalChunks,
381381 "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024)))
382382
@@ -435,7 +435,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er
435435 return fmt.Errorf("failed to reset file pointer: %w", err)
436436 }
437437
438- log.Debug("Chunk assembly completed successfully",
438+ logger.Debug("Chunk assembly completed successfully",
439439 "totalBytesWritten", written,
440440 "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024)))
441441
@@ -464,7 +464,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
464464 // If this is a retry, add an increasing delay
465465 if retry > 0 {
466466 sleepTime := time.Duration(500*retry) * time.Millisecond
467- log.Debug("Retrying to get container IP",
467+ logger.Debug("Retrying to get container IP",
468468 "attempt", retry+1,
469469 "containerID", containerID,
470470 "delay", sleepTime)
@@ -486,7 +486,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
486486 refreshedInfo, err := docker.GetContainerInfo(containerID)
487487 if err == nil {
488488 containerInfo = refreshedInfo
489- log.Debug("Refreshed container info for IP retrieval",
489+ logger.Debug("Refreshed container info for IP retrieval",
490490 "containerName", containerName,
491491 "containerID", containerID)
492492 } else {
@@ -501,7 +501,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
501501 networkName := a.Config.ContainerEngine.Network
502502 if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
503503 containerIP = networkSettings.IPAddress
504- log.Debug("Found container IP",
504+ logger.Debug("Found container IP",
505505 "containerName", containerName,
506506 "containerID", containerID,
507507 "containerIP", containerIP,
@@ -513,7 +513,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
513513 for networkName, networkSettings := range containerInfo.NetworkSettings.Networks {
514514 if networkSettings.IPAddress != "" {
515515 containerIP = networkSettings.IPAddress
516- log.Debug("Found container IP in alternative network",
516+ logger.Debug("Found container IP in alternative network",
517517 "containerName", containerName,
518518 "containerID", containerID,
519519 "containerIP", containerIP,
@@ -533,7 +533,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
533533 // Extract just the IP address from the CIDR format
534534 ipCIDR := endpoint.IPv4Address
535535 containerIP = strings.Split(ipCIDR, "/")[0]
536- log.Debug("Found container IP via network inspection",
536+ logger.Debug("Found container IP via network inspection",
537537 "containerName", containerName,
538538 "containerID", containerID,
539539 "containerIP", containerIP,
@@ -546,12 +546,12 @@ func GetContainerIP(a *server.App, containerID, containerName string) string {
546546
547547 // If we've tried multiple times and couldn't get an IP
548548 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",
550550 "error", lastErr,
551551 "containerID", containerID)
552552 }
553553
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",
555555 "containerName", containerName,
556556 "containerID", containerID)
557557
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 @@
11 package httpserve
22
33 import (
4+ "time"
5+
46 "github.com/bnema/gordon/internal/proxy"
57 "github.com/bnema/gordon/internal/server"
68 "github.com/charmbracelet/log"
@@ -21,6 +23,10 @@ func InitializeProxy(a *server.App) (*proxy.Proxy, error) {
2123 return nil, err
2224 }
2325
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+
2430 log.Info("Reverse proxy initialized and started")
2531 return p, nil
2632 }
M internal/httpserve/router.go +47 -8
@@ -12,9 +12,13 @@ import (
1212
1313 // RegisterRoutes registers all routes and middlewares
1414 func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
15- // Add default Echo logger middleware
16- e.Use(echomid.Logger())
1715 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+
1822 // Initiate the session middleware
1923 e.Use(middleware.InitSessionMiddleware(a))
2024
@@ -22,7 +26,10 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
2226 e.Use(middleware.LanguageDetection)
2327
2428 AdminPath := a.Config.Admin.Path
29+ log.Debug("Binding login routes with AdminPath", "path", AdminPath)
2530 bindLoginRoute(e, a, AdminPath)
31+
32+ log.Debug("Binding API endpoints")
2633 bindAPIEndpoints(e, a)
2734
2835 // SetCommonDataMiddleware will pass data to the renderer
@@ -37,15 +44,29 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
3744 // Color scheme detection for dark/light mode
3845 e.Use(middleware.ColorSchemeDetection)
3946
47+ // Set custom error handler
48+ e.HTTPErrorHandler = middleware.CustomHTTPErrorHandler(a)
49+
50+ log.Debug("Binding admin routes with AdminPath", "path", AdminPath)
4051 bindAdminRoute(e, a, AdminPath)
52+
53+ log.Debug("Binding static routes")
4154 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+ }
4363
4464 // Protect the root path with a 403
4565 e.GET("/", func(c echo.Context) error {
46- return c.String(403, "403 Forbidden")
66+ return handlers.RenderForbiddenPage(c, a)
4767 })
4868
69+ log.Info("All routes registered successfully")
4970 return e
5071 }
5172
@@ -101,8 +122,14 @@ func bindStaticRoute(e *echo.Echo, a *server.App, path string) {
101122
102123 // bindLoginRoute binds all login routes
103124 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+
104131 e.GET(adminPath+"/login", func(c echo.Context) error {
105- return handlers.RenderLoginPage(c, a)
132+ return handlers.RenderTemplLoginPage(c, a)
106133 })
107134 e.POST(adminPath+"/login/submit-token", func(c echo.Context) error {
108135 return handlers.HandleTokenSubmission(c, a)
@@ -120,6 +147,12 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
120147
121148 // bindAdminRoute binds all admin routes
122149 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+
123156 adminGroup := e.Group(adminPath)
124157 // Since login is behind the admin path, we cannot use group middleware
125158 adminGroup.GET("", func(c echo.Context) error {
@@ -127,7 +160,7 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
127160 }, middleware.RequireLogin(a))
128161
129162 adminGroup.GET("/manager", func(c echo.Context) error {
130- return handlers.AdminManagerRoute(c, a)
163+ return handlers.RenderTemplManagerPage(c, a)
131164 }, middleware.RequireLogin(a))
132165 adminGroup.GET("/cc/:ID", func(c echo.Context) error {
133166 return handlers.CreateContainerFullGET(c, a)
@@ -146,7 +179,13 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
146179
147180 // bindHTMXEndpoints binds all HTMX endpoints
148181 func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
149- // Create a group for /htmx endpoints
182+ // 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
150189 htmxGroup := e.Group("/htmx")
151190
152191 // Apply middleware to the group, so all endpoints are protected
@@ -163,7 +202,7 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
163202
164203 // List all containers
165204 htmxGroup.GET("/container-manager", func(c echo.Context) error {
166- return handlers.ContainerManagerComponent(c, a)
205+ return handlers.RenderTemplContainerList(c, a)
167206 })
168207 // Stop a container
169208 htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error {
M internal/proxy/certificates.go +126 -0
@@ -24,8 +24,116 @@ import (
2424 "golang.org/x/crypto/acme/autocert"
2525 )
2626
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+
27100 // setupCertManager configures the autocert manager for Let's Encrypt
28101 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+
29137 // Create a cache directory if it doesn't exist
30138 dir := p.config.CertDir
31139 if dir == "" {
@@ -384,6 +492,15 @@ func (p *Proxy) requestAdminCertificate() {
384492 logger.Debug("HTTPS is disabled, skipping admin certificate request")
385493 return
386494 }
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+ }
387504
388505 // Extract hostname from admin domain, resolving it to see if it's publicly accessible
389506 host := adminDomain
@@ -539,6 +656,15 @@ func (p *Proxy) requestDomainCertificate(domain string) {
539656 logger.Warn("Empty domain provided to requestDomainCertificate")
540657 return
541658 }
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+ }
542668
543669 // Extract hostname from domain, resolving it to see if it's publicly accessible
544670 ips, err := net.LookupIP(domain)
M internal/proxy/containers.go +7 -5
@@ -1,7 +1,6 @@
11 package proxy
22
33 import (
4- "fmt"
54 "os"
65 "strings"
76
@@ -12,11 +11,11 @@ import (
1211 // scanContainersAndCheckCertificates scans all containers from the Gordon network
1312 // and checks if we have certificates for their domains and subdomains
1413 func (p *Proxy) scanContainersAndCheckCertificates() error {
15- // Get the network name from app config
1614 networkName := p.app.GetConfig().ContainerEngine.Network
17-
1815 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
2019 }
2120
2221 logger.Info("Scanning containers in network for certificate checks",
@@ -25,7 +24,10 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
2524 // Try to get network info using the docker client
2625 networkInfo, err := docker.GetNetworkInfo(networkName)
2726 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
2931 }
3032
3133 // Skip admin cert check if environment variable is set
M internal/proxy/core.go +34 -6
@@ -22,7 +22,6 @@ import (
2222 // - certificates.go - Certificate management functions
2323 // - routes.go - Route management functions
2424 // - middleware.go - HTTP middleware functions
25-// - blacklist.go - IP blacklisting functionality
2625 // - server.go - Server startup and shutdown
2726 // - utils.go - Utility functions
2827
@@ -58,6 +57,10 @@ type Proxy struct {
5857 recentContainersMu sync.RWMutex
5958 containerCooldownPeriod time.Duration
6059
60+ // Upstream proxy detection
61+ upstreamProxyDetected bool
62+ upstreamProxyMu sync.RWMutex
63+
6164 // SQL queries
6265 queries *queries.ProxyQueries
6366 }
@@ -66,8 +69,36 @@ type Proxy struct {
6669 func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
6770 logger.Debug("Initializing reverse proxy")
6871
69- // Log important configuration information
70- logger.Debug("All internal connections to containers will use HTTP protocol regardless of external protocol")
72+ // Set up the config
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+ }
71102
72103 // Set up the echo server for HTTPS traffic
73104 httpsServer := echo.New()
@@ -77,9 +108,6 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
77108 httpServer := echo.New()
78109 httpServer.HideBanner = true
79110
80- // Set up the config
81- config := app.GetConfig().ReverseProxy
82-
83111 // Detect and store our container ID (if we're running in a container)
84112 var ourContainerID string
85113 if docker.IsRunningInContainer() {
M internal/proxy/middleware.go +90 -8
@@ -2,6 +2,7 @@ package proxy
22
33 import (
44 "net/http"
5+ "strings"
56 "time"
67
78 "github.com/bnema/gordon/pkg/logger"
@@ -30,8 +31,8 @@ func (p *Proxy) setupMiddleware() {
3031 p.httpsServer.Use(p.createRequestIDMiddleware())
3132 p.httpServer.Use(p.createRequestIDMiddleware())
3233
33- // Check if rate limiting is disabled via configuration
34- if !p.config.DisableRateLimit {
34+ // Check if rate limiting is enabled via configuration
35+ if p.config.EnableRateLimit {
3536 // Create a new Starskey rate limiter store for HTTPS server
3637 httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(
3738 "./data/ratelimiter/https", // Path to store rate limit data
@@ -98,12 +99,12 @@ func (p *Proxy) setupMiddleware() {
9899 }
99100
100101 // Add custom logger middleware with request ID if enabled in config
101- if p.config.EnableLogs {
102+ if p.config.EnableHttpLogs {
102103 p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
103104 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",
104105 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
105106 Skipper: func(c echo.Context) bool {
106- return !p.config.EnableLogs
107+ return !p.config.EnableHttpLogs
107108 },
108109 }))
109110 logger.Debug("HTTP request logging enabled for HTTPS server")
@@ -111,8 +112,8 @@ func (p *Proxy) setupMiddleware() {
111112 logger.Debug("HTTP request logging disabled for HTTPS server")
112113 }
113114
114- // Check if rate limiting is disabled via configuration
115- if !p.config.DisableRateLimit {
115+ // Check if rate limiting is enabled via configuration
116+ if p.config.EnableRateLimit {
116117 // Create a new Starskey rate limiter store for HTTP server
117118 httpRateLimiter, err := kv.NewStarskeyRateLimiterStore(
118119 "./data/ratelimiter/http", // Path to store rate limit data
@@ -179,12 +180,12 @@ func (p *Proxy) setupMiddleware() {
179180 }
180181
181182 // Add custom logger middleware with request ID for HTTP server if enabled in config
182- if p.config.EnableLogs {
183+ if p.config.EnableHttpLogs {
183184 p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
184185 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",
185186 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
186187 Skipper: func(c echo.Context) bool {
187- return !p.config.EnableLogs
188+ return !p.config.EnableHttpLogs
188189 },
189190 }))
190191 logger.Debug("HTTP request logging enabled for HTTP server")
@@ -206,8 +207,89 @@ func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc {
206207 // Add it as a response header
207208 c.Response().Header().Set(echo.HeaderXRequestID, requestID)
208209
210+ // If proxy detection is enabled, check for upstream proxy headers
211+ if p.config.DetectUpstreamProxy {
212+ p.detectUpstreamProxyFromRequest(c)
213+ }
214+
209215 // Continue processing
210216 return next(c)
211217 }
212218 }
213219 }
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
33 import (
44 "database/sql"
55 "fmt"
6- "net"
7- "net/http"
8- "net/http/httputil"
96 "net/url"
107 "os"
118 "strings"
129 "time"
1310
14- "github.com/bnema/gordon/pkg/docker"
11+ docker "github.com/bnema/gordon/pkg/docker"
1512 "github.com/bnema/gordon/pkg/logger"
1613 "github.com/labstack/echo/v4"
1714 )
@@ -42,12 +39,7 @@ func (p *Proxy) loadRoutes() error {
4239 }
4340
4441 // Save the admin domain route if it exists
45- adminDomain := p.app.GetConfig().Http.FullDomain()
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- }
42+ adminRoute := p.saveAdminRoute()
5143
5244 // Query the database for active proxy routes using retry mechanism
5345 rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes)
@@ -58,8 +50,8 @@ func (p *Proxy) loadRoutes() error {
5850 // Restore admin route if it was saved
5951 if adminRoute != nil {
6052 p.routes = make(map[string]*ProxyRouteInfo)
61- p.routes[adminDomain] = adminRoute
62- logger.Debug("Restored admin domain route after empty database query", "domain", adminDomain)
53+ p.restoreAdminRoute(adminRoute, p.routes)
54+ logger.Debug("Restored admin domain route after empty database query", "domain", p.app.GetConfig().Http.FullDomain())
6355 }
6456
6557 return nil
@@ -72,10 +64,7 @@ func (p *Proxy) loadRoutes() error {
7264 p.routes = make(map[string]*ProxyRouteInfo)
7365
7466 // Restore admin route if it was saved
75- if adminRoute != nil {
76- p.routes[adminDomain] = adminRoute
77- logger.Debug("Restored admin domain route after clearing routes map", "domain", adminDomain)
78- }
67+ p.restoreAdminRoute(adminRoute, p.routes)
7968
8069 // Populate the routes map
8170 for rows.Next() {
@@ -85,19 +74,11 @@ func (p *Proxy) loadRoutes() error {
8574 return fmt.Errorf("failed to scan row: %w", err)
8675 }
8776
88- // Normalize domain (lowercase, no trailing dot)
89- domain = strings.TrimSuffix(strings.ToLower(domain), ".")
77+ // Normalize domain
78+ domain = normalizeDomain(domain)
9079
9180 // Add the route to the map
92- p.routes[domain] = &ProxyRouteInfo{
93- Domain: domain,
94- ContainerID: containerID,
95- ContainerIP: containerIP,
96- ContainerPort: containerPort,
97- Protocol: protocol,
98- Path: path,
99- Active: active,
100- }
81+ p.routes[domain] = createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path, active)
10182
10283 logger.Debug("Loaded proxy route",
10384 "domain", domain,
@@ -112,114 +93,29 @@ func (p *Proxy) loadRoutes() error {
11293
11394 // configureRoutes sets up the HTTP and HTTPS routes
11495 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+
115100 // Get the admin domain from the config
116101 adminDomain := p.app.GetConfig().Http.FullDomain()
117102 logger.Debug("Configuring proxy routes", "admin_domain", adminDomain)
118103
119104 // Verify admin domain is in routes - if not, we need to add it
120- p.mu.RLock()
121105 _, adminRouteExists := p.routes[adminDomain]
122- p.mu.RUnlock()
123106
124107 if !adminRouteExists {
125- logger.Warn("Admin domain route is missing, attempting to recreate it")
126- p.mu.Lock()
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- }
108+ // Create new admin route
109+ p.routes[adminDomain] = p.createAdminRoute()
152110 logger.Info("Recreated admin domain route",
153111 "domain", adminDomain,
154- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
155- p.mu.Unlock()
112+ "target", fmt.Sprintf("http://%s:%s", p.routes[adminDomain].ContainerIP, p.routes[adminDomain].ContainerPort))
156113 } else {
157114 logger.Debug("Admin domain route exists", "domain", adminDomain)
158115 }
159116
160- // Handler function - will be assigned to both HTTP and HTTPS servers
161- handler := echo.HandlerFunc(func(c echo.Context) error {
162- host := c.Request().Host
163-
164- // Strip port if present in the host
165- if hostParts := strings.Split(host, ":"); len(hostParts) > 1 {
166- host = hostParts[0]
167- }
168-
169- // Normalize host (trim trailing dot, convert to lowercase)
170- host = strings.TrimSuffix(strings.ToLower(host), ".")
171-
172- // Check if this is the admin domain
173- adminDomain := p.app.GetConfig().Http.FullDomain()
174- if strings.EqualFold(host, adminDomain) {
175- // Always prioritize admin domain
176- p.mu.RLock()
177- adminRoute, adminOk := p.routes[adminDomain]
178- p.mu.RUnlock()
179-
180- if adminOk {
181- return p.proxyRequest(c, adminRoute)
182- }
183- }
184-
185- // Important: Get the route freshly from the map for every request
186- // This ensures we always use the most up-to-date IP
187- p.mu.RLock()
188- route, ok := p.routes[host]
189- p.mu.RUnlock()
190-
191- if !ok {
192- // Check if the host is an IP address - silently handle without logging warnings
193- if net.ParseIP(host) != nil {
194- // For IP-based requests, just return a 404 without logging warnings
195- return c.String(http.StatusNotFound, "Domain not found")
196- }
197-
198- // Create list of available domains for debugging
199- availableDomains := make([]string, 0, len(p.routes))
200- p.mu.RLock()
201- for d := range p.routes {
202- availableDomains = append(availableDomains, d)
203- }
204- p.mu.RUnlock()
205-
206- // Log warning for non-IP hosts that aren't configured
207- logger.Warn("Request with unknown host",
208- "requested_host", host,
209- "client_ip", c.RealIP(),
210- "available_domains", strings.Join(availableDomains, ", "))
211- return c.String(http.StatusNotFound, "Domain not found")
212- }
213-
214- // Check if the route is active
215- if !route.Active {
216- logger.Warn("Route is not active",
217- "domain", host,
218- "client_ip", c.RealIP())
219- return c.String(http.StatusServiceUnavailable, "Route is not active")
220- }
221- return p.proxyRequest(c, route)
222- })
117+ // Create handler function for both HTTP and HTTPS servers
118+ handler := p.createProxyHandler()
223119
224120 // Add the handler to the HTTPS server
225121 if p.httpsServer != nil {
@@ -237,8 +133,8 @@ func (p *Proxy) configureRoutes() {
237133 // Note: Regardless of the external protocol, all internal connections to containers use HTTP.
238134 // The HTTPS protocol is only used for external connections and certificates.
239135 func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error {
240- // Normalize domain name (lowercase, no trailing dot)
241- domainName = strings.TrimSuffix(strings.ToLower(domainName), ".")
136+ // Normalize domain name
137+ domainName = normalizeDomain(domainName)
242138
243139 logger.Debug("Adding proxy route",
244140 "domain", domainName,
@@ -256,15 +152,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
256152
257153 // Add the route to memory immediately for better UX
258154 p.mu.Lock()
259- p.routes[domainName] = &ProxyRouteInfo{
260- Domain: domainName,
261- ContainerID: containerID,
262- ContainerIP: containerIP,
263- ContainerPort: containerPort,
264- Protocol: protocol,
265- Path: path,
266- Active: true,
267- }
155+ p.routes[domainName] = createRouteInfo(domainName, containerID, containerIP, containerPort, protocol, path, true)
268156 p.mu.Unlock()
269157
270158 // Return success - the event listener will handle the database update
@@ -324,37 +212,8 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
324212 logger.Debug("Added domain to database for Let's Encrypt",
325213 "domain", hostname)
326214
327- // Also add the subdomain.domain format to the database if this is a domain with components
328- hostParts := strings.Split(hostname, ".")
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- }
215+ // Process subdomain if needed
216+ p.processNewSubdomain(tx, hostname, accountID, now)
358217 } else {
359218 logger.Error("Failed to query domain",
360219 "domain", hostname,
@@ -439,27 +298,23 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
439298 // Register this container to prevent duplicate processing
440299 p.RegisterNewlyCreatedContainer(containerID)
441300
442- // Update the in-memory routes map immediately for better UX
301+ // Lock to update the in-memory routes map and reload routes
443302 p.mu.Lock()
444- p.routes[hostname] = &ProxyRouteInfo{
445- Domain: hostname,
446- ContainerID: containerID,
447- ContainerIP: containerIP,
448- ContainerPort: containerPort,
449- Protocol: protocol,
450- Path: path,
451- Active: true,
303+
304+ // Update the in-memory routes map
305+ hostname = domainName
306+ p.routes[hostname] = createRouteInfo(hostname, containerID, containerIP, containerPort, protocol, path, true)
307+
308+ // Reload the routes directly while holding the lock
309+ // This avoids the race condition from concurrent reload operations
310+ if err := p.Reload(); err != nil {
311+ logger.Error("Failed to reload routes after adding route",
312+ "domain", hostname,
313+ "error", err)
452314 }
453- p.mu.Unlock()
454315
455- // Reload the routes in the background to avoid blocking
456- go func() {
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- }()
316+ // Unlock before continuing with certificate operations
317+ p.mu.Unlock()
463318
464319 logger.Info("Added proxy route",
465320 "domain", hostname,
@@ -488,10 +343,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
488343 // RemoveRoute removes a route from the database and reloads the proxy
489344 func (p *Proxy) RemoveRoute(domainName string) error {
490345 // Prevent removing the admin domain
491- adminDomain := p.app.GetConfig().Http.FullDomain()
492- if domainName == adminDomain {
493- logger.Warn("Attempt to remove admin domain route prevented",
494- "domain", domainName)
346+ if err := p.checkAdminDomainProtection(domainName); err != nil {
495347 return fmt.Errorf("cannot remove admin domain route: %s", domainName)
496348 }
497349
@@ -512,16 +364,9 @@ func (p *Proxy) RemoveRoute(domainName string) error {
512364 return fmt.Errorf("failed to query domain: %w", err)
513365 }
514366
515- // Delete the route with retry
516- _, err = txExecWithRetry(tx, p.queries.DeleteRouteByDomainID, domainID)
517- if err != nil {
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)
367+ // Delete the route and domain from the database
368+ if err := p.deleteRouteWithTransaction(tx, domainID); err != nil {
369+ return err
525370 }
526371
527372 // Commit the transaction
@@ -556,27 +401,17 @@ func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo {
556401 func (p *Proxy) Reload() error {
557402 logger.Info("Reloading proxy routes from database")
558403
559- // Get admin domain - this should be the full subdomain.domain format
404+ // Get admin domain
560405 adminDomain := p.app.GetConfig().Http.FullDomain()
561- // Normalize the admin domain (lowercase, no trailing dot)
562- adminDomain = strings.TrimSuffix(strings.ToLower(adminDomain), ".")
406+ // Normalize the admin domain
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()
563412
564413 // Save existing admin route if it exists
565- p.mu.RLock()
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()
414+ adminRoute := p.saveAdminRoute()
580415
581416 // Create a new route map instead of modifying the existing one
582417 // This ensures a clean reload without any potential race conditions
@@ -602,19 +437,11 @@ func (p *Proxy) Reload() error {
602437 return fmt.Errorf("failed to scan row during reload: %w", err)
603438 }
604439
605- // Normalize domain (lowercase, no trailing dot)
606- domain = strings.TrimSuffix(strings.ToLower(domain), ".")
440+ // Normalize domain
441+ domain = normalizeDomain(domain)
607442
608443 // Add the route to the new map
609- newRoutes[domain] = &ProxyRouteInfo{
610- Domain: domain,
611- ContainerID: containerID,
612- ContainerIP: containerIP,
613- ContainerPort: containerPort,
614- Protocol: protocol,
615- Path: path,
616- Active: active,
617- }
444+ newRoutes[domain] = createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path, active)
618445
619446 logger.Debug("Loaded route during reload",
620447 "domain", domain,
@@ -654,38 +481,18 @@ func (p *Proxy) Reload() error {
654481
655482 // If admin domain is still missing, recreate it
656483 if _, exists := newRoutes[adminDomain]; !exists {
657- logger.Warn("Admin domain route is missing, recreating it", "domain", adminDomain)
658-
659- // Auto-detect the Gordon container information
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- }
484+ // Create a new admin route
485+ adminRoute := p.createAdminRoute()
486+ newRoutes[adminDomain] = adminRoute
680487
681488 // Add to database
682489 err := p.AddRoute(
683490 adminDomain,
684- containerName,
685- containerIP,
686- p.app.GetConfig().Http.Port,
687- "http",
688- "/",
491+ adminRoute.ContainerID,
492+ adminRoute.ContainerIP,
493+ adminRoute.ContainerPort,
494+ adminRoute.Protocol,
495+ adminRoute.Path,
689496 )
690497
691498 if err != nil {
@@ -697,24 +504,22 @@ func (p *Proxy) Reload() error {
697504 }
698505 }
699506
700- // Atomically swap the new routes map with the old one
701- // This ensures that no requests use a partially updated map
702- p.mu.Lock()
507+ // Update the routes map with the new one
703508 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.
705511
706- // Rebuild route configuration
707- p.configureRoutes()
512+ // Define the handler using our helper function
513+ handler := p.createProxyHandler()
708514
709- // Force recreation of the route handlers if servers are initialized
710- if p.httpServer != nil {
711- logger.Debug("Refreshing HTTP routes")
712- p.httpServer.Routes()
515+ // Add the handler to the HTTPS server
516+ if p.httpsServer != nil {
517+ p.httpsServer.Any("/*", handler)
713518 }
714519
715- if p.httpsServer != nil {
716- logger.Debug("Refreshing HTTPS routes")
717- p.httpsServer.Routes()
520+ // Also add the handler to the HTTP server for HTTP-01 challenges
521+ if p.httpServer != nil {
522+ p.httpServer.Any("/*", handler)
718523 }
719524
720525 return nil
@@ -743,91 +548,14 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error {
743548 "old_ip", route.ContainerIP,
744549 "new_ip", newIP)
745550
746- // Double-check that the container exists and is running
747- containerInfo, err := docker.GetContainerInfo(containerID)
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- }
551+ // Verify and normalize the container IP
552+ newIP = p.verifyAndNormalizeContainerIP(containerID, newIP)
773553
774554 // First, update the in-memory route immediately
775- p.mu.Lock()
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()
555+ p.forceUpdateRouteIPInMemory(domain, newIP)
787556
788557 // Next, update the database
789- // Get the domain ID with retry
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
558+ return p.updateRouteIPInDatabase(domain, containerID, newIP)
831559 }
832560
833561 // FindRoutesByContainerName finds routes associated with a specific container name
@@ -992,8 +720,8 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
992720
993721 // First approach: check if there are any domains that should point to this container based on labels
994722 if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" {
995- // Normalize domain (lowercase, no trailing dot)
996- domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".")
723+ // Normalize domain
724+ domainLabel = normalizeDomain(domainLabel)
997725
998726 // Check if there's already a route for this domain
999727 p.mu.RLock()
@@ -1008,28 +736,10 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
1008736 "old_container_id", route.ContainerID,
1009737 "new_container_id", containerID)
1010738
1011- // Get the port to use for the proxy from the container labels
1012- containerPort := "80" // Default
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)
739+ // Update route for this container
740+ err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, route.Path)
1026741 if err != nil {
1027742 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)
1033743 }
1034744 } else {
1035745 // Same container ID but maybe IP changed
@@ -1053,27 +763,8 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
1053763 "domain", domainLabel,
1054764 "container_id", containerID)
1055765
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-
1069766 // Add a new route for this container
1070- logger.Info("Adding missing route for Gordon container",
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, "/")
767+ err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, "")
1077768 if err != nil {
1078769 logger.Error("Failed to add missing route", "error", err)
1079770 }
@@ -1093,33 +784,10 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI
1093784 "old_container_id", oldRoute.ContainerID,
1094785 "new_container_id", containerID)
1095786
1096- // Get the port to use for the proxy from the container labels
1097- containerPort := oldRoute.ContainerPort // Use the existing port by default
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)
787+ // Update route for this container, preserving the old path
788+ err = p.updateRouteForContainer(domain, containerID, containerIP, containerInfo, oldRoute.Path)
1116789 if err != nil {
1117790 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)
1123791 }
1124792 }
1125793 }
@@ -1143,150 +811,13 @@ func (p *Proxy) StartContainerEventListener() error {
1143811 "container_name", containerName,
1144812 "container_ip", containerIP)
1145813
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-
1157814 // Use a goroutine to handle the event asynchronously to avoid blocking the event listener
1158815 go func() {
1159816 // Add a small delay to allow any direct API calls to complete first
1160817 time.Sleep(500 * time.Millisecond)
1161818
1162- // Check if this is the Gordon container (admin domain)
1163- isAdminContainer := false
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- }
819+ // Process the container event
820+ p.processContainerEvent(containerID, containerName, containerIP)
1290821
1291822 // Call the standard recreation handler for additional checks
1292823 p.HandleContainerRecreation(containerID, containerName, containerIP)
@@ -1339,60 +870,15 @@ func (p *Proxy) StartContainerEventListener() error {
1339870
1340871 // checkRoutesForIPConsistency verifies that all active routes have the correct container IP
1341872 func (p *Proxy) checkRoutesForIPConsistency() {
873+ // Get the network name from app config
1342874 networkName := p.app.GetConfig().ContainerEngine.Network
1343875
1344- // Get a copy of the current routes
1345- p.mu.RLock()
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()
876+ // Get a copy of all active routes
877+ routesToCheck := p.getActiveRoutes()
1361878
1362- // Check each active route
879+ // Check each active route for IP consistency
1363880 for domain, route := range routesToCheck {
1364- // Skip checking admin domain
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- }
881+ p.checkAndUpdateIPIfNeeded(domain, route, networkName)
1396882 }
1397883 }
1398884
@@ -1402,28 +888,14 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1402888 return
1403889 }
1404890
1405- // Get the admin domain to protect it
1406- adminDomain := p.app.GetConfig().Http.FullDomain()
1407-
1408891 // Filter out admin domain from domains to mark inactive
1409- filteredDomains := make([]string, 0, len(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- }
892+ filteredDomains := p.filterOutAdminDomain(domains)
1418893
1419894 // If all domains were filtered out, nothing to do
1420895 if len(filteredDomains) == 0 {
1421896 return
1422897 }
1423898
1424- // Use the filtered domains from here on
1425- domains = filteredDomains
1426-
1427899 // Begin a transaction
1428900 tx, err := p.app.GetDB().Begin()
1429901 if err != nil {
@@ -1432,31 +904,17 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1432904 }
1433905 defer tx.Rollback()
1434906
1435- // Update each route in the database
1436- for _, domain := range domains {
1437- // First, get the domain ID
1438- var domainID string
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)
907+ // Update each route in the database and memory
908+ for _, domain := range filteredDomains {
909+ // Update in database
910+ err := p.markRouteInactiveInDatabase(tx, domain)
1448911 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
1450913 continue
1451914 }
1452915
1453- // Also update the in-memory route
1454- p.mu.Lock()
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()
916+ // Update in memory
917+ p.markRouteInactiveInMemory(domain)
1460918 }
1461919
1462920 // Commit the transaction
@@ -1465,7 +923,7 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1465923 return
1466924 }
1467925
1468- logger.Info("Successfully marked orphaned routes as inactive", "count", len(domains))
926+ logger.Info("Successfully marked orphaned routes as inactive", "count", len(filteredDomains))
1469927 }
1470928
1471929 // DiscoverMissingRoutes finds Gordon-managed containers that don't have routes yet
@@ -1531,8 +989,8 @@ func (p *Proxy) DiscoverMissingRoutes() {
1531989 continue
1532990 }
1533991
1534- // Normalize domain (lowercase, no trailing dot)
1535- domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".")
992+ // Normalize domain
993+ domainLabel = normalizeDomain(domainLabel)
1536994
1537995 // Skip if this is the admin domain
1538996 if domainLabel == p.app.GetConfig().Http.FullDomain() {
@@ -1559,27 +1017,13 @@ func (p *Proxy) DiscoverMissingRoutes() {
15591017 // Extract just the IP part from the CIDR notation (e.g., "192.168.1.2/24" → "192.168.1.2")
15601018 containerIP = strings.Split(containerIP, "/")[0]
15611019
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-
15751020 // Add a new route for this container
15761021 logger.Info("Adding missing route for Gordon container",
15771022 "domain", domainLabel,
15781023 "container_id", containerID,
1579- "container_ip", containerIP,
1580- "container_port", containerPort)
1024+ "container_ip", containerIP)
15811025
1582- err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, "/")
1026+ err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, "")
15831027 if err != nil {
15841028 logger.Error("Failed to add missing route", "error", err)
15851029 }
@@ -1737,6 +1181,7 @@ func (p *Proxy) LogGordonIdentity() {
17371181 }
17381182 }
17391183
1184+// detectGordonContainer attempts to find the Gordon container automatically
17401185 // detectGordonContainer attempts to find the Gordon container automatically
17411186 func (p *Proxy) detectGordonContainer() string {
17421187 logger.Debug("Starting Gordon container detection")
@@ -1880,83 +1325,11 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error {
18801325 // Create the target URL
18811326 targetURL := &url.URL{
18821327 Scheme: targetProtocol,
1328+ Host: fmt.Sprintf("%s:%s", formatIPAddress(containerIP), containerPort),
18831329 }
18841330
1885- // Format host properly for both IPv4 and IPv6
1886- if strings.Contains(containerIP, ":") {
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- }
1331+ // Create a reverse proxy using the helper function
1332+ proxy := p.createReverseProxy(targetURL, host, containerID, isAdminDomain)
19601333
19611334 // Serve the request using the reverse proxy
19621335 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 (
44 "context"
55 "crypto/tls"
66 "fmt"
7+ "log"
78 "net/http"
9+ "os"
10+ "os/exec"
11+ "strconv"
12+ "strings"
13+ "sync"
814 "time"
915
16+ "net"
17+
1018 "github.com/bnema/gordon/pkg/docker"
1119 "github.com/bnema/gordon/pkg/logger"
1220 "github.com/labstack/echo/v4"
1321 "golang.org/x/crypto/acme"
1422 )
1523
24+// Global mutex for admin connection testing to prevent concurrent executions
25+var adminTestMutex sync.Mutex
26+
1627 // This file contains server operations functions for the proxy.
1728 // These methods may need to be deleted from proxy.go after validation.
1829
1930 // Start loads the routes from the database and starts the proxy server
2031 func (p *Proxy) Start() error {
21- // First, load routes from the database
32+ // 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
2242 if err := p.loadRoutes(); err != nil {
2343 return fmt.Errorf("failed to load routes: %w", err)
2444 }
2545
26- // Now log Gordon container identity for debugging (after routes are loaded)
46+ // Log Gordon container identity and prepare admin route
2747 p.LogGordonIdentity()
48+ p.setupAdminRoute()
2849
29- // Scan Gordon network containers and check for certificates
50+ // Scan containers for certificates
3051 if err := p.scanContainersAndCheckCertificates(); err != nil {
3152 logger.Warn("Failed to scan containers for certificate checks", "error", err)
3253 }
3354
34- // Run an immediate route verification to ensure all routes are valid on startup
35- logger.Info("Performing initial route verification on startup")
36-
37- // Discover any containers that should have routes but don't
55+ // Discover missing routes and log active containers
3856 p.DiscoverMissingRoutes()
39-
40- // Log all active containers and their routes for visibility
4157 p.LogActiveContainersAndRoutes()
4258
43- // Start the container event listener for real-time updates
59+ // Start container event listener
4460 if err := p.StartContainerEventListener(); err != nil {
4561 logger.Warn("Failed to start container event listener", "error", err)
46- // Continue anyway, this is non-critical
4762 }
4863
49- // Check if there might be port conflicts with the main server
50- mainServerPort := p.app.GetConfig().Http.Port
64+ // Start the admin route verification in a separate goroutine
65+ go p.StartAdminRouteVerification()
5166
52- // Check HTTP port conflict
53- if p.config.HttpPort == mainServerPort {
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")
67+ // Check for port conflicts with the main server
68+ if p.checkForPortConflicts() {
5769 return nil
5870 }
5971
60- // Check HTTPS port conflict (less common, but still possible)
61- if p.config.Port == mainServerPort {
62- logger.Warn("HTTPS port for reverse proxy conflicts with main server port",
63- "port", p.config.Port,
64- "solution", "reverse proxy HTTPS server will be disabled")
65- return nil
72+ // Set up middleware and configure routes
73+ p.setupMiddleware()
74+ p.configureRoutes()
75+
76+ // Configure HTTP server for Let's Encrypt challenges
77+ 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+ }
66131 }
67132
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() {
69142 adminDomain := p.app.GetConfig().Http.FullDomain()
143+ logger.Info("Setting up admin domain route", "domain", adminDomain)
70144
71145 // Auto-detect the Gordon container name and ID
72146 containerName := p.detectGordonContainer()
73-
74- // Get the network name from config
75147 networkName := p.app.GetConfig().ContainerEngine.Network
76148
77- // Initialize container IP
78- var containerIP string
79-
80- // Try to get the actual container IP from the network
149+ // Get the IP address from the container's network
81150 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
83153 }
84154
85155 // Get the IP address from the container's network
@@ -89,147 +159,171 @@ func (p *Proxy) Start() error {
89159 "container_id", p.gordonContainerID,
90160 "network", networkName,
91161 "error", err)
92- return fmt.Errorf("failed to get Gordon container IP from network %s: %w", networkName, err)
162+ return
93163 }
94164
95- containerIP = ip
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
165+ // Add the admin route to the database
102166 err = p.AddRoute(
103167 adminDomain,
104168 containerName,
105- containerIP,
169+ ip,
106170 p.app.GetConfig().Http.Port,
107171 "http", // Gordon server uses HTTP internally
108172 "/",
109173 )
110174
111175 if err != nil {
112- logger.Error("Failed to add admin route to database",
113- "error", err,
114- "domain", adminDomain)
176+ logger.Error("Failed to add admin route to database", "error", err, "domain", adminDomain)
115177 } else {
116- logger.Info("Ensured admin domain route is in database",
117- "domain", adminDomain,
118- "container", containerName,
119- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
178+ logger.Info("Ensured admin domain route is in database", "domain", adminDomain)
120179 }
121180
181+ // Add to in-memory routes if it doesn't already exist
122182 p.mu.Lock()
123- // Only add if it doesn't already exist in memory
124183 if _, exists := p.routes[adminDomain]; !exists {
125184 p.routes[adminDomain] = &ProxyRouteInfo{
126185 Domain: adminDomain,
127- ContainerIP: containerIP,
186+ ContainerIP: ip,
128187 ContainerPort: p.app.GetConfig().Http.Port,
129- ContainerID: containerName, // Use auto-detected container name
130- Protocol: "http", // Gordon server uses HTTP internally
188+ ContainerID: containerName,
189+ Protocol: "http", // Gordon server uses HTTP internally
131190 Path: "/",
132191 Active: true,
133192 }
134- logger.Info("Added special route for admin domain",
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))
193+ logger.Info("Added special route for admin domain", "domain", adminDomain)
145194 }
146195 p.mu.Unlock()
196+}
147197
148- // Set up middleware
149- p.setupMiddleware()
198+// checkForPortConflicts checks if there are port conflicts with the main server
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
150202
151- // Configure routes
152- p.configureRoutes()
203+ // Check HTTP port conflict
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+ }
153210
154- // Configure HTTP server to handle Let's Encrypt HTTP-01 challenges
155- // and redirect everything else to HTTPS
156- p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil)))
211+ // Check HTTPS port conflict
212+ if p.config.Port == mainServerPort {
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+}
157221
158- // Create a custom HTTPS server with proper TLS config
222+// startHTTPSServer starts the HTTPS server
223+func (p *Proxy) startHTTPSServer() {
224+ // Create HTTPS server
159225 httpsServer := &http.Server{
160226 Addr: fmt.Sprintf(":%s", p.config.Port),
161227 Handler: p.httpsServer,
162228 TLSConfig: &tls.Config{
163229 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-
170230 // Try to get the certificate from the autocert manager
171231 cert, err := p.certManager.GetCertificate(hello)
172232
173- // If we can't get a certificate and we have a fallback, use it for the admin domain
233+ // If no cert available and we have a fallback, use it for admin domain
234+ adminDomain := p.app.GetConfig().Http.FullDomain()
174235 if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil {
175- logger.Debug("Using fallback certificate for admin domain",
176- "domain", adminDomain,
177- "error", err)
236+ logger.Info("Using fallback certificate for admin domain", "domain", adminDomain)
178237 return p.fallbackCert, nil
179238 }
180239
181240 return cert, err
182241 },
183242 MinVersion: tls.VersionTLS12,
184- // Add server name to use when client doesn't send SNI
185243 ServerName: p.app.GetConfig().Http.FullDomain(),
186- // Add support for TLS-ALPN-01 challenges
187244 NextProtos: []string{acme.ALPNProto},
188245 },
189- // Add timeouts to prevent hanging connections
190246 ReadTimeout: 30 * time.Second,
191247 WriteTimeout: 30 * time.Second,
192248 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)
193277 }
278+}
194279
195- // Create a custom HTTP server
280+// startHTTPServer starts the HTTP server
281+func (p *Proxy) startHTTPServer() {
282+ // Create HTTP server
196283 httpServer := &http.Server{
197284 Addr: fmt.Sprintf(":%s", p.config.HttpPort),
198285 Handler: p.httpServer,
199286 ReadTimeout: 30 * time.Second,
200287 WriteTimeout: 30 * time.Second,
201288 IdleTimeout: 120 * time.Second,
289+ ErrorLog: log.New(os.Stderr, "[HTTP] ", log.LstdFlags),
202290 }
203291
204- p.serverStarted = true
292+ // Try to start the server with different approaches depending on environment
293+ logger.Info("Starting HTTP server", "port", p.config.HttpPort)
205294
206- // Start HTTPS server
207- go func() {
208- logger.Info("Starting HTTPS server", "address", httpsServer.Addr)
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- }()
295+ // First try explicit IPv4 binding
296+ httpAddr := fmt.Sprintf("0.0.0.0:%s", p.config.HttpPort)
297+ httpListener, err := net.Listen("tcp4", httpAddr)
214298
215- // Start HTTP server
216- go func() {
217- logger.Info("Starting HTTP server", "address", httpServer.Addr)
299+ if err != nil {
300+ logger.Warn("Failed to create HTTP listener with explicit IPv4 binding",
301+ "error", err, "port", p.config.HttpPort)
302+
303+ // Try simple binding as fallback
218304 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)
220306 }
221- }()
222-
223- // Test admin connection after server starts
224- go p.TestAdminConnectionLater()
307+ return
308+ }
225309
226- return nil
310+ // 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+ }
227315 }
228316
229317 // Stop gracefully shuts down the proxy server
230318 func (p *Proxy) Stop() error {
231319 logger.Info("Stopping reverse proxy")
232320
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+
233327 // Run any rate limiter cleanup functions
234328 for _, cleanup := range rateLimiterCleanup {
235329 cleanup()
@@ -325,21 +419,178 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
325419
326420 // TestAdminConnectionLater tests the admin connection after a short delay
327421 func (p *Proxy) TestAdminConnectionLater() {
328- // Wait for servers to start
329- time.Sleep(2 * time.Second)
422+ // Try to acquire the lock, return immediately if another test is in progress
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+ }
330458
331459 logger.Debug("Testing admin connections after server startup")
332460
333461 // Get domain and current route
334462 adminDomain := p.app.GetConfig().Http.FullDomain()
335463
464+ // First, check if the route exists in memory
336465 p.mu.RLock()
337466 route, exists := p.routes[adminDomain]
338467 p.mu.RUnlock()
339468
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+
340524 if !exists {
341- logger.Warn("Admin route not found")
342- return
525+ logger.Warn("Admin route not found in memory, creating it now")
526+ // 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+ }
343594 }
344595
345596 // Test connection and update if needed
@@ -351,14 +602,43 @@ func (p *Proxy) TestAdminConnectionLater() {
351602 logger.Info("Updated admin route with working connection",
352603 "domain", adminDomain,
353604 "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+ }
354613 }
355614 p.mu.Unlock()
615+
616+ // Reconfigure routes to apply the IP change
617+ p.configureRoutes()
356618 }
357619
358620 // Check external port access
359621 p.checkExternalPortAccess()
360622 }
361623
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+
362642 // checkExternalPortAccess verifies if ports 80 and 443 are accessible
363643 func (p *Proxy) checkExternalPortAccess() {
364644 adminDomain := p.app.GetConfig().Http.FullDomain()
@@ -406,82 +686,129 @@ func (p *Proxy) checkExternalPortAccess() {
406686 }
407687 }
408688
409-// // detectGordonContainer attempts to find the Gordon container automatically
410-// func (p *Proxy) detectGordonContainer() string {
411-// // Check if we're running inside a container
412-// if docker.IsRunningInContainer() {
413-// logger.Debug("Detected we're running in a container")
414-
415-// // Get our hostname which should be the container ID in Docker/Podman
416-// hostname := os.Getenv("HOSTNAME")
417-// if hostname != "" {
418-// // Check if there's a container with this hostname
419-// containers, err := docker.ListRunningContainers()
420-// if err == nil {
421-// for _, container := range containers {
422-// // Check if this container is us by comparing hostname with container ID
423-// if strings.HasPrefix(hostname, container.ID) {
424-// // This is our container
425-// containerName := strings.TrimLeft(container.Names[0], "/")
426-// logger.Debug("Auto-detected Gordon container name from hostname", "container_name", containerName)
427-// return containerName
428-// }
429-// }
430-// } else {
431-// logger.Debug("Could not list containers when checking hostname", "error", err)
432-// }
433-// }
434-// }
435-
436-// // Try to find Gordon by examining container processes
437-// containers, err := docker.ListRunningContainers()
438-// if err == nil {
439-// for _, container := range containers {
440-// containerID := container.ID
441-
442-// // Check container processes for Gordon
443-// containerLogs, err := docker.GetContainerLogs(containerID)
444-// if err == nil && (strings.Contains(containerLogs, "/gordon") || strings.Contains(containerLogs, "serve")) {
445-// containerName := strings.TrimLeft(container.Names[0], "/")
446-// logger.Debug("Auto-detected Gordon container by process", "container_name", containerName)
447-// return containerName
448-// } else if err != nil {
449-// logger.Debug("Could not get container logs", "container_id", containerID, "error", err)
450-// }
451-
452-// // Check container command for Gordon
453-// if len(container.Command) > 0 && strings.Contains(container.Command, "gordon") {
454-// containerName := strings.TrimLeft(container.Names[0], "/")
455-// logger.Debug("Auto-detected Gordon container by command", "container_name", containerName)
456-// return containerName
457-// }
458-// }
459-// } else {
460-// logger.Debug("Could not list containers when checking processes", "error", err)
461-// }
462-
463-// // If we couldn't detect from processes, try to find a container with gordon in the name or image
464-// if err == nil {
465-// for _, container := range containers {
466-// // Check container names for "gordon"
467-// for _, name := range container.Names {
468-// if strings.Contains(strings.ToLower(name), "gordon") {
469-// containerName := strings.TrimLeft(name, "/")
470-// logger.Debug("Auto-detected Gordon container by name", "container_name", containerName)
471-// return containerName
472-// }
473-// }
474-
475-// // Check image name for "gordon"
476-// if strings.Contains(strings.ToLower(container.Image), "gordon") {
477-// containerName := strings.TrimLeft(container.Names[0], "/")
478-// logger.Debug("Auto-detected Gordon container by image", "container_name", containerName, "image", container.Image)
479-// return containerName
480-// }
481-// }
482-// }
483-
484-// // If all else fails, default to "gordon"
485-// logger.Debug("Could not auto-detect Gordon container, using default name", "container_name", "gordon")
486-// return "gordon"
487-// }
689+// getOwnContainerIP attempts to determine the container's own IP address
690+// from network interfaces, preferring non-loopback interfaces
691+func (p *Proxy) getOwnContainerIP() string {
692+ // Default fallback IP based on common container network range
693+ defaultIP := "10.89.0.118" // Updated to match the observed IP in logs
694+
695+ // Use environment variable if available (can be set in container config)
696+ if envIP := os.Getenv("GORDON_CONTAINER_IP"); envIP != "" {
697+ logger.Debug("Using container IP from environment variable", "ip", envIP)
698+ return envIP
699+ }
700+
701+ // Try to get the IP from the Gordon network - this is the most reliable method
702+ networkName := p.app.GetConfig().ContainerEngine.Network
703+ if p.gordonContainerID != "" {
704+ ip, err := docker.GetContainerIPFromNetwork(p.gordonContainerID, networkName)
705+ if err == nil && ip != "" {
706+ logger.Info("Using container IP from Docker network", "ip", ip, "network", networkName)
707+ return ip
708+ } else {
709+ logger.Error("Failed to get container IP from Docker network",
710+ "error", err,
711+ "container_id", p.gordonContainerID,
712+ "network", networkName)
713+ }
714+ } else {
715+ logger.Error("Gordon container ID not set, cannot get container IP from Docker network")
716+ }
717+
718+ // Try to detect by listing network interfaces
719+ ifaces, err := net.Interfaces()
720+ if err != nil {
721+ logger.Error("Failed to list network interfaces", "error", err)
722+ return defaultIP
723+ }
724+
725+ // First, look for eth0 or similar standard interfaces
726+ for _, iface := range ifaces {
727+ // Skip loopback
728+ if iface.Flags&net.FlagLoopback != 0 {
729+ continue
730+ }
731+
732+ // Prefer eth0 or similar
733+ if strings.HasPrefix(iface.Name, "eth") || strings.HasPrefix(iface.Name, "en") {
734+ addrs, err := iface.Addrs()
735+ if err != nil {
736+ continue
737+ }
738+
739+ for _, addr := range addrs {
740+ ip, _, err := net.ParseCIDR(addr.String())
741+ if err != nil {
742+ continue
743+ }
744+
745+ // Only use IPv4 addresses
746+ if ipv4 := ip.To4(); ipv4 != nil {
747+ logger.Debug("Found container IP from network interface",
748+ "interface", iface.Name,
749+ "ip", ipv4.String())
750+ return ipv4.String()
751+ }
752+ }
753+ }
754+ }
755+
756+ // If no preferred interface found, use any non-loopback interface
757+ for _, iface := range ifaces {
758+ if iface.Flags&net.FlagLoopback != 0 {
759+ continue
760+ }
761+
762+ addrs, err := iface.Addrs()
763+ if err != nil {
764+ continue
765+ }
766+
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 @@
22 <html lang="en">
33 {{template "header.gohtml" .}}
44
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">
66 {{template "menu.gohtml" .}}
77 <!-- Main content here -->
88 <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
99 <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">
11- <h1 class="text-2xl font-bold mb-4">Edit Container</h1>
10+ <div class="card flex-grow">
11+ <h1 class="card-header mb-4">Edit Container</h1>
1212 <!-- Include the existing edit container fragment here -->
1313 {{template "createcontainer.gohtml" .}}
1414 </div>
M internal/templating/models/html/admin/dashboard.gohtml +1 -1
@@ -1,6 +1,6 @@
11 <div id="dashboard">
22 <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"
44 >
55 Hello World from dashboard.html
66 </h1>
M internal/templating/models/html/admin/index.gohtml +9 -9
@@ -2,23 +2,23 @@
22 <html lang="en">
33 {{template "header.gohtml" .}}
44
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">
66 {{template "menu.gohtml" .}}
77 <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4">
88 <div
99 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">
11- <h2>{{index .Lang "dashboard" "content" "title"}}</h2>
10+ <div class="card flex-grow">
11+ <h2 class="card-header">{{index .Lang "dashboard" "content" "title"}}</h2>
1212 </div>
1313 <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">
15- <div class="w-full h-44 rounded-sm bg-gray-300"></div>
14+ <div class="card">
15+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
1616 </div>
17- <div class="bg-white p-4 rounded-md shadow mb-4">
18- <div class="w-full h-44 rounded-sm bg-gray-300"></div>
17+ <div class="card">
18+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
1919 </div>
20- <div class="bg-white p-4 rounded-md shadow mb-4">
21- <div class="w-full h-44 rounded-sm bg-gray-300"></div>
20+ <div class="card">
21+ <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
2222 </div>
2323 </div>
2424 </div>
M internal/templating/models/html/admin/install.gohtml +7 -7
@@ -21,7 +21,7 @@
2121 </head>
2222
2323 <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"
2525 hx-boost="true"
2626 >
2727 <!-- Logo and Title -->
@@ -38,28 +38,28 @@
3838 </div>
3939 <div class="flex justify-center mt-4">
4040 <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"
4242 >
4343 Gordon
4444 </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"
4646 >v0.0.1</span
4747 >
4848 </div>
4949 </div>
5050
5151 <!-- Main Content -->
52- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">
52+ <div class="card w-2/3">
5353 <!-- Built-in Proxy -->
5454 <div id="built-in-proxy" class="mt-8">
5555 <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">
5757 <span class="text-white font-bold">✓</span>
5858 </div>
59- <h2 class="text-lg ml-2">Reverse Proxy</h2>
59+ <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
6060 </div>
6161
62- <div class="bg-gray-100 p-4 rounded-md">
62+ <div class="bg-indigo-50 p-4 rounded-md">
6363 <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
6464 <p>The proxy automatically:</p>
6565 <ul class="list-disc ml-6 mb-4">
M internal/templating/models/html/admin/manager.gohtml +65 -23
@@ -1,34 +1,76 @@
11 <!-- manager.gohtml -->
22 <!DOCTYPE html>
3-<html lang="en">
3+<html lang="en" data-theme="gordon">
44 {{template "header.gohtml" .}}
55
6- <body class="bg-zinc-200 flex flex-col lg:flex-row p-0">
6+ <body class="min-h-screen bg-base-200">
77 {{template "menu.gohtml" .}}
8- <!-- Main content here -->
9- <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6">
10- <div class="flex flex-col md:flex-row w-full flex-grow">
11- <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4">
12- <!-- List all the containers -->
13- <div
14- id="container-manager"
15- hx-get="/htmx/container-manager"
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>
8+
9+ <!-- Main content -->
10+ <div class="container mx-auto py-6 px-4 mt-16">
11+ <div class="text-sm breadcrumbs mb-4">
12+ <ul>
13+ <li><a href="{{ .AdminPath }}">Gordon</a></li>
14+ <li>Manager</li>
15+ </ul>
2816 </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+
2972 </div>
3073
31- <!-- footer -->
3274 {{template "footer.gohtml" .}}
3375 </body>
3476 </html>
M internal/templating/models/html/fragments/components.gohtml +55 -12
@@ -1,20 +1,24 @@
11 <!-- @format -->
22
3-<div id="header">
4- <h1
5- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
6- >
7- {{ .body.header.helloworld }}
8- </h1>
3+<div id="header" class="hero bg-base-200">
4+ <div class="hero-content text-center">
5+ <div class="max-w-md">
6+ <h1 class="text-5xl font-bold gordon-title">
7+ {{ .body.header.helloworld }}
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>
913 </div>
1014
11-<div id="hello">
12- <h1
13- class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s"
14- >
15- {{ .body.div.hello }}
16- </h1>
15+<div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
16+ <div>
17+ <span class="iconfb">󰋽</span>
18+ <span class="IBMPlexSans">{{ .body.div.hello }}</span>
19+ </div>
1720 </div>
21+
1822 <div
1923 id="header"
2024 hx-get="/htmx"
@@ -31,3 +35,42 @@
3135 hx-trigger="load"
3236 hx-swap="outerHTML"
3337 ></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">
2- <div class="flex justify-between items-center mb-1">
3- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">
4- {{index .Lang "manager" "content" "h1-containers"}}
5- </h1>
6- <button
7- id="refresh-containers-button"
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"
9- title="Refresh container list"
10- >
11- <span class="iconf text-xl align-middle">󰑐 </span>
12- Refresh
13- </button>
14- </div>
15- <table class="p-4 border-t-2 table-auto">
16- <thead>
17- <tr class="text-center text-gray-400">
18- <th>{{index .Lang "manager" "content" "thName"}}</th>
19- <th>{{index .Lang "manager" "content" "thPorts"}}</th>
20- <th>{{index .Lang "manager" "content" "thEntryPoint"}}</th>
21- <th>{{index .Lang "manager" "content" "thStatus"}}</th>
22- <th>{{index .Lang "manager" "content" "thState"}}</th>
23- <th>{{index .Lang "manager" "content" "thActions"}}</th>
24- </tr>
25- </thead>
26- <tbody>
27- {{ $Lang := .Lang }}
28- {{
29- range.containers
30- }}
31- <tr id="container-{{.ID}}" class="text-center hover:bg-slate-100">
32- <td>
33- <span
34- title="{{.Container.ID}}"
35- class="font-bold"
36- >{{.Name}}</span
37- >
38- </td>
39- <td class="port-container">{{ .Ports }}</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>
1+<!-- Container list using daisyUI components -->
2+<div class="card w-full bg-base-300">
3+ <div class="card-body p-4">
4+
5+ <!-- Action buttons -->
6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7+ <h3 class="font-semibold text-base-content">Available Containers: {{ len .Containers }}</h3>
8+ <div class="btn-group">
9+ <button
10+ class="btn btn-sm btn-primary"
11+ hx-get="/htmx/container-manager"
12+ hx-target="#container-manager"
13+ hx-swap="innerHTML">
14+ <span class="iconf">󰑓</span> Refresh
15+ </button>
16+ </div>
17+ </div>
18+
19+ <!-- Container list -->
20+ <table class="table">
21+ <!-- head -->
22+ <thead>
23+ <tr>
24+ <th>Name</th>
25+ <th>Image</th>
26+ <th>Status</th>
27+ <th>Created</th>
28+ <th>Actions</th>
29+ </tr>
30+ </thead>
31+ <tbody>
32+ {{ if or .NoContainer (eq (len .Containers) 0) }}
33+ <tr>
34+ <td colspan="5" class="text-center py-4">
35+ <div class="alert alert-info">
36+ <span class="iconf">ℹ️</span>
37+ <span>No containers found. Upload an image first, then create a container.</span>
38+ </div>
39+ </td>
9240 </tr>
93- <tr>
94- <td colspan="7">
95- <div id="actionResponse-{{.ID}}"></div>
96- <div id="error-message-{{.ID}}" class="text-red-500"></div>
97- </td>
98- </tr>
99- {{ else }}
100- <tr>
101- <td colspan="7" class="mt-2">
102- <p class="text-center text-gray-400 text-xl">
103- {{index $Lang "manager" "content" "no-containers"}}
104- </p>
105- </td>
106- </tr>
107- {{
108- end
109- }}
110- </tbody>
111- </table>
41+ {{ else }}
42+ {{ range .Containers }}
43+ <tr class="hover">
44+ <td>
45+ <div class="flex items-center gap-3">
46+ <div>
47+ <div class="font-bold">{{ .Names }}</div>
48+ <div class="text-sm opacity-50">{{ .ID }}</div>
49+ </div>
50+ </div>
51+ </td>
52+ <td>{{ .Image }}</td>
53+ <td>
54+ {{ if eq .State "running" }}
55+ <div class="badge badge-success badge-sm">Running</div>
56+ {{ else if eq .State "exited" }}
57+ <div class="badge badge-warning badge-sm">Stopped</div>
58+ {{ else }}
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>
112118 </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 @@
11 <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets">
22 <!-- Left Side: Docker Run Options -->
3- <div class="flex-grow bg-slate-100 p-4 rounded-md">
4- <h2 class="font-bold pb-4">Deploy a container based on {{.ImageName}}</h2>
3+ <div class="flex-grow card p-6">
4+ <h2 class="card-title pb-4">Deploy a container based on {{.ImageName}}</h2>
55 {{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>
710 {{ end }}
811 <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post"
912 hx-post="/htmx/create-container/{{.ShortID}}"
@@ -11,83 +14,104 @@
1114 hx-target="#create-container-{{.ShortID}}"
1215 hx-target-*="#error-message-create-container-{{.ShortID}}">
1316 <!-- Image Name -->
14- <div class="flex space-x-4 pb-4">
15- <div class="w-1/3">
16- <label class="block">Image name</label>
17- <input type="text" name="image_name" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"
17+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
18+ <div class="md:col-span-1">
19+ <label class="label">
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"
1823 value="{{.ImageName}}" readonly />
1924 </div>
2025
21- <div class="w-2/3">
22- <label class="block">Image ID</label>
23- <input type="text" name="image_id" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2"
26+ <div class="md:col-span-2">
27+ <label class="label">
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"
2431 value="{{.ImageID}}" readonly />
2532 </div>
2633 </div>
2734
2835 <!-- Container Name -->
29- <div class="pb-4">
30- <label class="block">Container name</label>
31- <input type="text" name="container_name" class="w-full p-4 rounded-md" value="{{.ContainerName}}" />
36+ <div class="form-control pb-4">
37+ <label class="label">
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}}" />
3241 </div>
42+
3343 <!-- 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">
3545 <!-- Protocol -->
36- <div class="w-1/4">
37- <label class="block">Protocol</label>
38- <select name="container_protocol" class="w-full p-4 rounded-md border-2 bg-slate-200 border-gray-300">
46+ <div class="md:col-span-3">
47+ <label class="label">
48+ <span class="label-text">Protocol</span>
49+ </label>
50+ <select name="container_protocol" class="select select-bordered w-full">
3951 <option value="https">https</option>
4052 <option value="http">http</option>
4153 </select>
4254 </div>
4355
4456 <!-- Subdomain -->
45- <div class="w-1/3">
46- <label class="block">Subdomain</label>
47- <input type="text" name="container_subdomain" class="w-full p-4 rounded-md" value="{{.ContainerSubdomain}}" />
57+ <div class="md:col-span-4">
58+ <label class="label">
59+ <span class="label-text">Subdomain</span>
60+ </label>
61+ <input type="text" name="container_subdomain" class="input input-bordered w-full" value="{{.ContainerSubdomain}}" />
4862 </div>
4963
5064 <!-- Domain -->
51- <div class="w-5/12">
52- <label class="block">Domain</label>
53- <input type="text" name="container_domain" class="w-full p-4 rounded-md" value="{{.ContainerDomain}}" />
65+ <div class="md:col-span-5">
66+ <label class="label">
67+ <span class="label-text">Domain</span>
68+ </label>
69+ <input type="text" name="container_domain" class="input input-bordered w-full" value="{{.ContainerDomain}}" />
5470 </div>
5571 </div>
72+
5673 <!-- Exposed Ports -->
57- <div class="pb-4">
58- <label class="block">Exposed Ports (Optional)</label>
59- <span class="text-gray-500">
60- eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)
61- </span>
62- <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" />
74+ <div class="form-control pb-4">
75+ <label class="label">
76+ <span class="label-text">Exposed Ports (Optional)</span>
77+ <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
78+ </label>
79+ <input type="text" name="ports" class="input input-bordered w-full" value="{{.Ports}}" />
6380 </div>
6481
6582 <!-- Proxy Port -->
66- <div class="mb-6">
67- <label class="block">Container Proxy Port (Required)</label>
68- <div class="text-xs text-gray-500 mb-2">
69- Specify the internal container port that the proxy will route traffic to
70- </div>
71- <input type="text" name="container_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required />
83+ <div class="form-control pb-4">
84+ <label class="label">
85+ <span class="label-text">Container Proxy Port (Required)</span>
86+ <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
87+ </label>
88+ <input type="text" name="container_port" class="input input-bordered w-full" value="{{.ProxyPort}}" required />
7289 </div>
7390
7491 <!-- Volumes -->
75- <div class="pb-4">
76- <label class="block">Volumes</label>
77- <span class="text-gray-500">e.g. ./data:/container/data </span>
78- <input type="text" name="volumes" class="w-full p-4 rounded-md" value="{{.Volumes}}" />
92+ <div class="form-control pb-4">
93+ <label class="label">
94+ <span class="label-text">Volumes</span>
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}}" />
7998 </div>
8099
81- <div class="pb-4">
82- <label class="block">Environment variables</label>
83- <span class="text-gray-500">e.g. KEY=VALUE </span>
84- <textarea name="environment_variables" class="w-full p-4 rounded-md"
100+ <div class="form-control pb-4">
101+ <label class="label">
102+ <span class="label-text">Environment variables</span>
103+ <span class="label-text-alt">e.g. KEY=VALUE</span>
104+ </label>
105+ <textarea name="environment_variables" class="textarea textarea-bordered w-full"
85106 rows="4">{{.EnvironmentVariables}}</textarea>
86107 </div>
108+
87109 <!-- Restart: always/on-failure/only when stopped/never -->
88- <div class="pb-4">
89- <label class="block">Restart</label>
90- <select name="restart" class="w-full p-4 rounded-md border-2 bg-gray-100 border-gray-300">
110+ <div class="form-control pb-4">
111+ <label class="label">
112+ <span class="label-text">Restart</span>
113+ </label>
114+ <select name="restart" class="select select-bordered w-full">
91115 <option value="always">Always</option>
92116 <option value="on-failure">On failure</option>
93117 <option value="unless-stopped">Unless stopped</option>
@@ -96,20 +120,21 @@
96120 </div>
97121
98122 <!-- Skip Proxy Setup Option -->
99- <div class="pb-4">
100- <label class="flex items-center space-x-2">
101- <input type="checkbox" name="skip_proxy_setup" value="true" class="form-checkbox h-5 w-5 text-blue-600" />
123+ <div class="form-control pb-4">
124+ <label class="label cursor-pointer justify-start gap-4">
125+ <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
102126 <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
103127 </label>
104- <p class="text-sm text-gray-500 ml-7">
128+ <div class="text-sm opacity-70 ml-10">
105129 Use this option if you only want to create the container without triggering proxy setup,
106130 certificate generation, and other related processes.
107- </p>
131+ </div>
108132 </div>
109133
110- <input type="submit" value="Create" id="create-container-submit"
111- class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" />
134+ <button type="submit" id="create-container-submit" class="btn btn-primary">
135+ Create Container
136+ </button>
112137 </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>
114139 </div>
115140 </div>⏎̸
M internal/templating/models/html/fragments/editcontainer.gohtml +12 -10
@@ -1,9 +1,9 @@
11 <div
22 id="edit-container-{{.ID}}"
3- class="h-fit-content"
3+ class="card bg-base-100 shadow-xl"
44 hx-ext="response-targets"
55 >
6- <div id="editor">
6+ <div id="editor" class="card-body">
77 <form
88 method="post"
99 action="/htmx/container-manager/edit/{{.ID}}"
@@ -14,17 +14,19 @@
1414 <textarea
1515 name="container_config"
1616 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"
1818 rows="20"
1919 >{{.ContainerInfo}}</textarea
2020 >
21- <input
22- type="submit"
23- value="Save"
24- id="save-button-{{.ID}}"
25- class="bg-gordonmascot-lblue text-white font-bold px-8 py-1 rounded-md hover:bg-gordonmascot-mblue cursor-pointer"
26- />
21+ <div class="card-actions justify-end mt-4">
22+ <input
23+ type="submit"
24+ value="Save"
25+ id="save-button-{{.ID}}"
26+ class="btn btn-primary"
27+ />
28+ </div>
2729 </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>
2931 </div>
3032 </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 @@
1111 <script src="/libs/htmx-sse.js"></script>
1212 <script defer src="/libs/hyperscript.min.js"></script>
1313 <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>
1424 </head>
M internal/templating/models/html/fragments/imagelist.gohtml +95 -79
@@ -1,82 +1,98 @@
1-<div class="flex flex-col p-4">
2- <!-- Upload button aligned right of Images -->
3- <div class="flex justify-between items-center mb-1">
4- <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">Images</h1>
5- <div class="flex space-x-2">
6- <button
7- id="refresh-images-button"
8- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"
9- title="Refresh image list"
10- >
11- <span class="iconf text-xl align-middle">󰑐 </span>
12- Refresh
13- </button>
14- <button
15- class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded"
16- id="upload-button"
17- hx-get="/htmx/upload-image"
18- hx-target="#upload-image"
19- hx-swap="innerHTML"
20- title="Manually upload a .tar container image"
21- >
22- <span class="iconf text-xl align-middle">󰭾 </span>
23- Upload
24- </button>
1+<!-- Image list using daisyUI components -->
2+<div class="card w-full bg-base-300">
3+ <div class="card-body p-4">
4+
5+ <!-- Action buttons -->
6+ <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
7+ <h3 class="font-semibold text-base-content">Available Images: {{ len .Images }}</h3>
8+ <div class="flex gap-2">
9+ <button
10+ class="btn btn-sm btn-secondary"
11+ hx-get="/htmx/upload-image"
12+ hx-target="#image-manager"
13+ hx-swap="innerHTML">
14+ <span class="iconf">󰦒</span> Upload
15+ </button>
16+ <button
17+ class="btn btn-sm btn-primary"
18+ hx-get="/htmx/image-manager"
19+ hx-target="#image-manager"
20+ hx-swap="innerHTML">
21+ <span class="iconf">󰑓</span> Refresh
22+ </button>
23+ </div>
2524 </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>
5325
54- <button
55- id="remove-button-img-{{.ShortID}}"
56- hx-delete="/htmx/image-manager/delete/{{.ShortID}}"
57- hx-target="#img-{{.ShortID}}"
58- hx-target-*="#error-message-div-{{.ShortID}}"
59- hx-swap="innerHTML"
60- hx-confirm="Are you sure you want to delete this container image?"
61- class="py-2 px-4"
62- >
63- <span title="Remove this image" class="iconf text-xl text-red-600 hover:text-red-900"></span>
64- </button>
65- </td>
66- </tr>
67- <t>
68- <td colspan="4">
69- <div id="error-message-div-{{.ShortID}}" class="text-red-500"></div>
70- <div class="create-container" id="create-container-{{.ShortID}}"></div>
71- </td>
72- </tr>
73- {{ else }}
74- <tr>
75- <td colspan="4" class="mt-2">No images found.</td>
76- </tr>
77- {{
78- end
79- }}
80- </tbody>
81- </table>
26+ <!-- Image list -->
27+ <table class="table">
28+ <!-- head -->
29+ <thead>
30+ <tr>
31+ <th>Repository</th>
32+ <th>Tag</th>
33+ <th>Size</th>
34+ <th>Created</th>
35+ <th>Actions</th>
36+ </tr>
37+ </thead>
38+ <tbody>
39+ {{ if .NoImage }}
40+ <tr>
41+ <td colspan="5" class="text-center py-4">
42+ <div class="alert alert-info">
43+ <span class="iconf">ℹ️</span>
44+ <span>No images found. Upload an image first.</span>
45+ </div>
46+ </td>
47+ </tr>
48+ {{ else }}
49+ {{ range .Images }}
50+ <tr class="hover">
51+ <td>
52+ <div class="flex items-center gap-3">
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>
8294 </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-<div
2- id="menubar"
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">
4- <div class="w-full flex justify-start">
5- <h1
6- class="gordon-title text-3xl md:text-4xl text-gordonmascot-hblue tracking-tighter">
7- Gordon
8- </h1>
9- <span
10- id="actual-version"
11- class="text-gordonmascot-mblue text-xs md:text-sm pr-1 JetBrainsMonoNF">
12- {{ .BuildVersion }}
13- </span>
14- <span
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>
1+<!-- DaisyUI Navbar -->
2+<div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
3+ <div class="navbar-start">
4+ <div class="flex items-center">
5+ <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
6+ Gordon
7+ </h1>
8+ <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
9+ {{ .BuildVersion }}
10+ </span>
11+ <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
12+ <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
13+ </span>
14+ </div>
2115 </div>
22- <div class="w-full">
23- <ul
24- class="flex flex-row space-y-6 font-semibold text-gray-100 text-lg md:text-xl justify-center">
25- <li class="">
26- <span class="JetBrainsMonoBoldNF pr-1"> </span>
27- <a href="{{ .AdminPath }}/manager">Manager</a>
16+ <div class="navbar-center hidden lg:flex">
17+ <ul class="menu menu-horizontal px-1">
18+ <li>
19+ <a href="{{ .AdminPath }}/manager" class="font-semibold">
20+ <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
21+ </a>
2822 </li>
2923 </ul>
3024 </div>
31- <div class="w-full flex space-x-4 justify-end">
32- <a
33- href="{{ .UserSettingsURL }}"
34- title="User settings"
35- class="iconf text-gordonmascot-mblue text-lg md:text-xl"
36- ></a
37- >
38- <a
39- href="{{ .LogoutURL }}"
40- title="Logout"
41- class="iconf text-gordonmascot-mblue text-lg md:text-xl"
42- >󰍃</a
43- >
25+ <div class="navbar-end">
26+ <!-- Theme toggle -->
27+ <div class="dropdown dropdown-end">
28+ <label tabindex="0" class="btn btn-ghost btn-circle">
29+ <span class="iconf text-lg md:text-xl">󰔎</span>
30+ </label>
31+ <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
32+ <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
33+ <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
34+ <li><button onclick="setTheme('light')">Light</button></li>
35+ <li><button onclick="setTheme('dark')">Dark</button></li>
36+ </ul>
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>
4457 </div>
4558 </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 @@
11 <div
22 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">
44 <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p>
55 </div>
M internal/templating/models/html/fragments/uploadimage.gohtml +53 -27
@@ -1,35 +1,61 @@
1-<!-- Form to upload a container image (.tar format) -->
2-<div
3- class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"
4- hx-ext="response-targets"
5- hx-boost="true">
6- <div class="flex-grow bg-slate-100 p-4 rounded-md">
7- <h2>Upload Image Containers</h2>
8- <!-- Upload Form -->
1+<!-- Upload image form using DaisyUI components -->
2+<div class="card bg-base-100 shadow">
3+ <div class="card-body">
4+ <div class="flex justify-between items-center">
5+ <h2 class="card-title text-primary">Upload Image</h2>
6+ <button
7+ class="btn btn-sm btn-circle btn-ghost"
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+
916 <form
10- action="/upload"
11- method="post"
12- enctype="multipart/form-data"
17+ id="upload-form"
18+ hx-encoding="multipart/form-data"
1319 hx-post="/htmx/upload-image"
14- hx-swap="innerHTML"
1520 hx-target="#image-manager"
16- hx-target-*="#error-message-div">
17- <div
18- class="upload-zone w-full h-44 bg-slate-200 rounded-md p-4"
19- hx-drop="true">
20- Drag & Drop your file here or
21- <input
22- type="file"
23- name="file"
21+ hx-swap="innerHTML"
22+ class="space-y-4">
23+
24+ <div class="alert alert-info shadow-lg mb-4">
25+ <span class="iconf">ℹ️</span>
26+ <div>
27+ <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
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"
2440 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>
2658 </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" />
3259 </form>
33- <div id="error-message-div" class="text-red-500"></div>
3460 </div>
3561 </div>
M internal/templating/models/html/login/index.gohtml +79 -63
@@ -1,5 +1,5 @@
11 <!doctype html>
2-<html lang="en">
2+<html lang="en" data-theme="corporate">
33 <head>
44 <meta charset="UTF-8" />
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -12,95 +12,111 @@
1212 <script src="/libs/htmx.min.js"></script>
1313 <!-- HTMX SSE -->
1414 <script src="/libs/htmx-sse.js"></script>
15+ <!-- DaisyUI -->
16+ <script src="/libs/daisyui.js"></script>
1517 <!-- Custom JS from assets/js -->
1618 <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>
1727 </head>
1828
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">
2030 <!-- Logo and Title -->
2131 <div
2232 id="logo-mascot"
2333 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">
2535 <img
2636 src="/assets/imgs/gordon-mascot-mq-trsp.png"
2737 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" />
2939 </div>
3040 <div class="flex justify-center mt-4">
3141 <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">
3343 Gordon
3444 </h1>
3545 <span
36- class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2"
46+ class="gordon-v-subtitle text-secondary text-sm ml-2"
3747 >{{ .BuildVersion }}</span
3848 >
3949 </div>
4050 </div>
4151
4252 <!-- Main Content -->
43- <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3">
44- <!-- Login Section -->
45- <div id="login-section">
46- <div class="flex flex-col items-center">
47- <h2 class="text-2xl font-semibold text-gray-800">Login</h2>
48-
49- <!-- Error Message Display -->
50- {{ if .Error }}
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">
52- <span class="block sm:inline">
53- {{ if eq .Error "empty_token" }}
54- Please enter a token.
55- {{ else if eq .Error "invalid_token" }}
56- Invalid token. Please try again.
57- {{ else if eq .Error "server_error" }}
58- A server error occurred. Please try again later.
59- {{ else }}
60- An error occurred. Please try again.
61- {{ end }}
62- </span>
63- </div>
64- {{ end }}
65-
66- <!-- Token Input Form -->
67- <div class="mt-4 w-full max-w-md">
68- <form action="login/submit-token" method="POST" class="mb-4">
69- <div class="mb-2">
70- <label for="token" class="block text-sm font-medium text-gray-700">Admin Token</label>
71- <input
72- type="text"
73- id="token"
74- name="token"
75- placeholder="Enter your admin token"
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"
77- required
78- />
79- </div>
80- <button
81- type="submit"
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"
53+ <div class="card bg-base-100 shadow-xl w-full max-w-md">
54+ <div class="card-body">
55+ <!-- Login Section -->
56+ <div id="login-section">
57+ <div class="flex flex-col items-center">
58+ <h2 class="card-title text-center mb-4">Login</h2>
59+
60+ <!-- Error Message Display -->
61+ {{ if .Error }}
62+ <div class="alert alert-error mb-4">
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>
64+ <span>
65+ {{ if eq .Error "empty_token" }}
66+ Please enter a token.
67+ {{ else if eq .Error "invalid_token" }}
68+ Invalid token. Please try again.
69+ {{ else if eq .Error "server_error" }}
70+ A server error occurred. Please try again later.
71+ {{ else }}
72+ An error occurred. Please try again.
73+ {{ end }}
74+ </span>
75+ </div>
76+ {{ end }}
77+
78+ <!-- Token Input Form -->
79+ <div class="w-full">
80+ <form action="login/submit-token" method="POST" class="mb-4">
81+ <div class="form-control mb-4">
82+ <label for="token" class="label">
83+ <span class="label-text">Admin Token</span>
84+ </label>
85+ <input
86+ type="text"
87+ id="token"
88+ name="token"
89+ placeholder="Enter your admin token"
90+ class="input input-bordered w-full"
91+ required
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"
83109 >
84- Continue with Token
85- </button>
86- </form>
87- <div class="text-center text-sm text-gray-500 mb-4">- OR -</div>
88- </div>
89-
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>
110+ <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
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" />
112+ </svg>
113+ Sign in with GitHub
114+ </a>
115+ </div>
100116 </div>
101117 </div>
118+ <!-- End Login Section -->
102119 </div>
103- <!-- End Login Section -->
104120 </div>
105121 </body>
106122 </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 @@
66
77 /* Embeded path : assets/css/cutsom.css */
88
9-@tailwind base;
10-@tailwind components;
11-@tailwind utilities;
9+@import "tailwindcss";
10+@source "../../../templating/models/html/**/*.{gohtml,go}";
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+}
12121
13122 /* Import IBM Plex Fonts */
14123 @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() {
187187
188188 // Show loading indicator
189189 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+
190198 const originalContent = containerManager.html();
191199 containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading containers...</div>');
192200
201+ // Ensure the container is visible
202+ containerManager.removeClass('hidden');
203+
193204 $.ajax({
194205 url: '/htmx/container-manager',
195206 type: 'GET',
196207 success: function(data) {
208+ console.debug("Container data received, length:", data.length);
197209 $('#container-manager').html(data);
210+
211+ // Force visibility
212+ $('#container-manager').removeClass('hidden');
213+
198214 console.debug("Container list refreshed successfully");
199215 },
200216 error: function(xhr, status, error) {
201217 console.error("Error refreshing container list:", error);
218+ console.error("Status:", status);
219+ console.error("Response:", xhr.responseText);
202220 // Restore original content on error
203221 containerManager.html(originalContent);
204222 }
@@ -211,6 +229,14 @@ function refreshImageList() {
211229
212230 // Show loading indicator
213231 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+
214240 const originalContent = imageManager.html();
215241 imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading images...</div>');
216242
@@ -218,11 +244,14 @@ function refreshImageList() {
218244 url: '/htmx/image-manager',
219245 type: 'GET',
220246 success: function(data) {
247+ console.debug("Image data received, length:", data.length);
221248 $('#image-manager').html(data);
222249 console.debug("Image list refreshed successfully");
223250 },
224251 error: function(xhr, status, error) {
225252 console.error("Error refreshing image list:", error);
253+ console.error("Status:", status);
254+ console.error("Response:", xhr.responseText);
226255 // Restore original content on error
227256 imageManager.html(originalContent);
228257 }
@@ -236,6 +265,9 @@ function setupHtmxEventListeners() {
236265 const targetId = event.detail.target.id;
237266 console.debug(`HTMX afterOnLoad event for target: ${targetId}`);
238267
268+ // Remove the tab visibility fix which was interfering with DaisyUI
269+ // DaisyUI handles tab visibility automatically with the input[type="radio"] approach
270+
239271 // Check if the target is an action response
240272 if (targetId && targetId.startsWith('actionResponse-')) {
241273 console.debug(`Action completed for ${targetId}`);
@@ -257,7 +289,7 @@ function setupHtmxEventListeners() {
257289 console.debug('Image manager updated');
258290
259291 // 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')) {
261293 showNotification('Image uploaded successfully', 'success');
262294 }
263295
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 @@
11 {
2+ "type": "module",
23 "devDependencies": {
4+ "daisyui": "^5.0.6",
35 "prettier": "^3.5.3",
46 "prettier-plugin-tailwindcss": "^0.5.14",
57 "tailwindcss": "^4.0.14"
@@ -9,7 +11,7 @@
911 "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css"
1012 },
1113 "dependencies": {
12- "caniuse-lite": "^1.0.30001705",
14+ "@tailwindcss/cli": "^4.0.14",
1315 "dotenv": "^16.4.7"
1416 }
1517 }
M pkg/docker/client.go +19 -3
@@ -28,14 +28,15 @@ func InitializeClient(config *Config) error {
2828 return fmt.Errorf("sock field in Config is empty")
2929 }
3030
31+ logger.Debug("Checking socket file existence", "socket_path", config.Sock)
3132 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)
3334 return err
3435 }
3536
3637 // For Podman compatibility, we need to ensure we're using the right connection approach
3738 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)
3940
4041 // Create the Docker client with appropriate options
4142 cli, err := client.NewClientWithOpts(
@@ -47,7 +48,18 @@ func InitializeClient(config *Config) error {
4748 return fmt.Errorf("error initializing Docker client: %s", err)
4849 }
4950
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)
5163 dockerCli = cli
5264 currentConfig = config
5365 return nil
@@ -56,17 +68,20 @@ func InitializeClient(config *Config) error {
5668 func CheckIfInitialized() error {
5769 if currentConfig == nil || currentConfig.Sock == "" {
5870 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")
5972 return fmt.Errorf("docker client is not initialized or configuration is missing")
6073 }
6174
6275 socketPath := currentConfig.Sock
6376 if _, err := os.Stat(socketPath); os.IsNotExist(err) {
6477 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)
6579 return fmt.Errorf("sock file does not exist: %s", socketPath)
6680 }
6781
6882 if dockerCli == nil {
6983 logger.Error("Docker client check failed", "error", "client is nil")
84+ logger.Debug("Docker client check failed: client is nil")
7085 return fmt.Errorf("docker client is not initialized")
7186 }
7287
@@ -76,6 +91,7 @@ func CheckIfInitialized() error {
7691 _, err := dockerCli.ContainerList(ctx, container.ListOptions{})
7792 if err != nil {
7893 logger.Error("Docker client check failed", "error", err)
94+ logger.Debug("Docker client check failed: cannot connect to Docker daemon", "error", err)
7995 return fmt.Errorf("cannot connect to Docker daemon: %s", err)
8096 }
8197
M pkg/docker/containers.go +7 -5
@@ -100,10 +100,7 @@ func ListRunningContainers() ([]types.Container, error) {
100100 if err != nil {
101101 return nil, err
102102 }
103- // Check if the list is empty
104- if len(containers) == 0 {
105- return nil, fmt.Errorf("no containers found")
106- }
103+ // Just return the containers, even if it's an empty slice
107104 return containers, nil
108105 }
109106
@@ -420,6 +417,11 @@ func CheckIfNetworkExists(networkName string) (bool, error) {
420417
421418 // GetNetworkInfo returns information about a network
422419 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+
423425 networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{})
424426 if err != nil {
425427 return nil, err
@@ -947,7 +949,7 @@ func ListenForContainerEvents(networkFilter string, startCallback func(string, s
947949 logger.Debug("Container stopped",
948950 "container_id", event.id,
949951 "container_name", event.name)
950-
952+
951953 // Call the stop callback if provided
952954 if stopCallback != nil {
953955 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 = {
1414 lbeige: "#FFDCB3",
1515 mbeige: "#D7B49F",
1616 },
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+ },
1747 },
1848 },
1949 },
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+ },
2059 };
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